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
This commit is contained in:
Tim Jacomb 2019-02-23 14:42:08 +00:00
parent 697aba0990
commit 292aaddaee
No known key found for this signature in database
GPG Key ID: 08A202C942DC52AD
3 changed files with 96 additions and 24 deletions

View File

@ -3,6 +3,7 @@ import json
import requests import requests
import hashlib import hashlib
import ipaddress import ipaddress
import os
from app import app from app import app
from distutils.version import StrictVersion from distutils.version import StrictVersion
@ -244,10 +245,12 @@ def init_saml_auth(req):
else: else:
settings['sp']['NameIDFormat'] = idp_data.get('sp', {}).get('NameIDFormat', 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified') 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'] settings['sp']['entityId'] = app.config['SAML_SP_ENTITY_ID']
cert = open(CERT_FILE, "r").readlines() if os.path.isfile(CERT_FILE):
key = open(KEY_FILE, "r").readlines() cert = open(CERT_FILE, "r").readlines()
settings['sp']['privateKey'] = "".join(key) settings['sp']['x509cert'] = "".join(cert)
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'] = {}
settings['sp']['assertionConsumerService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' settings['sp']['assertionConsumerService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
settings['sp']['assertionConsumerService']['url'] = own_url+'/saml/authorized' settings['sp']['assertionConsumerService']['url'] = own_url+'/saml/authorized'
@ -273,7 +276,7 @@ def init_saml_auth(req):
settings['security']['nameIdEncrypted'] = False settings['security']['nameIdEncrypted'] = False
settings['security']['signMetadata'] = True settings['security']['signMetadata'] = True
settings['security']['wantAssertionsSigned'] = 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['security']['wantNameIdEncrypted'] = False
settings['contactPerson'] = {} settings['contactPerson'] = {}
settings['contactPerson']['support'] = {} settings['contactPerson']['support'] = {}

View File

@ -293,27 +293,42 @@ def saml_authorized():
email_attribute_name = app.config.get('SAML_ATTRIBUTE_EMAIL', 'email') email_attribute_name = app.config.get('SAML_ATTRIBUTE_EMAIL', 'email')
givenname_attribute_name = app.config.get('SAML_ATTRIBUTE_GIVENNAME', 'givenname') givenname_attribute_name = app.config.get('SAML_ATTRIBUTE_GIVENNAME', 'givenname')
surname_attribute_name = app.config.get('SAML_ATTRIBUTE_SURNAME', 'surname') 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) account_attribute_name = app.config.get('SAML_ATTRIBUTE_ACCOUNT', None)
admin_attribute_name = app.config.get('SAML_ATTRIBUTE_ADMIN', 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']: if email_attribute_name in session['samlUserdata']:
user.email = session['samlUserdata'][email_attribute_name][0].lower() user.email = session['samlUserdata'][email_attribute_name][0].lower()
if givenname_attribute_name in session['samlUserdata']: if givenname_attribute_name in session['samlUserdata']:
user.firstname = session['samlUserdata'][givenname_attribute_name][0] user.firstname = session['samlUserdata'][givenname_attribute_name][0]
if surname_attribute_name in session['samlUserdata']: if surname_attribute_name in session['samlUserdata']:
user.lastname = session['samlUserdata'][surname_attribute_name][0] 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()) user_accounts = set(user.get_account())
saml_accounts = [] 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, []): for account_name in session['samlUserdata'].get(account_attribute_name, []):
clean_name = ''.join(c for c in account_name.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789") account = handle_account(account_name)
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()
saml_accounts.append(account) saml_accounts.append(account)
saml_accounts = set(saml_accounts) saml_accounts = set(saml_accounts)
for account in saml_accounts - user_accounts: for account in saml_accounts - user_accounts:
@ -324,14 +339,11 @@ def saml_authorized():
account.remove_user(user) account.remove_user(user)
history = History(msg='Removing {0} from account {1}'.format(user.username, account.name), created_by='SAML Assertion') history = History(msg='Removing {0} from account {1}'.format(user.username, account.name), created_by='SAML Assertion')
history.add() history.add()
if admin_attribute_name: if admin_attribute_name and 'true' in session['samlUserdata'].get(admin_attribute_name, []):
if 'true' in session['samlUserdata'].get(admin_attribute_name, []): uplift_to_admin(user)
admin_role = Role.query.filter_by(name='Administrator').first().id elif admin_group_name in user_groups:
if user.role_id != admin_role: uplift_to_admin(user)
user.role_id = admin_role elif admin_attribute_name or group_attribute_name:
history = History(msg='Promoting {0} to administrator'.format(user.username), created_by='SAML Assertion')
history.add()
else:
user_role = Role.query.filter_by(name='User').first().id user_role = Role.query.filter_by(name='User').first().id
if user.role_id != user_role: if user.role_id != user_role:
user.role_id = user_role user.role_id = user_role
@ -343,7 +355,37 @@ def saml_authorized():
login_user(user, remember=False) login_user(user, remember=False)
return redirect(url_for('index')) return redirect(url_for('index'))
else: 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 @login_manager.unauthorized_handler

View File

@ -71,6 +71,12 @@ SAML_METADATA_CACHE_LIFETIME = 1
### Example: urn:oid:2.5.4.4 ### Example: urn:oid:2.5.4.4
#SAML_ATTRIBUTE_SURNAME = '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 ## Attribute to use for username
### Default: Use NameID instead ### Default: Use NameID instead
### Example: urn:oid:0.9.2342.19200300.100.1.1 ### 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. ### the user is set as a non-administrator user.
#SAML_ATTRIBUTE_ADMIN = 'https://example.edu/pdns-admin' #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 ## Attribute to get account names from
### Default: Don't control accounts with SAML attribute ### Default: Don't control accounts with SAML attribute
### If set, the user will be added and removed from accounts to match ### If set, the user will be added and removed from accounts to match
@ -97,6 +119,11 @@ SAML_SP_CONTACT_MAIL = '<contact mail>'
#Configures if SAML tokens should be encrypted. #Configures if SAML tokens should be encrypted.
#If enabled a new app certificate will be generated on restart #If enabled a new app certificate will be generated on restart
SAML_SIGN_REQUEST = False 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 #Use SAML standard logout mechanism retrieved from idp metadata
#If configured false don't care about SAML session on logout. #If configured false don't care about SAML session on logout.
#Logout from PowerDNS-Admin only and keep SAML session authenticated. #Logout from PowerDNS-Admin only and keep SAML session authenticated.