From 292aaddaee53eb066fe1623b7e27e13997e14d52 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sat, 23 Feb 2019 14:42:08 +0000 Subject: [PATCH 1/3] Improve SAML support - Make SAML_WANT_MESSAGE_SIGNED configurable, AzureAD signs the assertion but wouldn't sign the message - Add support for a name attribute, i.e. 'Tim Jacomb' using `SAML_ATTRIBUTE_NAME`, which will be mapped into the given and surname fields, AzureAD only has displayname - Add support for group based admin `SAML_ATTRIBUTE_GROUP` and `SAML_GROUP_ADMIN_NAME` - Add support for group based accounts `SAML_GROUP_TO_ACCOUNT_MAPPING` - Don't fail if cert and key aren't present --- app/lib/utils.py | 13 +++++--- app/views.py | 80 +++++++++++++++++++++++++++++++++++----------- config_template.py | 27 ++++++++++++++++ 3 files changed, 96 insertions(+), 24 deletions(-) diff --git a/app/lib/utils.py b/app/lib/utils.py index 8f5ebc1..5154b71 100644 --- a/app/lib/utils.py +++ b/app/lib/utils.py @@ -3,6 +3,7 @@ import json import requests import hashlib import ipaddress +import os from app import app from distutils.version import StrictVersion @@ -244,10 +245,12 @@ def init_saml_auth(req): 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'] - cert = open(CERT_FILE, "r").readlines() - key = open(KEY_FILE, "r").readlines() - settings['sp']['privateKey'] = "".join(key) - settings['sp']['x509cert'] = "".join(cert) + 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' @@ -273,7 +276,7 @@ def init_saml_auth(req): settings['security']['nameIdEncrypted'] = False settings['security']['signMetadata'] = True settings['security']['wantAssertionsSigned'] = True - settings['security']['wantMessagesSigned'] = True + settings['security']['wantMessagesSigned'] = app.config.get('SAML_WANT_MESSAGE_SIGNED', True) settings['security']['wantNameIdEncrypted'] = False settings['contactPerson'] = {} settings['contactPerson']['support'] = {} diff --git a/app/views.py b/app/views.py index b6606fd..9600efc 100755 --- a/app/views.py +++ b/app/views.py @@ -293,27 +293,42 @@ def saml_authorized(): email_attribute_name = app.config.get('SAML_ATTRIBUTE_EMAIL', 'email') givenname_attribute_name = app.config.get('SAML_ATTRIBUTE_GIVENNAME', 'givenname') surname_attribute_name = app.config.get('SAML_ATTRIBUTE_SURNAME', 'surname') + name_attribute_name = app.config.get('SAML_ATTRIBUTE_NAME', None) account_attribute_name = app.config.get('SAML_ATTRIBUTE_ACCOUNT', None) admin_attribute_name = app.config.get('SAML_ATTRIBUTE_ADMIN', None) + group_attribute_name = app.config.get('SAML_ATTRIBUTE_GROUP', None) + admin_group_name = app.config.get('SAML_GROUP_ADMIN_NAME', None) + group_to_account_mapping = create_group_to_account_mapping() + if email_attribute_name in session['samlUserdata']: user.email = session['samlUserdata'][email_attribute_name][0].lower() if givenname_attribute_name in session['samlUserdata']: user.firstname = session['samlUserdata'][givenname_attribute_name][0] if surname_attribute_name in session['samlUserdata']: user.lastname = session['samlUserdata'][surname_attribute_name][0] - if admin_attribute_name: + if name_attribute_name in session['samlUserdata']: + name = session['samlUserdata'][name_attribute_name][0].split(' ') + user.firstname = name[0] + user.lastname = ' '.join(name[1:]) + + if group_attribute_name: + user_groups = session['samlUserdata'].get(group_attribute_name, []) + else: + user_groups = [] + if admin_attribute_name or group_attribute_name: user_accounts = set(user.get_account()) saml_accounts = [] + for group_mapping in group_to_account_mapping: + mapping = group_mapping.split('=') + group = mapping[0] + account_name = mapping[1] + + if group in user_groups: + account = handle_account(account_name) + saml_accounts.append(account) + for account_name in session['samlUserdata'].get(account_attribute_name, []): - clean_name = ''.join(c for c in account_name.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789") - if len(clean_name) > Account.name.type.length: - logging.error("Account name {0} too long. Truncated.".format(clean_name)) - account = Account.query.filter_by(name=clean_name).first() - if not account: - account = Account(name=clean_name.lower(), description='', contact='', mail='') - account.create_account() - history = History(msg='Account {0} created'.format(account.name), created_by='SAML Assertion') - history.add() + account = handle_account(account_name) saml_accounts.append(account) saml_accounts = set(saml_accounts) for account in saml_accounts - user_accounts: @@ -324,14 +339,11 @@ def saml_authorized(): account.remove_user(user) history = History(msg='Removing {0} from account {1}'.format(user.username, account.name), created_by='SAML Assertion') history.add() - if admin_attribute_name: - if 'true' in session['samlUserdata'].get(admin_attribute_name, []): - admin_role = Role.query.filter_by(name='Administrator').first().id - if user.role_id != admin_role: - user.role_id = admin_role - history = History(msg='Promoting {0} to administrator'.format(user.username), created_by='SAML Assertion') - history.add() - else: + if admin_attribute_name and 'true' in session['samlUserdata'].get(admin_attribute_name, []): + uplift_to_admin(user) + elif admin_group_name in user_groups: + uplift_to_admin(user) + elif admin_attribute_name or group_attribute_name: user_role = Role.query.filter_by(name='User').first().id if user.role_id != user_role: user.role_id = user_role @@ -343,7 +355,37 @@ def saml_authorized(): login_user(user, remember=False) return redirect(url_for('index')) else: - return render_template('errors/SAML.html', errors=errors) + return render_template('errors/SAML.html', errors=errors) + + +def create_group_to_account_mapping(): + group_to_account_mapping_string = app.config.get('SAML_GROUP_TO_ACCOUNT_MAPPING', None) + 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(',') + else: + group_to_account_mapping = [] + return group_to_account_mapping + + +def handle_account(account_name): + clean_name = ''.join(c for c in account_name.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789") + if len(clean_name) > Account.name.type.length: + logging.error("Account name {0} too long. Truncated.".format(clean_name)) + account = Account.query.filter_by(name=clean_name).first() + if not account: + account = Account(name=clean_name.lower(), description='', contact='', mail='') + account.create_account() + history = History(msg='Account {0} created'.format(account.name), created_by='SAML Assertion') + history.add() + return account + + +def uplift_to_admin(user): + admin_role = Role.query.filter_by(name='Administrator').first().id + if user.role_id != admin_role: + user.role_id = admin_role + history = History(msg='Promoting {0} to administrator'.format(user.username), created_by='SAML Assertion') + history.add() @login_manager.unauthorized_handler diff --git a/config_template.py b/config_template.py index ecc40ac..41bc2b3 100644 --- a/config_template.py +++ b/config_template.py @@ -71,6 +71,12 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### Example: urn:oid:2.5.4.4 #SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4' +## Split into Given name and Surname +## Useful if your IDP only gives a display name +### Default: none +### Example: http://schemas.microsoft.com/identity/claims/displayname +#SAML_ATTRIBUTE_NAME = 'http://schemas.microsoft.com/identity/claims/displayname' + ## Attribute to use for username ### Default: Use NameID instead ### Example: urn:oid:0.9.2342.19200300.100.1.1 @@ -84,6 +90,22 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### the user is set as a non-administrator user. #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin' +## Attribute to get group from +### Default: Don't use groups from SAML attribute +### Example: https://example.edu/pdns-admin-group +#SAML_ATTRIBUTE_GROUP = 'https://example.edu/pdns-admin' + +## Group namem to get admin status from +### Default: Don't control admin with SAML group +### Example: https://example.edu/pdns-admin +#SAML_GROUP_ADMIN_NAME = 'powerdns-admin' + +## Attribute to get group to account mappings from +### Default: None +### If set, the user will be added and removed from accounts to match +### what's in the login assertion if they are in the required group +#SAML_GROUP_TO_ACCOUNT_MAPPING = 'dev-admins=dev,prod-admins=prod' + ## 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 @@ -97,6 +119,11 @@ SAML_SP_CONTACT_MAIL = '' #Configures if SAML tokens should be encrypted. #If enabled a new app certificate will be generated on restart SAML_SIGN_REQUEST = False + +# Configures if you want to request the IDP to sign the message +# Default is True +#SAML_WANT_MESSAGE_SIGNED = True + #Use SAML standard logout mechanism retrieved from idp metadata #If configured false don't care about SAML session on logout. #Logout from PowerDNS-Admin only and keep SAML session authenticated. From 08de197cffe309df0461b0a60ae01cabc4e45186 Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sun, 3 Mar 2019 15:07:51 +0000 Subject: [PATCH 2/3] Sync config templates --- configs/development.py | 27 +++++++++++++++++++++++++++ configs/test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/configs/development.py b/configs/development.py index 71cae97..66bd60e 100644 --- a/configs/development.py +++ b/configs/development.py @@ -62,6 +62,12 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### Example: urn:oid:2.5.4.4 #SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4' +## Split into Given name and Surname +## Useful if your IDP only gives a display name +### Default: none +### Example: http://schemas.microsoft.com/identity/claims/displayname +#SAML_ATTRIBUTE_NAME = 'http://schemas.microsoft.com/identity/claims/displayname' + ## Attribute to use for username ### Default: Use NameID instead ### Example: urn:oid:0.9.2342.19200300.100.1.1 @@ -75,6 +81,22 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### the user is set as a non-administrator user. #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin' +## Attribute to get group from +### Default: Don't use groups from SAML attribute +### Example: https://example.edu/pdns-admin-group +#SAML_ATTRIBUTE_GROUP = 'https://example.edu/pdns-admin' + +## Group namem to get admin status from +### Default: Don't control admin with SAML group +### Example: https://example.edu/pdns-admin +#SAML_GROUP_ADMIN_NAME = 'powerdns-admin' + +## Attribute to get group to account mappings from +### Default: None +### If set, the user will be added and removed from accounts to match +### what's in the login assertion if they are in the required group +#SAML_GROUP_TO_ACCOUNT_MAPPING = 'dev-admins=dev,prod-admins=prod' + ## 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 @@ -88,6 +110,11 @@ SAML_SP_CONTACT_MAIL = '' #Configures if SAML tokens should be encrypted. #If enabled a new app certificate will be generated on restart SAML_SIGN_REQUEST = False + +# Configures if you want to request the IDP to sign the message +# Default is True +#SAML_WANT_MESSAGE_SIGNED = True + #Use SAML standard logout mechanism retrieved from idp metadata #If configured false don't care about SAML session on logout. #Logout from PowerDNS-Admin only and keep SAML session authenticated. diff --git a/configs/test.py b/configs/test.py index cbfc046..e50ab50 100644 --- a/configs/test.py +++ b/configs/test.py @@ -69,6 +69,12 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### Example: urn:oid:2.5.4.4 #SAML_ATTRIBUTE_SURNAME = 'urn:oid:2.5.4.4' +## Split into Given name and Surname +## Useful if your IDP only gives a display name +### Default: none +### Example: http://schemas.microsoft.com/identity/claims/displayname +#SAML_ATTRIBUTE_NAME = 'http://schemas.microsoft.com/identity/claims/displayname' + ## Attribute to use for username ### Default: Use NameID instead ### Example: urn:oid:0.9.2342.19200300.100.1.1 @@ -82,6 +88,22 @@ SAML_METADATA_CACHE_LIFETIME = 1 ### the user is set as a non-administrator user. #SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin' +## Attribute to get group from +### Default: Don't use groups from SAML attribute +### Example: https://example.edu/pdns-admin-group +#SAML_ATTRIBUTE_GROUP = 'https://example.edu/pdns-admin' + +## Group namem to get admin status from +### Default: Don't control admin with SAML group +### Example: https://example.edu/pdns-admin +#SAML_GROUP_ADMIN_NAME = 'powerdns-admin' + +## Attribute to get group to account mappings from +### Default: None +### If set, the user will be added and removed from accounts to match +### what's in the login assertion if they are in the required group +#SAML_GROUP_TO_ACCOUNT_MAPPING = 'dev-admins=dev,prod-admins=prod' + ## 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 @@ -95,6 +117,11 @@ SAML_SP_CONTACT_MAIL = '' #Configures if SAML tokens should be encrypted. #If enabled a new app certificate will be generated on restart SAML_SIGN_REQUEST = False + +# Configures if you want to request the IDP to sign the message +# Default is True +#SAML_WANT_MESSAGE_SIGNED = True + #Use SAML standard logout mechanism retrieved from idp metadata #If configured false don't care about SAML session on logout. #Logout from PowerDNS-Admin only and keep SAML session authenticated. From 334878d75d89dcf0be5540c9ace81ff9acd3d27b Mon Sep 17 00:00:00 2001 From: Tim Jacomb Date: Sun, 3 Mar 2019 15:08:01 +0000 Subject: [PATCH 3/3] Use display name of role --- app/views.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/views.py b/app/views.py index 9600efc..74e0ebc 100755 --- a/app/views.py +++ b/app/views.py @@ -344,9 +344,8 @@ def saml_authorized(): elif admin_group_name in user_groups: uplift_to_admin(user) elif admin_attribute_name or group_attribute_name: - user_role = Role.query.filter_by(name='User').first().id - if user.role_id != user_role: - user.role_id = user_role + if user.role.name != 'User': + user.role_id = Role.query.filter_by(name='User').first().id history = History(msg='Demoting {0} to user'.format(user.username), created_by='SAML Assertion') history.add() user.plain_text_password = None @@ -381,9 +380,8 @@ def handle_account(account_name): def uplift_to_admin(user): - admin_role = Role.query.filter_by(name='Administrator').first().id - if user.role_id != admin_role: - user.role_id = admin_role + if user.role.name != 'Administrator': + user.role_id = Role.query.filter_by(name='Administrator').first().id history = History(msg='Promoting {0} to administrator'.format(user.username), created_by='SAML Assertion') history.add()