diff --git a/configs/development.py b/configs/development.py index 2c2e63d..1359da1 100644 --- a/configs/development.py +++ b/configs/development.py @@ -37,117 +37,6 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') # MAIL_PASSWORD = None # MAIL_DEFAULT_SENDER = ('PowerDNS-Admin', 'noreply@domain.ltd') -# SAML Authnetication -SAML_ENABLED = False -# SAML_DEBUG = True -# SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml') -# ##Example for ADFS Metadata-URL -# SAML_METADATA_URL = 'https:///FederationMetadata/2007-06/FederationMetadata.xml' -# #Cache Lifetime in Seconds -# SAML_METADATA_CACHE_LIFETIME = 1 - -# # SAML SSO binding format to use -# ## Default: library default (urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect) -# #SAML_IDP_SSO_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' - -# ## EntityID of the IdP to use. Only needed if more than one IdP is -# ## in the SAML_METADATA_URL -# ### Default: First (only) IdP in the SAML_METADATA_URL -# ### Example: https://idp.example.edu/idp -# #SAML_IDP_ENTITY_ID = 'https://idp.example.edu/idp' -# ## NameID format to request -# ### Default: The SAML NameID Format in the metadata if present, -# ### otherwise urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified -# ### Example: urn:oid:0.9.2342.19200300.100.1.1 -# #SAML_NAMEID_FORMAT = 'urn:oid:0.9.2342.19200300.100.1.1' - -# Following parameter defines RequestedAttributes section in SAML metadata -# since certain iDPs require explicit attribute request. If not provided section -# will not be available in metadata. -# -# Possible attributes: -# name (mandatory), nameFormat, isRequired, friendlyName -# -# NOTE: This parameter requires to be entered in valid JSON format as displayed below -# and multiple attributes can given -# -# Following example: -# -# SAML_SP_REQUESTED_ATTRIBUTES = '[ \ -# {"name": "urn:oid:0.9.2342.19200300.100.1.3", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "email"}, \ -# {"name": "mail", "isRequired": false, "friendlyName": "test-field"} \ -# ]' -# -# produces following metadata section: -# -# -# -# - - -# ## Attribute to use for Email address -# ### Default: email -# ### Example: urn:oid:0.9.2342.19200300.100.1.3 -# #SAML_ATTRIBUTE_EMAIL = 'urn:oid:0.9.2342.19200300.100.1.3' - -# ## Attribute to use for Given name -# ### Default: givenname -# ### Example: urn:oid:2.5.4.42 -# #SAML_ATTRIBUTE_GIVENNAME = 'urn:oid:2.5.4.42' - -# ## Attribute to use for Surname -# ### Default: surname -# ### Example: urn:oid:2.5.4.4 -# #SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4' - -# ## Attribute to use for username -# ### Default: Use NameID instead -# ### Example: urn:oid:0.9.2342.19200300.100.1.1 -# #SAML_ATTRIBUTE_USERNAME = 'urn:oid:0.9.2342.19200300.100.1.1' - -# ## Attribute to get admin status from -# ### Default: Don't control admin with SAML attribute -# ### Example: https://example.edu/pdns-admin -# ### If set, look for the value 'true' to set a user as an administrator -# ### If not included in assertion, or set to something other than 'true', -# ### the user is set as a non-administrator user. -# #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin' - -# ## Attribute to get account names from -# ### Default: Don't control accounts with SAML attribute -# ### If set, the user will be added and removed from accounts to match -# ### what's in the login assertion. Accounts that don't exist will -# ### be created and the user added to them. -# SAML_ATTRIBUTE_ACCOUNT = 'https://example.edu/pdns-account' - -# SAML_SP_ENTITY_ID = 'http://' -# SAML_SP_CONTACT_NAME = '' -# SAML_SP_CONTACT_MAIL = '' - -# Configures the path to certificate file and it's respective private key file -# This pair is used for signing metadata, encrypting tokens and all other signing/encryption -# tasks during communication between iDP and SP -# NOTE: if this two parameters aren't explicitly provided, self-signed certificate-key pair -# will be generated in "PowerDNS-Admin" root directory -# ########################################################################################### -# CAUTION: For production use, usage of self-signed certificates it's highly discouraged. -# Use certificates from trusted CA instead -# ########################################################################################### -# SAML_CERT_FILE = '/etc/pki/powerdns-admin/cert.crt' -# SAML_CERT_KEY = '/etc/pki/powerdns-admin/key.pem' - -# Configures if SAML tokens should be encrypted. -# SAML_SIGN_REQUEST = False -# #Use SAML standard logout mechanism retreived from idp metadata -# #If configured false don't care about SAML session on logout. -# #Logout from PowerDNS-Admin only and keep SAML session authenticated. -# SAML_LOGOUT = False -# #Configure to redirect to a different url then PowerDNS-Admin login after SAML logout -# #for example redirect to google.com after successful saml logout -# #SAML_LOGOUT_URL = 'https://google.com' - -# #SAML_ASSERTION_ENCRYPTED = True - # Remote authentication settings # Whether to enable remote user authentication or not diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index 16b8161..04c1246 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -27,8 +27,4 @@ SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( ) ### DATABASE - SQLite -# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') - -# SAML Authnetication -SAML_ENABLED = False -SAML_ASSERTION_ENCRYPTED = True +# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') \ No newline at end of file diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index 33b8db5..a460ebb 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -110,6 +110,51 @@ class Setting(db.Model): 'oidc_oauth_email': 'email', 'oidc_oauth_account_name_property': '', 'oidc_oauth_account_description_property': '', + 'saml_enabled': False, + 'saml_debug': True, + 'saml_metadata_url': 'https://example.com/metadata.xml', + 'saml_metadata_cache_lifetime': '15', + 'saml_idp_sso_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'saml_idp_slo_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'saml_sp_acs_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'saml_sp_sls_binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'saml_idp_entity_id': 'https://idp.example.com/idp', + 'saml_nameid_format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', + 'saml_sp_requested_attributes': '[ \ + {"name": "urn:oid:0.9.2342.19200300.100.1.1", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "username" }, \ + {"name": "urn:oid:0.9.2342.19200300.100.1.3", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "email"}, \ + {"name": "urn:oid:2.5.4.42", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": false, "friendlyName": "givenname"}, \ + {"name": "urn:oid:2.5.4.4", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": false, "friendlyName": "surname" } \ + ]', + 'saml_attribute_email': 'urn:oid:0.9.2342.19200300.100.1.3', + 'saml_attribute_givenname': 'urn:oid:2.5.4.42', + 'saml_attribute_surname': 'urn:oid:2.5.4.4', + 'saml_attribute_username': 'urn:oid:0.9.2342.19200300.100.1.1', + 'saml_attribute_admin': 'https://example.edu/pdns-admin', + 'saml_attribute_account': 'https://example.edu/pdns-account', + 'saml_attribute_name': None, + 'saml_attribute_group': None, + 'saml_group_admin_name': None, + 'saml_group_to_account_mapping': None, + 'saml_sp_entity_id': 'https://pdnsa.example.com', + 'saml_sp_contact_name': 'admin', + 'saml_sp_contact_mail': 'powerdnsadmin@organization.com', + 'saml_cert_file': '/etc/pki/powerdns-admin/cert.crt', + 'saml_cert_key': '/etc/pki/powerdns-admin/key.pem', + 'saml_sign_authn_request': False, + 'saml_sign_logout_request_response': False, + 'saml_logout': False, + 'saml_logout_url': 'https://google.com', + 'saml_want_assertions_encrypted': False, + 'saml_digest_algorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'saml_signature_algorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'saml_want_assertions_signed': False, + 'saml_sign_metadata': False, + 'saml_want_message_signed': False, + 'saml_nameid_encrypted': False, + 'saml_want_nameid_encrypted': False, + 'saml_metadata_cache_duration': None, + 'saml_metadata_valid_until': None, 'forward_records_allow_edit': { 'A': True, 'AAAA': True, diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index ca0561b..99df8fc 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -669,11 +669,11 @@ class User(db.Model): current_app.logger.warning("Cannot apply autoprovisioning on user: {}".format(e)) return entitlements - def updateUser(self, Entitlements): + def updateUser(self, Entitlements, urn_value): """ Update user associations based on ldap attribute """ - entitlements= getCorrectEntitlements(Entitlements) + entitlements= getCorrectEntitlements(Entitlements, urn_value) if len(entitlements)!=0: self.revoke_privilege(True) for entitlement in entitlements: @@ -712,12 +712,11 @@ class User(db.Model): if account!=None: account.add_user(user) -def getCorrectEntitlements(Entitlements): +def getCorrectEntitlements(Entitlements, urn_value): """ Gather a list of valid records from the ldap attribute given """ from ..models.role import Role - urn_value=Setting().get('urn_value') urnArgs=[x.lower() for x in urn_value.split(':')] entitlements=[] for Entitlement in Entitlements: diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 5573502..5275528 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -22,7 +22,6 @@ from ..models.domain_template import DomainTemplate from ..models.domain_template_record import DomainTemplateRecord from ..models.api_key import ApiKey from ..models.base import db - from ..lib.schema import ApiPlainKeySchema apikey_plain_schema = ApiPlainKeySchema(many=True) @@ -1367,7 +1366,8 @@ def has_an_auth_method(local_db_enabled=None, google_oauth_enabled=None, github_oauth_enabled=None, oidc_oauth_enabled=None, - azure_oauth_enabled=None): + azure_oauth_enabled=None, + saml_enabled=None): if local_db_enabled is None: local_db_enabled = Setting().get('local_db_enabled') if ldap_enabled is None: @@ -1380,6 +1380,8 @@ def has_an_auth_method(local_db_enabled=None, oidc_oauth_enabled = Setting().get('oidc_oauth_enabled') if azure_oauth_enabled is None: azure_oauth_enabled = Setting().get('azure_oauth_enabled') + if saml_enabled is None: + saml_enabled = Setting().get('saml_enabled') return local_db_enabled or ldap_enabled or google_oauth_enabled or github_oauth_enabled or oidc_oauth_enabled or azure_oauth_enabled @@ -1621,6 +1623,115 @@ def setting_authentication(): 'msg': 'Saved successfully. Please reload PDA to take effect.' } + elif conf_type == 'saml': + saml_enabled = True if request.form.get('saml_enabled') else False + if not has_an_auth_method(saml_enabled=saml_enabled): + result = { + 'status': + False, + 'msg': + 'Must have at least one authentication method enabled.' + } + else: + Setting().set( + 'saml_enabled', + True if request.form.get('saml_enabled') else False) + Setting().set('saml_metadata_url', + request.form.get('saml_metadata_url')) + if request.form.get('saml_metadata_cache_lifetime'): + Setting().set('saml_metadata_cache_lifetime', + request.form.get('saml_metadata_cache_lifetime')) + else: + Setting().set('saml_metadata_cache_lifetime', + Setting().defaults['saml_metadata_cache_lifetime']) + Setting().set('saml_idp_sso_binding', + request.form.get('saml_idp_sso_binding')) + Setting().set('saml_idp_slo_binding', + request.form.get('saml_idp_slo_binding')) + Setting().set('saml_idp_entity_id', + request.form.get('saml_idp_entity_id')) + Setting().set('saml_nameid_format', + request.form.get('saml_nameid_format')) + Setting().set('saml_sp_acs_binding', + request.form.get('saml_sp_acs_binding')) + Setting().set('saml_sp_sls_binding', + request.form.get('saml_sp_sls_binding')) + Setting().set('saml_sp_requested_attributes', + request.form.get('saml_sp_requested_attributes')) + Setting().set('saml_attribute_email', + request.form.get('saml_attribute_email')) + Setting().set('saml_attribute_givenname', + request.form.get('saml_attribute_givenname')) + Setting().set('saml_attribute_surname', + request.form.get('saml_attribute_surname')) + Setting().set('saml_attribute_username', + request.form.get('saml_attribute_username')) + Setting().set('saml_attribute_admin', + request.form.get('saml_attribute_admin')) + Setting().set('saml_attribute_account', + request.form.get('saml_attribute_account')) + Setting().set('saml_sp_entity_id', + request.form.get('saml_sp_entity_id')) + if request.form.get('saml_sp_contact_name'): + Setting().set('saml_sp_contact_name', + request.form.get('saml_sp_contact_name')) + else: + Setting().set('saml_sp_contact_name', + Setting().defaults['saml_sp_contact_name']) + if request.form.get('saml_sp_contact_mail'): + Setting().set('saml_sp_contact_mail', + request.form.get('saml_sp_contact_mail')) + else: + Setting().set('saml_sp_contact_mail', + Setting().defaults['saml_sp_contact_mail']) + Setting().set('saml_cert_file', + request.form.get('saml_cert_file')) + Setting().set('saml_cert_key', + request.form.get('saml_cert_key')) + Setting().set( + 'saml_sign_authn_request', + True if request.form.get('saml_sign_authn_request') else False) + Setting().set( + 'saml_sign_logout_request_response', + True if request.form.get('saml_sign_logout_request_response') else False) + Setting().set( + 'saml_logout', + True if request.form.get('saml_logout') else False) + if request.form.get('saml_logout_url'): + Setting().set('saml_logout_url', + request.form.get('saml_logout_url')) + Setting().set( + 'saml_want_assertions_encrypted', + True if request.form.get('saml_want_assertions_encrypted') else False) + Setting().set( + 'saml_want_assertions_signed', + True if request.form.get('saml_want_assertions_signed') else False) + Setting().set( + 'saml_want_nameid_encrypted', + True if request.form.get('saml_want_nameid_encrypted') else False) + Setting().set( + 'saml_nameid_encrypted', + True if request.form.get('saml_nameid_encrypted') else False) + Setting().set('saml_digest_algorithm', + request.form.get('saml_digest_algorithm')) + Setting().set('saml_signature_algorithm', + request.form.get('saml_signature_algorithm')) + Setting().set( + 'saml_want_message_signed', + True if request.form.get('saml_want_message_signed') else False) + Setting().set( + 'saml_sign_metadata', + True if request.form.get('saml_sign_metadata') else False) + Setting().set('saml_metadata_cache_duration', + request.form.get('saml_metadata_cache_duration')) + Setting().set('saml_metadata_valid_until', + request.form.get('saml_metadata_valid_until')) + + result = { + 'status': True, + 'msg': + 'Saved successfully. Please reload PDA to take effect.' + } else: return abort(400) diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 316db2d..0909a69 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -142,7 +142,7 @@ def oidc_login(): @index_bp.route('/login', methods=['GET', 'POST']) def login(): - SAML_ENABLED = current_app.config.get('SAML_ENABLED') + SAML_ENABLED = Setting().get('saml_enabled') if g.user is not None and current_user.is_authenticated: return redirect(url_for('dashboard.dashboard')) @@ -496,7 +496,7 @@ def login(): elif len(Entitlements)!=0: if checkForPDAEntries(Entitlements, urn_value): - user.updateUser(Entitlements) + user.updateUser(Entitlements, urn_value) else: current_app.logger.warning('Not a single powerdns-admin record was found, possibly a typo in the prefix') if Setting().get('purge'): @@ -594,26 +594,33 @@ def prepare_welcome_user(user_id): @index_bp.route('/logout') def logout(): - if current_app.config.get( - 'SAML_ENABLED' - ) and 'samlSessionIndex' in session and current_app.config.get( - 'SAML_LOGOUT'): + if Setting().get('saml_enabled' + ) and 'samlSessionIndex' in session and Setting().get( + 'saml_logout'): req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) - if current_app.config.get('SAML_LOGOUT_URL'): + if Setting().get('saml_logout_url'): + try: + return redirect( + auth.logout( + name_id_format= + Setting().get('saml_nameid_format'), + return_to=Setting().get('saml_logout_url'), + session_index=session['samlSessionIndex'], + name_id=session['samlNameId'])) + except: + current_app.logger.info( + "SAML: Your IDP does not support Single Logout.") + try: return redirect( auth.logout( name_id_format= - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - return_to=current_app.config.get('SAML_LOGOUT_URL'), + Setting().get('saml_nameid_format'), session_index=session['samlSessionIndex'], name_id=session['samlNameId'])) - return redirect( - auth.logout( - name_id_format= - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - session_index=session['samlSessionIndex'], - name_id=session['samlNameId'])) + except: + current_app.logger.info( + "SAML: Your IDP does not support Single Logout.") redirect_uri = url_for('index.login') oidc_logout = Setting().get('oidc_oauth_logout_url') @@ -930,25 +937,42 @@ def dyndns_update(): ### START SAML AUTHENTICATION ### @index_bp.route('/saml/login') def saml_login(): - if not current_app.config.get('SAML_ENABLED'): + if not Setting().get('saml_enabled'): abort(400) + global saml req = saml.prepare_flask_request(request) - auth = saml.init_saml_auth(req) + try: + auth = saml.init_saml_auth(req) + except: + current_app.logger.info( + "SAML: IDP Metadata were not successfully initialized. Reinitializing...") + saml = SAML() + req = saml.prepare_flask_request(request) + auth = saml.init_saml_auth(req) redirect_url = OneLogin_Saml2_Utils.get_self_url(req) + url_for( 'index.saml_authorized') + if auth is None: + return render_template('errors/SAML.html') return redirect(auth.login(return_to=redirect_url)) @index_bp.route('/saml/metadata') def saml_metadata(): - if not current_app.config.get('SAML_ENABLED'): + if not Setting().get('saml_enabled'): current_app.logger.error("SAML authentication is disabled.") abort(400) req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) + if auth is None: + return render_template('errors/SAML.html') settings = auth.get_settings() - metadata = settings.get_sp_metadata() + try: + metadata = settings.get_sp_metadata() + except: + current_app.logger.error( + "SAML: Error fetching SP Metadata") + return render_template('errors/SAML.html') errors = settings.validate_metadata(metadata) if len(errors) == 0: @@ -958,15 +982,16 @@ def saml_metadata(): resp = make_response(errors.join(', '), 500) return resp - @index_bp.route('/saml/authorized', methods=['GET', 'POST']) def saml_authorized(): errors = [] - if not current_app.config.get('SAML_ENABLED'): + if not Setting().get('saml_enabled'): current_app.logger.error("SAML authentication is disabled.") abort(400) req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) + if auth is None: + return render_template('errors/SAML.html') auth.process_response() current_app.logger.debug( auth.get_attributes() ) errors = auth.get_errors() @@ -979,9 +1004,9 @@ def saml_authorized(): if 'RelayState' in request.form and self_url != request.form[ 'RelayState']: return redirect(auth.redirect_to(request.form['RelayState'])) - if current_app.config.get('SAML_ATTRIBUTE_USERNAME', False): + if Setting().get('saml_attribute_username'): username = session['samlUserdata'][ - current_app.config['SAML_ATTRIBUTE_USERNAME']][0].lower() + Setting().get('saml_attribute_username')][0].lower() else: username = session['samlNameId'].lower() user = User.query.filter_by(username=username).first() @@ -992,22 +1017,38 @@ def saml_authorized(): email=session['samlNameId']) user.create_local_user() session['user_id'] = user.id - email_attribute_name = current_app.config.get('SAML_ATTRIBUTE_EMAIL', - 'email') - givenname_attribute_name = current_app.config.get( - 'SAML_ATTRIBUTE_GIVENNAME', 'givenname') - surname_attribute_name = current_app.config.get( - 'SAML_ATTRIBUTE_SURNAME', 'surname') - name_attribute_name = current_app.config.get('SAML_ATTRIBUTE_NAME', - None) - account_attribute_name = current_app.config.get( - 'SAML_ATTRIBUTE_ACCOUNT', None) - admin_attribute_name = current_app.config.get('SAML_ATTRIBUTE_ADMIN', - None) - group_attribute_name = current_app.config.get('SAML_ATTRIBUTE_GROUP', - None) - admin_group_name = current_app.config.get('SAML_GROUP_ADMIN_NAME', - None) + if Setting().get('saml_attribute_email'): + email_attribute_name = Setting().get('saml_attribute_email') + else: + email_attribute_name = 'email' + if Setting().get('saml_attribute_givenname'): + givenname_attribute_name = Setting().get('saml_attribute_givenname') + else: + givenname_attribute_name = 'givenname' + if Setting().get('saml_attribute_surname'): + surname_attribute_name = Setting().get('saml_attribute_surname') + else: + surname_attribute_name = 'surname' + if Setting().get('saml_attribute_name'): + name_attribute_name = Setting().get('saml_attribute_name') + else: + name_attribute_name = None + if Setting().get('saml_attribute_account'): + account_attribute_name = Setting().get('saml_attribute_account') + else: + account_attribute_name = None + if Setting().get('saml_attribute_admin'): + admin_attribute_name = Setting().get('saml_attribute_admin') + else: + admin_attribute_name = None + if Setting().get('saml_attribute_group'): + group_attribute_name = Setting().get('saml_attribute_group') + else: + group_attribute_name = None + if Setting().get('saml_group_admin_name'): + admin_group_name = Setting().get('saml_group_admin_name') + else: + admin_group_name = None group_to_account_mapping = create_group_to_account_mapping() if email_attribute_name in session['samlUserdata']: @@ -1068,6 +1109,7 @@ def saml_authorized(): user.username), created_by='SAML Assertion') history.add() + user.plain_text_password = None user.update_profile() session['authentication_type'] = 'SAML' @@ -1077,8 +1119,7 @@ def saml_authorized(): def create_group_to_account_mapping(): - group_to_account_mapping_string = current_app.config.get( - 'SAML_GROUP_TO_ACCOUNT_MAPPING', None) + group_to_account_mapping_string = Setting().get('saml_group_to_account_mapping') if group_to_account_mapping_string and len( group_to_account_mapping_string.strip()) > 0: group_to_account_mapping = group_to_account_mapping_string.split(',') @@ -1119,17 +1160,23 @@ def uplift_to_admin(user): @index_bp.route('/saml/sls') +@login_required def saml_logout(): + if not Setting().get('saml_enabled'): + current_app.logger.error("SAML authentication is disabled.") + abort(400) req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) + if auth is None: + return render_template('errors/SAML.html') url = auth.process_slo() errors = auth.get_errors() if len(errors) == 0: clear_session() if url is not None: return redirect(url) - elif current_app.config.get('SAML_LOGOUT_URL') is not None: - return redirect(current_app.config.get('SAML_LOGOUT_URL')) + elif Setting().get('saml_logout_url') is not None: + return redirect(Setting().get('saml_logout_url')) else: return redirect(url_for('login')) else: diff --git a/powerdnsadmin/services/saml.py b/powerdnsadmin/services/saml.py index 40c97bf..9d3b615 100644 --- a/powerdnsadmin/services/saml.py +++ b/powerdnsadmin/services/saml.py @@ -6,11 +6,17 @@ import os from ..lib.certutil import KEY_FILE, CERT_FILE, create_self_signed_cert from ..lib.utils import urlparse +from ..models.setting import Setting - +# The python3-saml library currently supports only the Redirect binding for IDP endpoints. +# For SP, the Assertion Consumer Service endpoint supports HTTP-POST binding, +# while the Single Logout Service endpoint uses HTTP-Redirect. +# Therefore, to protect users from using unsupported features, settings +# 'saml_idp_sso_binding', 'saml_idp_slo_binding', 'saml_sp_acs_binding' and 'saml_sp_sls_binding' +# are not exposed on the front end SAML interface. class SAML(object): def __init__(self): - if current_app.config['SAML_ENABLED']: + if Setting().get('saml_enabled'): from onelogin.saml2.auth import OneLogin_Saml2_Auth from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser @@ -19,29 +25,34 @@ class SAML(object): 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']) + if Setting().get('saml_idp_entity_id'): + try: + self.idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote( + Setting().get('saml_metadata_url'), + entity_id=Setting().get('saml_idp_entity_id'), + required_sso_binding=Setting().get('saml_idp_sso_binding')) + except: + self.idp_data = None 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)) + try: + self.idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote( + Setting().get('saml_metadata_url'), + entity_id=None) + except: + self.idp_data = None if self.idp_data is None: - current_app.logger.info( + current_app.logger.error( 'SAML: IDP Metadata initial load failed') - exit(-1) + Setting().set('saml_enabled', False) + def get_idp_data(self): lifetime = timedelta( - minutes=current_app.config['SAML_METADATA_CACHE_LIFETIME']) - - if self.idp_timestamp + lifetime < datetime.now(): + minutes=int(Setting().get('saml_metadata_cache_lifetime'))) + if not hasattr(self,'idp_timestamp'): + self.retrieve_idp_data() + elif self.idp_timestamp + lifetime < datetime.now(): background_thread = Thread(target=self.retrieve_idp_data()) background_thread.start() @@ -49,22 +60,27 @@ class SAML(object): 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'] - ) + if Setting().get('saml_idp_sso_binding'): + try: + new_idp_data = self.OneLogin_Saml2_IdPMetadataParser.parse_remote( + Setting().get('saml_metadata_url'), + entity_id=Setting().get('saml_idp_entity_id'), + required_sso_binding=Setting().get('saml_idp_sso_binding')) + except: + new_idp_data = None 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)) + try: + new_idp_data = self.OneLogin_Saml2_IdPMetadataParser.parse_remote( + Setting().get('saml_metadata_url'), + entity_id=Setting().get('saml_idp_entity_id')) + except: + new_idp_data = 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']) + Setting().get('saml_metadata_url')) else: current_app.logger.info( "SAML: IDP Metadata could not be retrieved") @@ -94,20 +110,19 @@ class SAML(object): 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'] + if Setting().get('saml_nameid_format'): + settings['sp']['NameIDFormat'] = Setting().get('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'] + settings['sp']['entityId'] = Setting().get('saml_sp_entity_id') - if ('SAML_CERT' in current_app.config) and ('SAML_KEY' in current_app.config): + if (Setting().get('saml_cert_file')) and (Setting().get('saml_cert_key')): - saml_cert_file = current_app.config['SAML_CERT'] - saml_key_file = current_app.config['SAML_KEY'] + saml_cert_file = Setting().get('saml_cert_file') + saml_key_file = Setting().get('saml_cert_key') if os.path.isfile(saml_cert_file): cert = open(saml_cert_file, "r").readlines() @@ -130,8 +145,8 @@ class SAML(object): settings['sp']['privateKey'] = "".join(key) - if 'SAML_SP_REQUESTED_ATTRIBUTES' in current_app.config: - saml_req_attr = json.loads(current_app.config['SAML_SP_REQUESTED_ATTRIBUTES']) + if Setting().get('saml_sp_requested_attributes'): + saml_req_attr = json.loads(Setting().get('saml_sp_requested_attributes')) settings['sp']['attributeConsumingService'] = { "serviceName": "PowerDNSAdmin", "serviceDescription": "PowerDNS-Admin - PowerDNS administration utility", @@ -143,55 +158,52 @@ class SAML(object): settings['sp']['assertionConsumerService'] = {} settings['sp']['assertionConsumerService'][ - 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + 'binding'] = Setting().get('saml_sp_acs_binding')#'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' settings['sp']['assertionConsumerService'][ 'url'] = own_url + '/saml/authorized' settings['sp']['singleLogoutService'] = {} settings['sp']['singleLogoutService'][ - 'binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + 'binding'] = Setting().get('saml_sp_sls_binding')#'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' settings['sp']['singleLogoutService']['url'] = own_url + '/saml/sls' - settings['idp'] = metadata['idp'] + if metadata is not None and 'idp' in metadata: + settings['idp'] = metadata['idp'] settings['strict'] = True - settings['debug'] = current_app.config['SAML_DEBUG'] + settings['debug'] = bool(Setting().get('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 + 'digestAlgorithm'] = Setting().get('saml_digest_algorithm') + settings['security']['metadataCacheDuration'] = Setting().get('saml_metadata_cache_duration') if Setting().get('saml_metadata_cache_duration') else None + settings['security']['metadataValidUntil'] = Setting().get('saml_metadata_valid_until') if Setting().get('saml_metadata_valid_until') else None settings['security']['requestedAuthnContext'] = True settings['security'][ - 'signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' - settings['security']['wantAssertionsEncrypted'] = current_app.config.get( - 'SAML_ASSERTION_ENCRYPTED', True) + 'signatureAlgorithm'] = Setting().get('saml_signature_algorithm') + settings['security']['wantAssertionsEncrypted'] = bool(Setting().get('saml_want_assertions_encrypted')) 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['security']['authnRequestsSigned'] = bool(Setting().get('saml_sign_authn_request')) + settings['security']['logoutRequestSigned'] = bool(Setting().get('saml_sign_logout_request_response')) + settings['security']['logoutResponseSigned'] = bool(Setting().get('saml_sign_logout_request_response')) + settings['security']['nameIdEncrypted'] = bool(Setting().get('saml_nameid_encrypted')) + settings['security']['signMetadata'] = bool(Setting().get('saml_sign_metadata')) + settings['security']['wantAssertionsSigned'] = bool(Setting().get('saml_want_assertions_signed')) + settings['security']['wantMessagesSigned'] = bool(Setting().get('saml_want_message_signed')) + settings['security']['wantNameIdEncrypted'] = bool(Setting().get('saml_want_nameid_encrypted')) 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']['support']['emailAddress'] = Setting().get('saml_sp_contact_mail') + settings['contactPerson']['support']['givenName'] = Setting().get('saml_sp_contact_name') 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['contactPerson']['technical']['emailAddress'] = Setting().get('saml_sp_contact_mail') + settings['contactPerson']['technical']['givenName'] = Setting().get('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) + try: + auth = self.OneLogin_Saml2_Auth(req, settings) + except: + current_app.logger.error( + "SAML: SAML Authentication failed") + auth = None return auth diff --git a/powerdnsadmin/templates/admin_setting_authentication.html b/powerdnsadmin/templates/admin_setting_authentication.html index ba82c2e..d750a5a 100644 --- a/powerdnsadmin/templates/admin_setting_authentication.html +++ b/powerdnsadmin/templates/admin_setting_authentication.html @@ -56,6 +56,7 @@
  • Github OAuth
  • Microsoft OAuth
  • OpenID Connect OAuth
  • +
  • SAML
  • @@ -677,6 +678,438 @@
    +
    +
    +
    +
    + + +
    + GENERAL +
    + + +
    +
    +
    + IDP +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + SP +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + SP ATTRIBUTES +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + SIGNING & ENCRYPTION +
    + + + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + + +
    +
    +
    + LOGOUT +
    + + +
    +
    + + + +
    +
    +
    + AUTOPROVISION +
    + + + +
    +
    + + + +
    +
    +
    + +
    +
    +
    +
    + Help +
    +
    General
    +

    + Enable SAML - Enables or disables SAML Authentication.
    + If toggled on, the following fields must be filled in the left form: +
      +
    • + IDP Entity ID +
    • +
    • + IDP Metadata URL +
    • +
    • + SP Entity ID +
    • +
    • + SP NameID Format +
    • +
    • + SP Requested Attributes +
    • +
    • + SP Username Attribute +
    • +
    • + Cert File +
    • +
    • + Cert Key +
    • +
    • + Digest Algorithm +
    • +
    • + Signature Algorithm +
    • +
    • + Logout URL, if SAML Logout is toggled on +
    • +
    • + Roles Provisioning Field and Urn Prefix, if Roles Autoprovisioning is toggled on. +
    • +
    + The rest can be filled, or left empty to revert to the Default settings. +
    +
    IDP
    +

    +
      +
    • + IDP Entity ID - Specify the EntityID of the IDP to use.
      + Only needed if more the XML provided in the SAML_METADATA_URL contains more than 1 IDP Entity. +
    • +
    • + IDP Metadata URL - Url where the XML of the Identity Provider Metadata is published. +
    • +
    • + IDP Metadata Cache Lifetime - Cache Lifetime in minutes before fresh metadata are fetched from the IDP Metadata URL +
    • +
    • + IDP SSO Binding - SAML SSO binding format required for the IDP to use
      +
    • +
    • + IDP SLO Binding - SAML SLO binding format required for the IDP to use
      +
    • + NOTE::The Binding settings are currently disabled, as the underlying saml library currently supports only the Redirect binding for IDP endpoints.
      +
    +
    +
    SP
    +

    +
      +
    • + SP Entity ID - Specify the EntityID of your Service Provider (SP). +
    • +
    • + SP NameID Format - NameID format to request. This specifies the content of the NameID and any associated processing rules. +
    • +
    • + SP Metadata Cache Duration - Set the cache duration of generated metadata.
      + Use PT5M to set cache duration to 5 minutes. +
    • +
    • + SP Metadata Valid Until - Set the expiration date, in XML DateTime String format, for generated metadata.
      + XML DateTime String Format: "YYYY-MM-DDThh:mm:ssZ", Z can be Z for timezone 0 or "+-hh:mm" for other timezones. +
    • +
    • + Sign SP Metadata - Choose whether metadata produced is signed. +
    • +
    • + SP ACS Binding - SAML Assertion Consumer Service Binding Format for the SP to use on login. +
    • +
    • + SP SLS Binding - SAML Single Logout Service Binding Format for the SP to use on logout. +
    • + NOTE::The Binding settings are currently disabled, as in the underlying saml library, the ACS endpoint currently supports + only the HTTP-POST binding, while the SLS endpoint supports only HTTP-Redirect. +
    +
    +
    SP ATTRIBUTES
    +

    +
      +
    • + Requested Attributes - The following parameter defines RequestedAttributes section in SAML metadata + since certain IDPs require explicitly requesting attributes.
      + If not provided, the Attribute Consuming Service Section will not be available in metadata. +
      + Possible attributes:
      + name (mandatory), nameFormat, isRequired, friendlyName +
      + NOTE: This parameter requires to be entered in valid JSON format as displayed below + and multiple attributes can be given. +
      + Following example: +
      + SAML_SP_REQUESTED_ATTRIBUTES = '[
      + {"name": "urn:oid:0.9.2342.19200300.100.1.3", "nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "isRequired": true, "friendlyName": "email"},
      + {"name": "mail", "isRequired": false, "friendlyName": "test-field"}
      + ]'
      +
      + produces following metadata section: +
      + <md:AttributeConsumingService index="1">
      + <md:RequestedAttribute Name="urn:oid:0.9.2342.19200300.100.1.3" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="email" isRequired="true"/>
      + <md:RequestedAttribute Name="mail" FriendlyName="test-field"/>
      + </md:AttributeConsumingService>
      +
    • +
    + The following attribute values must be derived from Requested Attributes, and must be in the form of a valid URN (e.g. urn:oid:2.5.4.4): +
      +
    • + Email - Attribute to use for Email address. +
    • +
    • + Given Name - Attribute to use for Given name. +
    • +
    • + Surname - Attribute to use for Surname. +
    • +
    • + Username - Attribute to use for username. +
    • +
    + These may be generic strings containing your information: +
      +
    • + SP Entity Name - Contact information about your SP, to be included in the generated metadata. +
    • +
    • + SP Entity Mail - Contact information about your SP, to be included in the generated metadata. +
    • +
    +
    +
    ENCRYPTION
    +

    +
      +
    • + The Cert File - Cert Key pair configures the path + to certificate file and it's respective private key file.
      + It is used for signing metadata, encrypting tokens and all other + signing/encryption tasks during communication between IDP and SP.
      + NOTE: If these two parameters aren't explicitly provided, + a self-signed certificate-key pair will be generated.
      +
    • +
    • + Sign Authentication Request - Configures if the SP should sign outgoing authentication requests. +
    • +
    • + Sign Logout Request & Response - Configures if the SP should sign outgoing Logout requests & Logout responses. +
    • +
    • + Want Assertions Encrypted - Choose whether the SP expects incoming assertions received from the IDP to be encrypted. +
    • +
    • + Want Assertions Signed - Choose whether the SP expects incoming assertions to be signed. +
    • +
    • + NameID Encrypted - Indicates that the outgoing nameID of the logoutRequest sent by this SP will be encrypted. +
    • +
    • + Want NameID Encrypted - Indicates a requirement for the incoming NameID received by this SP to be encrypted. +
    • +
    • + Want Message Signed - Choose whether the SP expects incoming messages to be signed. +
    • +
    • + Digest Algorithm - Encryption algorithm for the DigestValue, which is part of the validation process to ensure the integrity of the XML message. +
    • +
    • + Signature Algorithm - Encryption algorithm for the message Signature. +
    • +
    +
    +
    LOGOUT
    +

    +
      +
    • + SAML Logout - Choose whether user is logged out of the SAML session using SLO. +
        +
      • + If enabled, use SAML standard logout mechanism retreived from IDP metadata. +
      • +
      • + If disabled, don't care about SAML session on logout.
        + Logout from PowerDNS-Admin only and keep SAML session authenticated. +
      • +
      +
    • +
    • + Logout URL - Configure to redirect to a url different than PowerDNS-Admin login after a successful SAML logout. +
    • +
    +
    +
    AUTOPROVISION
    +

    + Assert user Admin status and associated Accounts with SAML Attributes. +
      +
    • + Admin - Attribute to get admin status from.
      + If set, look for the value 'true' to set a user as an administrator.
      + If not included in assertion, or set to something other than 'true', + the user is set as a non-administrator user. +
    • +
    • + Account - Attribute to get account names from.
      + If set, the user will be added and removed from accounts to match + what's in the login assertion.
      Accounts that don't exist will + be created and the user added to them. +
    • +
    +
    +
    +
    +
    +
    @@ -691,6 +1124,13 @@ {%- endassets %} {% endblock %} @@ -1098,5 +1661,26 @@ + {% endblock %} diff --git a/powerdnsadmin/templates/errors/SAML.html b/powerdnsadmin/templates/errors/SAML.html index 2cfacbd..c0ebf5a 100644 --- a/powerdnsadmin/templates/errors/SAML.html +++ b/powerdnsadmin/templates/errors/SAML.html @@ -26,14 +26,15 @@ Oops! Something went wrong

    - Login failed.
    - Error(s) when processing SAML Response:
    + Login failed. + {% if error %} +
    Error(s) when processing SAML Response:

      {% for error in errors %}
    • {{ error }}
    • {% endfor %}
    - + {% endif %} You may return to the dashboard.