diff --git a/powerdnsadmin/lib/utils.py b/powerdnsadmin/lib/utils.py index 19009de..b6e398f 100644 --- a/powerdnsadmin/lib/utils.py +++ b/powerdnsadmin/lib/utils.py @@ -3,14 +3,11 @@ import json import requests import hashlib import ipaddress -import os from distutils.version import StrictVersion from urllib.parse import urlparse from datetime import datetime, timedelta -from .certutil import KEY_FILE, CERT_FILE - def auth_from_url(url): auth = None @@ -184,101 +181,6 @@ def email_to_gravatar_url(email="", size=100): return "https://s.gravatar.com/avatar/{0}?s={1}".format(hash_string, size) -def prepare_flask_request(request): - # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields - url_data = urlparse(request.url) - return { - 'https': 'on' if request.scheme == 'https' else 'off', - 'http_host': request.host, - 'server_port': url_data.port, - 'script_name': request.path, - 'get_data': request.args.copy(), - 'post_data': request.form.copy(), - # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 - 'lowercase_urlencoding': True, - 'query_string': request.query_string - } - - -def init_saml_auth(req): - own_url = '' - if req['https'] == 'on': - own_url = 'https://' - else: - own_url = 'http://' - own_url += req['http_host'] - metadata = get_idp_data() - settings = {} - settings['sp'] = {} - if 'SAML_NAMEID_FORMAT' in app.config: - settings['sp']['NameIDFormat'] = app.config['SAML_NAMEID_FORMAT'] - else: - settings['sp']['NameIDFormat'] = idp_data.get('sp', {}).get( - 'NameIDFormat', - 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified') - settings['sp']['entityId'] = app.config['SAML_SP_ENTITY_ID'] - if os.path.isfile(CERT_FILE): - cert = open(CERT_FILE, "r").readlines() - settings['sp']['x509cert'] = "".join(cert) - if os.path.isfile(KEY_FILE): - key = open(KEY_FILE, "r").readlines() - settings['sp']['privateKey'] = "".join(key) - settings['sp']['assertionConsumerService'] = {} - settings['sp']['assertionConsumerService'][ - 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' - settings['sp']['assertionConsumerService'][ - 'url'] = own_url + '/saml/authorized' - settings['sp']['attributeConsumingService'] = {} - settings['sp']['singleLogoutService'] = {} - settings['sp']['singleLogoutService'][ - 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' - settings['sp']['singleLogoutService']['url'] = own_url + '/saml/sls' - settings['idp'] = metadata['idp'] - settings['strict'] = True - settings['debug'] = app.config['SAML_DEBUG'] - settings['security'] = {} - settings['security'][ - 'digestAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' - settings['security']['metadataCacheDuration'] = None - settings['security']['metadataValidUntil'] = None - settings['security']['requestedAuthnContext'] = True - settings['security'][ - 'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' - settings['security']['wantAssertionsEncrypted'] = False - settings['security']['wantAttributeStatement'] = True - settings['security']['wantNameId'] = True - settings['security']['authnRequestsSigned'] = app.config[ - 'SAML_SIGN_REQUEST'] - settings['security']['logoutRequestSigned'] = app.config[ - 'SAML_SIGN_REQUEST'] - settings['security']['logoutResponseSigned'] = app.config[ - 'SAML_SIGN_REQUEST'] - settings['security']['nameIdEncrypted'] = False - settings['security']['signMetadata'] = True - settings['security']['wantAssertionsSigned'] = True - settings['security']['wantMessagesSigned'] = app.config.get( - 'SAML_WANT_MESSAGE_SIGNED', True) - settings['security']['wantNameIdEncrypted'] = False - settings['contactPerson'] = {} - settings['contactPerson']['support'] = {} - settings['contactPerson']['support']['emailAddress'] = app.config[ - 'SAML_SP_CONTACT_NAME'] - settings['contactPerson']['support']['givenName'] = app.config[ - 'SAML_SP_CONTACT_MAIL'] - settings['contactPerson']['technical'] = {} - settings['contactPerson']['technical']['emailAddress'] = app.config[ - 'SAML_SP_CONTACT_NAME'] - settings['contactPerson']['technical']['givenName'] = app.config[ - 'SAML_SP_CONTACT_MAIL'] - settings['organization'] = {} - settings['organization']['en-US'] = {} - settings['organization']['en-US']['displayname'] = 'PowerDNS-Admin' - settings['organization']['en-US']['name'] = 'PowerDNS-Admin' - settings['organization']['en-US']['url'] = own_url - auth = OneLogin_Saml2_Auth(req, settings) - return auth - - def display_setting_state(value): if value == 1: return "ON" diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 2696866..812fbe9 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -28,11 +28,13 @@ from ..services.google import google_oauth from ..services.github import github_oauth from ..services.azure import azure_oauth from ..services.oidc import oidc_oauth +from ..services.saml import SAML google = None github = None azure = None oidc = None +saml = None index_bp = Blueprint('index', __name__, @@ -46,10 +48,12 @@ def register_modules(): global github global azure global oidc + global saml google = google_oauth() github = github_oauth() azure = azure_oauth() oidc = oidc_oauth() + saml = SAML() @index_bp.before_request @@ -351,8 +355,8 @@ def logout(): 'SAML_ENABLED' ) and 'samlSessionIndex' in session and current_app.config.get( 'SAML_LOGOUT'): - req = utils.prepare_flask_request(request) - auth = utils.init_saml_auth(req) + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) if current_app.config.get('SAML_LOGOUT_URL'): return redirect( auth.logout( @@ -579,10 +583,10 @@ def dyndns_update(): def saml_login(): if not current_app.config.get('SAML_ENABLED'): abort(400) - req = utils.prepare_flask_request(request) - auth = utils.init_saml_auth(req) + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) redirect_url = OneLogin_Saml2_Utils.get_self_url(req) + url_for( - 'saml_authorized') + 'index.saml_authorized') return redirect(auth.login(return_to=redirect_url)) @@ -592,8 +596,8 @@ def saml_metadata(): current_app.logger.error("SAML authentication is disabled.") abort(400) - req = utils.prepare_flask_request(request) - auth = utils.init_saml_auth(req) + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) settings = auth.get_settings() metadata = settings.get_sp_metadata() errors = settings.validate_metadata(metadata) @@ -612,8 +616,8 @@ def saml_authorized(): if not current_app.config.get('SAML_ENABLED'): current_app.logger.error("SAML authentication is disabled.") abort(400) - req = utils.prepare_flask_request(request) - auth = utils.init_saml_auth(req) + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) auth.process_response() errors = auth.get_errors() if len(errors) == 0: @@ -719,7 +723,7 @@ def saml_authorized(): session['authentication_type'] = 'SAML' login_user(user, remember=False) signin_history(user.username, 'SAML', True) - return redirect(url_for('index')) + return redirect(url_for('index.login')) else: return render_template('errors/SAML.html', errors=errors) @@ -765,8 +769,8 @@ def uplift_to_admin(user): @index_bp.route('/saml/sls') def saml_logout(): - req = utils.prepare_flask_request(request) - auth = utils.init_saml_auth(req) + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) url = auth.process_slo() errors = auth.get_errors() if len(errors) == 0: diff --git a/powerdnsadmin/services/saml.py b/powerdnsadmin/services/saml.py new file mode 100644 index 0000000..c131826 --- /dev/null +++ b/powerdnsadmin/services/saml.py @@ -0,0 +1,163 @@ +from datetime import datetime, timedelta +from threading import Thread +from flask import current_app +import os + +from ..lib.certutil import KEY_FILE, CERT_FILE +from ..lib.utils import urlparse + + +class SAML(object): + def __init__(self): + if current_app.config['SAML_ENABLED']: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + + self.idp_timestamp = datetime.now() + self.OneLogin_Saml2_Auth = OneLogin_Saml2_Auth + self.OneLogin_Saml2_IdPMetadataParser = OneLogin_Saml2_IdPMetadataParser + self.idp_data = None + + if 'SAML_IDP_ENTITY_ID' in current_app.config: + self.idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote( + current_app.config['SAML_METADATA_URL'], + entity_id=current_app.config.get('SAML_IDP_ENTITY_ID', + None), + required_sso_binding=current_app. + config['SAML_IDP_SSO_BINDING']) + else: + self.idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote( + current_app.config['SAML_METADATA_URL'], + entity_id=current_app.config.get('SAML_IDP_ENTITY_ID', + None)) + if self.idp_data is None: + current_app.logger.info( + 'SAML: IDP Metadata initial load failed') + exit(-1) + + def get_idp_data(self): + + lifetime = timedelta( + minutes=current_app.config['SAML_METADATA_CACHE_LIFETIME']) + + if self.idp_timestamp + lifetime < datetime.now(): + background_thread = Thread(target=self.retrieve_idp_data()) + background_thread.start() + + return self.idp_data + + def retrieve_idp_data(self): + + if 'SAML_IDP_SSO_BINDING' in current_app.config: + new_idp_data = self.OneLogin_Saml2_IdPMetadataParser.parse_remote( + current_app.config['SAML_METADATA_URL'], + entity_id=current_app.config.get('SAML_IDP_ENTITY_ID', None), + required_sso_binding=current_app.config['SAML_IDP_SSO_BINDING'] + ) + else: + new_idp_data = self.OneLogin_Saml2_IdPMetadataParser.parse_remote( + current_app.config['SAML_METADATA_URL'], + entity_id=current_app.config.get('SAML_IDP_ENTITY_ID', None)) + if new_idp_data is not None: + self.idp_data = new_idp_data + self.idp_timestamp = datetime.now() + current_app.logger.info( + "SAML: IDP Metadata successfully retrieved from: " + + current_app.config['SAML_METADATA_URL']) + else: + current_app.logger.info( + "SAML: IDP Metadata could not be retrieved") + + def prepare_flask_request(self, request): + # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields + url_data = urlparse(request.url) + return { + 'https': 'on' if request.scheme == 'https' else 'off', + 'http_host': request.host, + 'server_port': url_data.port, + 'script_name': request.path, + 'get_data': request.args.copy(), + 'post_data': request.form.copy(), + # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + 'lowercase_urlencoding': True, + 'query_string': request.query_string + } + + def init_saml_auth(self, req): + own_url = '' + if req['https'] == 'on': + own_url = 'https://' + else: + own_url = 'http://' + own_url += req['http_host'] + metadata = self.get_idp_data() + settings = {} + settings['sp'] = {} + if 'SAML_NAMEID_FORMAT' in current_app.config: + settings['sp']['NameIDFormat'] = current_app.config[ + 'SAML_NAMEID_FORMAT'] + else: + settings['sp']['NameIDFormat'] = self.idp_data.get('sp', {}).get( + 'NameIDFormat', + 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified') + settings['sp']['entityId'] = current_app.config['SAML_SP_ENTITY_ID'] + if os.path.isfile(CERT_FILE): + cert = open(CERT_FILE, "r").readlines() + settings['sp']['x509cert'] = "".join(cert) + if os.path.isfile(KEY_FILE): + key = open(KEY_FILE, "r").readlines() + settings['sp']['privateKey'] = "".join(key) + settings['sp']['assertionConsumerService'] = {} + settings['sp']['assertionConsumerService'][ + 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + settings['sp']['assertionConsumerService'][ + 'url'] = own_url + '/saml/authorized' + settings['sp']['attributeConsumingService'] = {} + settings['sp']['singleLogoutService'] = {} + settings['sp']['singleLogoutService'][ + 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + settings['sp']['singleLogoutService']['url'] = own_url + '/saml/sls' + settings['idp'] = metadata['idp'] + settings['strict'] = True + settings['debug'] = current_app.config['SAML_DEBUG'] + settings['security'] = {} + settings['security'][ + 'digestAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + settings['security']['metadataCacheDuration'] = None + settings['security']['metadataValidUntil'] = None + settings['security']['requestedAuthnContext'] = True + settings['security'][ + 'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + settings['security']['wantAssertionsEncrypted'] = True + settings['security']['wantAttributeStatement'] = True + settings['security']['wantNameId'] = True + settings['security']['authnRequestsSigned'] = current_app.config[ + 'SAML_SIGN_REQUEST'] + settings['security']['logoutRequestSigned'] = current_app.config[ + 'SAML_SIGN_REQUEST'] + settings['security']['logoutResponseSigned'] = current_app.config[ + 'SAML_SIGN_REQUEST'] + settings['security']['nameIdEncrypted'] = False + settings['security']['signMetadata'] = True + settings['security']['wantAssertionsSigned'] = True + settings['security']['wantMessagesSigned'] = current_app.config.get( + 'SAML_WANT_MESSAGE_SIGNED', True) + settings['security']['wantNameIdEncrypted'] = False + settings['contactPerson'] = {} + settings['contactPerson']['support'] = {} + settings['contactPerson']['support'][ + 'emailAddress'] = current_app.config['SAML_SP_CONTACT_NAME'] + settings['contactPerson']['support']['givenName'] = current_app.config[ + 'SAML_SP_CONTACT_MAIL'] + settings['contactPerson']['technical'] = {} + settings['contactPerson']['technical'][ + 'emailAddress'] = current_app.config['SAML_SP_CONTACT_MAIL'] + settings['contactPerson']['technical'][ + 'givenName'] = current_app.config['SAML_SP_CONTACT_NAME'] + settings['organization'] = {} + settings['organization']['en-US'] = {} + settings['organization']['en-US']['displayname'] = 'PowerDNS-Admin' + settings['organization']['en-US']['name'] = 'PowerDNS-Admin' + settings['organization']['en-US']['url'] = own_url + auth = self.OneLogin_Saml2_Auth(req, settings) + return auth diff --git a/powerdnsadmin/static/custom/css/custom.css b/powerdnsadmin/static/custom/css/custom.css index 7b8e281..474d77a 100644 --- a/powerdnsadmin/static/custom/css/custom.css +++ b/powerdnsadmin/static/custom/css/custom.css @@ -1,5 +1,6 @@ .length-break { word-break: break-all !important; + width: 70% !important; } table td { diff --git a/powerdnsadmin/templates/domain.html b/powerdnsadmin/templates/domain.html index 8eb4797..9d45d9a 100644 --- a/powerdnsadmin/templates/domain.html +++ b/powerdnsadmin/templates/domain.html @@ -138,7 +138,7 @@ "columnDefs": [ { type: 'natural', - targets: [0, 5] + targets: [0, 4] }, { // hidden column so that we can add new records on top @@ -148,7 +148,7 @@ }, { className: "length-break", - targets: [ 5 ] + targets: [ 4, 5 ] } ], "orderFixed": [[8, 'asc']] diff --git a/powerdnsadmin/templates/template_edit.html b/powerdnsadmin/templates/template_edit.html index 820a2e4..f716293 100644 --- a/powerdnsadmin/templates/template_edit.html +++ b/powerdnsadmin/templates/template_edit.html @@ -62,10 +62,10 @@ {{ record.ttl }} - + {{ record.data }} - + {{ record.comment }} @@ -123,7 +123,7 @@ "columnDefs": [ { type: 'natural', - targets: [0, 5] + targets: [0, 4] }, { // hidden column so that we can add new records on top @@ -133,7 +133,7 @@ }, { className: "length-break", - targets: [ 5 ] + targets: [ 4, 5 ] } ], "orderFixed": [[8, 'asc']]