diff --git a/configs/docker_config.py b/configs/docker_config.py index f934548..652777a 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -1,3 +1,8 @@ +# import everything from environment variables +import os +import sys +import json + # Defaults for Docker image BIND_ADDRESS = '0.0.0.0' PORT = 80 @@ -23,6 +28,7 @@ legal_envvars = ( 'OIDC_OAUTH_EMAIL', 'BIND_ADDRESS', 'PORT', + 'SERVER_EXTERNAL_SSL', 'LOG_LEVEL', 'SALT', 'SQLALCHEMY_TRACK_MODIFICATIONS', @@ -97,21 +103,18 @@ legal_envvars_bool = ( 'SESSION_COOKIE_SECURE', 'CSRF_COOKIE_SECURE', 'CAPTCHA_ENABLE', + 'SERVER_EXTERNAL_SSL', ) legal_envvars_dict = ( 'SQLALCHEMY_ENGINE_OPTIONS', ) -# import everything from environment variables -import os -import sys -import json - def str2bool(v): return v.lower() in ("true", "yes", "1") -def dictfromstr(v,ret): + +def dictfromstr(v, ret): try: return json.loads(ret) except Exception as e: @@ -119,10 +122,11 @@ def dictfromstr(v,ret): print(e) raise ValueError + for v in legal_envvars: ret = None - # _FILE suffix will allow to read value from file, usefull for Docker's + # _FILE suffix will allow to read value from file, useful for Docker containers. # secrets feature if v + '_FILE' in os.environ: if v in os.environ: diff --git a/docs/wiki/configuration/Environment-variables.md b/docs/wiki/configuration/Environment-variables.md index d49f60f..7f42727 100644 --- a/docs/wiki/configuration/Environment-variables.md +++ b/docs/wiki/configuration/Environment-variables.md @@ -1,64 +1,65 @@ # Supported environment variables -| Variable | Description | Required | Default value | -| ---------| ----------- | -------- | ------------- | -| BIND_ADDRESS | -| CSRF_COOKIE_SECURE | -| SESSION_TYPE | null|filesystem|sqlalchemy | | filesystem | -| LDAP_ENABLED | -| LOCAL_DB_ENABLED | -| LOG_LEVEL | -| MAIL_DEBUG | -| MAIL_DEFAULT_SENDER | -| MAIL_PASSWORD | -| MAIL_PORT | -| MAIL_SERVER | -| MAIL_USERNAME | -| MAIL_USE_SSL | -| MAIL_USE_TLS | -| OFFLINE_MODE | -| OIDC_OAUTH_API_URL | | | | -| OIDC_OAUTH_AUTHORIZE_URL | -| OIDC_OAUTH_TOKEN_URL | | | | -| OIDC_OAUTH_METADATA_URL | | | | -| PORT | -| REMOTE_USER_COOKIES | -| REMOTE_USER_LOGOUT_URL | -| SALT | -| SAML_ASSERTION_ENCRYPTED | -| SAML_ATTRIBUTE_ACCOUNT | -| SAML_ATTRIBUTE_ADMIN | -| SAML_ATTRIBUTE_EMAIL | -| SAML_ATTRIBUTE_GIVENNAME | -| SAML_ATTRIBUTE_GROUP | -| SAML_ATTRIBUTE_NAME | -| SAML_ATTRIBUTE_SURNAME | -| SAML_ATTRIBUTE_USERNAME | -| SAML_CERT | -| SAML_DEBUG | -| SAML_ENABLED | -| SAML_GROUP_ADMIN_NAME | -| SAML_GROUP_TO_ACCOUNT_MAPPING | -| SAML_IDP_SSO_BINDING | -| SAML_IDP_ENTITY_ID | -| SAML_KEY | -| SAML_LOGOUT | -| SAML_LOGOUT_URL | -| SAML_METADATA_CACHE_LIFETIME | -| SAML_METADATA_URL | -| SAML_NAMEID_FORMAT | -| SAML_PATH | -| SAML_SIGN_REQUEST | -| SAML_SP_CONTACT_MAIL | -| SAML_SP_CONTACT_NAME | -| SAML_SP_ENTITY_ID | -| SAML_WANT_MESSAGE_SIGNED | -| SECRET_KEY | Flask secret key [^1] | Y | no default | -| SESSION_COOKIE_SECURE | -| SIGNUP_ENABLED | -| SQLALCHEMY_DATABASE_URI | SQL Alchemy URI to connect to database | N | no default | +| Variable | Description | Required | Default value | +|--------------------------------|--------------------------------------------------------------------------|------------|---------------| +| BIND_ADDRESS | +| CSRF_COOKIE_SECURE | +| SESSION_TYPE | null | filesystem | sqlalchemy | | filesystem | +| LDAP_ENABLED | +| LOCAL_DB_ENABLED | +| LOG_LEVEL | +| MAIL_DEBUG | +| MAIL_DEFAULT_SENDER | +| MAIL_PASSWORD | +| MAIL_PORT | +| MAIL_SERVER | +| MAIL_USERNAME | +| MAIL_USE_SSL | +| MAIL_USE_TLS | +| OFFLINE_MODE | +| OIDC_OAUTH_API_URL | | | | +| OIDC_OAUTH_AUTHORIZE_URL | +| OIDC_OAUTH_TOKEN_URL | | | | +| OIDC_OAUTH_METADATA_URL | | | | +| PORT | +| SERVER_EXTERNAL_SSL | Forceful override of URL schema detection when using the url_for method. | False | None | +| REMOTE_USER_COOKIES | +| REMOTE_USER_LOGOUT_URL | +| SALT | +| SAML_ASSERTION_ENCRYPTED | +| SAML_ATTRIBUTE_ACCOUNT | +| SAML_ATTRIBUTE_ADMIN | +| SAML_ATTRIBUTE_EMAIL | +| SAML_ATTRIBUTE_GIVENNAME | +| SAML_ATTRIBUTE_GROUP | +| SAML_ATTRIBUTE_NAME | +| SAML_ATTRIBUTE_SURNAME | +| SAML_ATTRIBUTE_USERNAME | +| SAML_CERT | +| SAML_DEBUG | +| SAML_ENABLED | +| SAML_GROUP_ADMIN_NAME | +| SAML_GROUP_TO_ACCOUNT_MAPPING | +| SAML_IDP_SSO_BINDING | +| SAML_IDP_ENTITY_ID | +| SAML_KEY | +| SAML_LOGOUT | +| SAML_LOGOUT_URL | +| SAML_METADATA_CACHE_LIFETIME | +| SAML_METADATA_URL | +| SAML_NAMEID_FORMAT | +| SAML_PATH | +| SAML_SIGN_REQUEST | +| SAML_SP_CONTACT_MAIL | +| SAML_SP_CONTACT_NAME | +| SAML_SP_ENTITY_ID | +| SAML_WANT_MESSAGE_SIGNED | +| SECRET_KEY | Flask secret key [^1] | Y | no default | +| SESSION_COOKIE_SECURE | +| SIGNUP_ENABLED | +| SQLALCHEMY_DATABASE_URI | SQL Alchemy URI to connect to database | N | no default | | SQLALCHEMY_TRACK_MODIFICATIONS | -| SQLALCHEMY_ENGINE_OPTIONS | json string. e.g. '{"pool_recycle":600,"echo":1}' [^2] | +| SQLALCHEMY_ENGINE_OPTIONS | json string. e.g. '{"pool_recycle":600,"echo":1}' [^2] | [^1]: Flask secret key (see https://flask.palletsprojects.com/en/1.1.x/config/#SECRET_KEY for how to generate) [^2]: See Flask-SQLAlchemy Documentation for all engine options. diff --git a/package.json b/package.json index 42f866d..1ceb82e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "jquery-sparkline": "^2.4.0", "jquery-ui-dist": "^1.13.2", "jquery.quicksearch": "^2.4.0", + "jquery-validation": "^1.19.5", "jtimeout": "^3.2.0", + "knockout": "^3.5.1", "multiselect": "^0.9.12" }, "resolutions": { diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py index 5e17d7f..d46d431 100644 --- a/powerdnsadmin/assets.py +++ b/powerdnsadmin/assets.py @@ -20,6 +20,7 @@ js_login = Bundle( 'node_modules/jquery/dist/jquery.js', 'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/icheck/icheck.js', + 'node_modules/knockout/build/output/knockout-latest.js', 'custom/js/custom.js', filters=(ConcatFilter, 'rjsmin'), output='generated/login.js') @@ -47,6 +48,7 @@ js_main = Bundle( 'node_modules/datatables.net-bs4/js/dataTables.bootstrap4.js', 'node_modules/jquery-sparkline/jquery.sparkline.js', 'node_modules/jquery-slimscroll/jquery.slimscroll.js', + 'node_modules/jquery-validation/dist/jquery.validate.js', 'node_modules/icheck/icheck.js', 'node_modules/fastclick/lib/fastclick.js', 'node_modules/moment/moment.js', @@ -55,6 +57,8 @@ js_main = Bundle( 'node_modules/datatables.net-plugins/sorting/natural.js', 'node_modules/jtimeout/src/jTimeout.js', 'node_modules/jquery.quicksearch/src/jquery.quicksearch.js', + 'node_modules/knockout/build/output/knockout-latest.js', + 'custom/js/app-authentication-settings-editor.js', 'custom/js/custom.js', 'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js', filters=(ConcatFilter, 'rjsmin'), diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index 55d28ef..3ae1b13 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -8,6 +8,7 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' PORT = 9191 HSTS_ENABLED = False +SERVER_EXTERNAL_SSL = None SESSION_TYPE = 'sqlalchemy' SESSION_COOKIE_SAMESITE = 'Lax' diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index dedaaab..bd6265a 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -15,6 +15,7 @@ class Setting(db.Model): value = db.Column(db.Text()) defaults = { + # General Settings 'maintenance': False, 'fullscreen_layout': True, 'record_helper': True, @@ -42,56 +43,82 @@ class Setting(db.Model): 'pdns_api_timeout': 30, 'pdns_version': '4.1.1', 'verify_ssl_connections': True, + 'verify_user_email': False, + 'enforce_api_ttl': False, + 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', + 'otp_field_enabled': True, + 'custom_css': '', + 'otp_force': False, + 'max_history_records': 1000, + 'deny_domain_override': False, + 'account_name_extra_chars': False, + 'gravatar_enabled': False, + + # Local Authentication Settings 'local_db_enabled': True, 'signup_enabled': True, - 'autoprovisioning': False, - 'urn_value': '', - 'autoprovisioning_attribute': '', - 'purge': False, - 'verify_user_email': False, + 'pwd_enforce_characters': False, + 'pwd_min_len': 10, + 'pwd_min_lowercase': 3, + 'pwd_min_uppercase': 2, + 'pwd_min_digits': 2, + 'pwd_min_special': 1, + 'pwd_enforce_complexity': False, + 'pwd_min_complexity': 11, + + # LDAP Authentication Settings 'ldap_enabled': False, 'ldap_type': 'ldap', 'ldap_uri': '', 'ldap_base_dn': '', 'ldap_admin_username': '', 'ldap_admin_password': '', + 'ldap_domain': '', 'ldap_filter_basic': '', - 'ldap_filter_group': '', 'ldap_filter_username': '', + 'ldap_filter_group': '', 'ldap_filter_groupname': '', 'ldap_sg_enabled': False, 'ldap_admin_group': '', 'ldap_operator_group': '', 'ldap_user_group': '', - 'ldap_domain': '', + 'autoprovisioning': False, + 'autoprovisioning_attribute': '', + 'urn_value': '', + 'purge': False, + + # Google OAuth2 Settings + 'google_oauth_enabled': False, + 'google_oauth_client_id': '', + 'google_oauth_client_secret': '', + 'google_oauth_scope': 'openid email profile', + 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', + 'google_oauth_auto_configure': True, + 'google_oauth_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', + 'google_token_url': 'https://oauth2.googleapis.com/token', + 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', + + # GitHub OAuth2 Settings 'github_oauth_enabled': False, 'github_oauth_key': '', 'github_oauth_secret': '', 'github_oauth_scope': 'email', 'github_oauth_api_url': 'https://api.github.com/user', - 'github_oauth_token_url': - 'https://github.com/login/oauth/access_token', - 'github_oauth_authorize_url': - 'https://github.com/login/oauth/authorize', + 'github_oauth_auto_configure': False, 'github_oauth_metadata_url': '', - 'google_oauth_enabled': False, - 'google_oauth_client_id': '', - 'google_oauth_client_secret': '', - 'google_token_url': 'https://oauth2.googleapis.com/token', - 'google_oauth_scope': 'openid email profile', - 'google_authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth', - 'google_oauth_metadata_url': '', - 'google_base_url': 'https://www.googleapis.com/oauth2/v3/', + 'github_oauth_token_url': 'https://github.com/login/oauth/access_token', + 'github_oauth_authorize_url': 'https://github.com/login/oauth/authorize', + + # Azure OAuth2 Settings 'azure_oauth_enabled': False, 'azure_oauth_key': '', 'azure_oauth_secret': '', 'azure_oauth_scope': 'User.Read openid email profile', 'azure_oauth_api_url': 'https://graph.microsoft.com/v1.0/', - 'azure_oauth_token_url': - 'https://login.microsoftonline.com/[tenancy]/oauth2/v2.0/token', - 'azure_oauth_authorize_url': - 'https://login.microsoftonline.com/[tenancy]/oauth2/v2.0/authorize', + 'azure_oauth_auto_configure': True, 'azure_oauth_metadata_url': '', + 'azure_oauth_token_url': '', + 'azure_oauth_authorize_url': '', 'azure_sg_enabled': False, 'azure_admin_group': '', 'azure_operator_group': '', @@ -101,22 +128,26 @@ class Setting(db.Model): 'azure_group_accounts_name_re': '', 'azure_group_accounts_description': 'description', 'azure_group_accounts_description_re': '', + + # OIDC OAuth2 Settings 'oidc_oauth_enabled': False, 'oidc_oauth_key': '', 'oidc_oauth_secret': '', 'oidc_oauth_scope': 'email', 'oidc_oauth_api_url': '', + 'oidc_oauth_auto_configure': True, + 'oidc_oauth_metadata_url': '', 'oidc_oauth_token_url': '', 'oidc_oauth_authorize_url': '', - 'oidc_oauth_metadata_url': '', 'oidc_oauth_logout_url': '', 'oidc_oauth_username': 'preferred_username', + 'oidc_oauth_email': 'email', 'oidc_oauth_firstname': 'given_name', 'oidc_oauth_last_name': 'family_name', - 'oidc_oauth_email': 'email', 'oidc_oauth_account_name_property': '', 'oidc_oauth_account_description_property': '', - 'enforce_api_ttl': False, + + # Zone Record Settings 'forward_records_allow_edit': { 'A': True, 'AAAA': True, @@ -193,22 +224,103 @@ class Setting(db.Model): 'TXT': True, 'URI': False }, - 'ttl_options': '1 minute,5 minutes,30 minutes,60 minutes,24 hours', - 'otp_field_enabled': True, - 'custom_css': '', - 'otp_force': False, - 'max_history_records': 1000, - 'deny_domain_override': False, - 'account_name_extra_chars': False, - 'gravatar_enabled': False, - 'pwd_enforce_characters': False, - 'pwd_min_len': 10, - 'pwd_min_lowercase': 3, - 'pwd_min_uppercase': 2, - 'pwd_min_digits': 2, - 'pwd_min_special': 1, - 'pwd_enforce_complexity': False, - 'pwd_min_complexity': 11 + } + + groups = { + 'authentication': [ + # Local Authentication Settings + 'local_db_enabled', + 'signup_enabled', + 'pwd_enforce_characters', + 'pwd_min_len', + 'pwd_min_lowercase', + 'pwd_min_uppercase', + 'pwd_min_digits', + 'pwd_min_special', + 'pwd_enforce_complexity', + 'pwd_min_complexity', + + # LDAP Authentication Settings + 'ldap_enabled', + 'ldap_type', + 'ldap_uri', + 'ldap_base_dn', + 'ldap_admin_username', + 'ldap_admin_password', + 'ldap_domain', + 'ldap_filter_basic', + 'ldap_filter_username', + 'ldap_filter_group', + 'ldap_filter_groupname', + 'ldap_sg_enabled', + 'ldap_admin_group', + 'ldap_operator_group', + 'ldap_user_group', + 'autoprovisioning', + 'autoprovisioning_attribute', + 'urn_value', + 'purge', + + # Google OAuth2 Settings + 'google_oauth_enabled', + 'google_oauth_client_id', + 'google_oauth_client_secret', + 'google_oauth_scope', + 'google_base_url', + 'google_oauth_auto_configure', + 'google_oauth_metadata_url', + 'google_token_url', + 'google_authorize_url', + + # GitHub OAuth2 Settings + 'github_oauth_enabled', + 'github_oauth_key', + 'github_oauth_secret', + 'github_oauth_scope', + 'github_oauth_api_url', + 'github_oauth_auto_configure', + 'github_oauth_metadata_url', + 'github_oauth_token_url', + 'github_oauth_authorize_url', + + # Azure OAuth2 Settings + 'azure_oauth_enabled', + 'azure_oauth_key', + 'azure_oauth_secret', + 'azure_oauth_scope', + 'azure_oauth_api_url', + 'azure_oauth_auto_configure', + 'azure_oauth_metadata_url', + 'azure_oauth_token_url', + 'azure_oauth_authorize_url', + 'azure_sg_enabled', + 'azure_admin_group', + 'azure_operator_group', + 'azure_user_group', + 'azure_group_accounts_enabled', + 'azure_group_accounts_name', + 'azure_group_accounts_name_re', + 'azure_group_accounts_description', + 'azure_group_accounts_description_re', + + # OIDC OAuth2 Settings + 'oidc_oauth_enabled', + 'oidc_oauth_key', + 'oidc_oauth_secret', + 'oidc_oauth_scope', + 'oidc_oauth_api_url', + 'oidc_oauth_auto_configure', + 'oidc_oauth_metadata_url', + 'oidc_oauth_token_url', + 'oidc_oauth_authorize_url', + 'oidc_oauth_logout_url', + 'oidc_oauth_username', + 'oidc_oauth_email', + 'oidc_oauth_firstname', + 'oidc_oauth_last_name', + 'oidc_oauth_account_name_property', + 'oidc_oauth_account_description_property', + ] } def __init__(self, id=None, name=None, value=None): @@ -306,6 +418,28 @@ class Setting(db.Model): else: current_app.logger.error('Unknown setting queried: {0}'.format(setting)) + def get_group(self, group): + if isinstance(group, str): + group = self.groups[group] + + result = {} + records = self.query.all() + + for record in records: + if record.name in group: + value = record.value + + if value in ['True', 'False']: + value = strtobool(value) + elif value.isdecimal() and '.' in value: + value = float(value) + elif value.isnumeric(): + value = int(value) + + result[record.name] = value + + return result + def get_records_allow_to_edit(self): return list( set(self.get_forward_records_allow_to_edit() + diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index eedabdc..28eb3df 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -72,8 +72,8 @@ def get_record_changes(del_rrset, add_rrset): """For the given record, return the state dict.""" return { "disabled": record['disabled'], - "content": record['content'], - "comment": record.get('comment', ''), + "content": record['content'], + "comment": record.get('comment', ''), } add_records = get_records(add_rrset) @@ -149,8 +149,8 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n # Sort them by the record name if change_num in out_changes: out_changes[change_num].sort(key=lambda change: - change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] - ) + change.del_rrset['name'] if change.del_rrset else change.add_rrset['name'] + ) # only used for changelog per record if record_name != None and record_type != None: # then get only the records with the specific (record_name, record_type) tuple @@ -897,7 +897,8 @@ class DetailedHistory(): description=DetailedHistory.get_key_val(detail_dict, "description")) - elif any(msg in history.msg for msg in ['Change zone','Change domain']) and 'access control' in history.msg: # added or removed a user from a zone + elif any(msg in history.msg for msg in ['Change zone', + 'Change domain']) and 'access control' in history.msg: # added or removed a user from a zone users_with_access = DetailedHistory.get_key_val(detail_dict, "user_has_access") self.detailed_msg = render_template_string(""" @@ -942,7 +943,7 @@ class DetailedHistory(): linked_domains=DetailedHistory.get_key_val(detail_dict, "domains")) - elif any(msg in history.msg for msg in ['Update type for zone','Update type for domain']): + elif any(msg in history.msg for msg in ['Update type for zone', 'Update type for domain']): self.detailed_msg = render_template_string("""
@@ -977,7 +978,8 @@ class DetailedHistory(): 'status'), history_msg=DetailedHistory.get_key_val(detail_dict, 'msg')) - elif any(msg in history.msg for msg in ['Update zone','Update domain']) and 'associate account' in history.msg: # When an account gets associated or dissociate with zones + elif any(msg in history.msg for msg in ['Update zone', + 'Update domain']) and 'associate account' in history.msg: # When an account gets associated or dissociate with zones self.detailed_msg = render_template_string('''
Zone: {{ domain }}
@@ -1231,8 +1233,10 @@ def history_table(): # ajax call data .filter( db.and_( db.or_( - History.msg.like("%domain " + domain_name) if domain_name != "*" else History.msg.like("%domain%"), - History.msg.like("%zone " + domain_name) if domain_name != "*" else History.msg.like("%zone%"), + History.msg.like("%domain " + domain_name) if domain_name != "*" else History.msg.like( + "%domain%"), + History.msg.like("%zone " + domain_name) if domain_name != "*" else History.msg.like( + "%zone%"), History.msg.like( "%domain " + domain_name + " access control") if domain_name != "*" else History.msg.like( "%domain%access control"), @@ -1540,287 +1544,34 @@ 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') - return local_db_enabled or ldap_enabled or google_oauth_enabled or github_oauth_enabled or oidc_oauth_enabled or azure_oauth_enabled + return local_db_enabled or ldap_enabled or google_oauth_enabled or github_oauth_enabled or oidc_oauth_enabled \ + or azure_oauth_enabled @admin_bp.route('/setting/authentication', methods=['GET', 'POST']) @login_required @admin_role_required def setting_authentication(): - if request.method == 'GET': - return render_template('admin_setting_authentication.html') - elif request.method == 'POST': - conf_type = request.form.get('config_tab') - result = None + return render_template('admin_setting_authentication.html') - if conf_type == 'general': - local_db_enabled = True if request.form.get( - 'local_db_enabled') else False - signup_enabled = True if request.form.get( - 'signup_enabled') else False - pwd_enforce_characters = True if request.form.get('pwd_enforce_characters') else False - pwd_min_len = safe_cast(request.form.get('pwd_min_len', Setting().defaults["pwd_min_len"]), int, - Setting().defaults["pwd_min_len"]) - pwd_min_lowercase = safe_cast(request.form.get('pwd_min_lowercase', Setting().defaults["pwd_min_lowercase"]), int, - Setting().defaults["pwd_min_lowercase"]) - pwd_min_uppercase = safe_cast(request.form.get('pwd_min_uppercase', Setting().defaults["pwd_min_uppercase"]), int, - Setting().defaults["pwd_min_uppercase"]) - pwd_min_digits = safe_cast(request.form.get('pwd_min_digits', Setting().defaults["pwd_min_digits"]), int, - Setting().defaults["pwd_min_digits"]) - pwd_min_special = safe_cast(request.form.get('pwd_min_special', Setting().defaults["pwd_min_special"]), int, - Setting().defaults["pwd_min_special"]) +@admin_bp.route('/setting/authentication/api', methods=['POST']) +@login_required +@admin_role_required +def setting_authentication_api(): + result = {'status': 1, 'messages': [], 'data': {}} - pwd_enforce_complexity = True if request.form.get('pwd_enforce_complexity') else False - pwd_min_complexity = safe_cast(request.form.get('pwd_min_complexity', Setting().defaults["pwd_min_complexity"]), int, - Setting().defaults["pwd_min_complexity"]) + if request.form.get('commit') == '1': + model = Setting() + data = json.loads(request.form.get('data')) - if not has_an_auth_method(local_db_enabled=local_db_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set('local_db_enabled', local_db_enabled) - Setting().set('signup_enabled', signup_enabled) + for key, value in data.items(): + if key in model.groups['authentication']: + model.set(key, value) - Setting().set('pwd_enforce_characters', pwd_enforce_characters) - Setting().set('pwd_min_len', pwd_min_len) - Setting().set('pwd_min_lowercase', pwd_min_lowercase) - Setting().set('pwd_min_uppercase', pwd_min_uppercase) - Setting().set('pwd_min_digits', pwd_min_digits) - Setting().set('pwd_min_special', pwd_min_special) + result['data'] = Setting().get_group('authentication') - Setting().set('pwd_enforce_complexity', pwd_enforce_complexity) - Setting().set('pwd_min_complexity', pwd_min_complexity) - - result = {'status': True, 'msg': 'Saved successfully'} - - elif conf_type == 'ldap': - ldap_enabled = True if request.form.get('ldap_enabled') else False - - if not has_an_auth_method(ldap_enabled=ldap_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set('ldap_enabled', ldap_enabled) - Setting().set('ldap_type', request.form.get('ldap_type')) - Setting().set('ldap_uri', request.form.get('ldap_uri')) - Setting().set('ldap_base_dn', request.form.get('ldap_base_dn')) - Setting().set('ldap_admin_username', - request.form.get('ldap_admin_username')) - Setting().set('ldap_admin_password', - request.form.get('ldap_admin_password')) - Setting().set('ldap_filter_basic', - request.form.get('ldap_filter_basic')) - Setting().set('ldap_filter_group', - request.form.get('ldap_filter_group')) - Setting().set('ldap_filter_username', - request.form.get('ldap_filter_username')) - Setting().set('ldap_filter_groupname', - request.form.get('ldap_filter_groupname')) - Setting().set( - 'ldap_sg_enabled', True - if request.form.get('ldap_sg_enabled') == 'ON' else False) - Setting().set('ldap_admin_group', - request.form.get('ldap_admin_group')) - Setting().set('ldap_operator_group', - request.form.get('ldap_operator_group')) - Setting().set('ldap_user_group', - request.form.get('ldap_user_group')) - Setting().set('ldap_domain', request.form.get('ldap_domain')) - Setting().set( - 'autoprovisioning', True - if request.form.get('autoprovisioning') == 'ON' else False) - Setting().set('autoprovisioning_attribute', - request.form.get('autoprovisioning_attribute')) - - if request.form.get('autoprovisioning') == 'ON': - if validateURN(request.form.get('urn_value')): - Setting().set('urn_value', - request.form.get('urn_value')) - else: - return render_template('admin_setting_authentication.html', - error="Invalid urn") - else: - Setting().set('urn_value', - request.form.get('urn_value')) - - Setting().set('purge', True - if request.form.get('purge') == 'ON' else False) - - result = {'status': True, 'msg': 'Saved successfully'} - elif conf_type == 'google': - google_oauth_enabled = True if request.form.get( - 'google_oauth_enabled') else False - if not has_an_auth_method(google_oauth_enabled=google_oauth_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set('google_oauth_enabled', google_oauth_enabled) - Setting().set('google_oauth_client_id', - request.form.get('google_oauth_client_id')) - Setting().set('google_oauth_client_secret', - request.form.get('google_oauth_client_secret')) - Setting().set('google_oauth_metadata_url', - request.form.get('google_oauth_metadata_url')) - Setting().set('google_token_url', - request.form.get('google_token_url')) - Setting().set('google_oauth_scope', - request.form.get('google_oauth_scope')) - Setting().set('google_authorize_url', - request.form.get('google_authorize_url')) - Setting().set('google_base_url', - request.form.get('google_base_url')) - result = { - 'status': True, - 'msg': - 'Saved successfully. Please reload PDA to take effect.' - } - elif conf_type == 'github': - github_oauth_enabled = True if request.form.get( - 'github_oauth_enabled') else False - if not has_an_auth_method(github_oauth_enabled=github_oauth_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set('github_oauth_enabled', github_oauth_enabled) - Setting().set('github_oauth_key', - request.form.get('github_oauth_key')) - Setting().set('github_oauth_secret', - request.form.get('github_oauth_secret')) - Setting().set('github_oauth_scope', - request.form.get('github_oauth_scope')) - Setting().set('github_oauth_api_url', - request.form.get('github_oauth_api_url')) - Setting().set('github_oauth_metadata_url', - request.form.get('github_oauth_metadata_url')) - Setting().set('github_oauth_token_url', - request.form.get('github_oauth_token_url')) - Setting().set('github_oauth_authorize_url', - request.form.get('github_oauth_authorize_url')) - result = { - 'status': True, - 'msg': - 'Saved successfully. Please reload PDA to take effect.' - } - elif conf_type == 'azure': - azure_oauth_enabled = True if request.form.get( - 'azure_oauth_enabled') else False - if not has_an_auth_method(azure_oauth_enabled=azure_oauth_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set('azure_oauth_enabled', azure_oauth_enabled) - Setting().set('azure_oauth_key', - request.form.get('azure_oauth_key')) - Setting().set('azure_oauth_secret', - request.form.get('azure_oauth_secret')) - Setting().set('azure_oauth_scope', - request.form.get('azure_oauth_scope')) - Setting().set('azure_oauth_api_url', - request.form.get('azure_oauth_api_url')) - Setting().set('azure_oauth_metadata_url', - request.form.get('azure_oauth_metadata_url')) - Setting().set('azure_oauth_token_url', - request.form.get('azure_oauth_token_url')) - Setting().set('azure_oauth_authorize_url', - request.form.get('azure_oauth_authorize_url')) - Setting().set( - 'azure_sg_enabled', True - if request.form.get('azure_sg_enabled') == 'ON' else False) - Setting().set('azure_admin_group', - request.form.get('azure_admin_group')) - Setting().set('azure_operator_group', - request.form.get('azure_operator_group')) - Setting().set('azure_user_group', - request.form.get('azure_user_group')) - Setting().set( - 'azure_group_accounts_enabled', True - if request.form.get('azure_group_accounts_enabled') == 'ON' else False) - Setting().set('azure_group_accounts_name', - request.form.get('azure_group_accounts_name')) - Setting().set('azure_group_accounts_name_re', - request.form.get('azure_group_accounts_name_re')) - Setting().set('azure_group_accounts_description', - request.form.get('azure_group_accounts_description')) - Setting().set('azure_group_accounts_description_re', - request.form.get('azure_group_accounts_description_re')) - result = { - 'status': True, - 'msg': - 'Saved successfully. Please reload PDA to take effect.' - } - elif conf_type == 'oidc': - oidc_oauth_enabled = True if request.form.get( - 'oidc_oauth_enabled') else False - if not has_an_auth_method(oidc_oauth_enabled=oidc_oauth_enabled): - result = { - 'status': - False, - 'msg': - 'Must have at least one authentication method enabled.' - } - else: - Setting().set( - 'oidc_oauth_enabled', - True if request.form.get('oidc_oauth_enabled') else False) - Setting().set('oidc_oauth_key', - request.form.get('oidc_oauth_key')) - Setting().set('oidc_oauth_secret', - request.form.get('oidc_oauth_secret')) - Setting().set('oidc_oauth_scope', - request.form.get('oidc_oauth_scope')) - Setting().set('oidc_oauth_api_url', - request.form.get('oidc_oauth_api_url')) - Setting().set('oidc_oauth_metadata_url', - request.form.get('oidc_oauth_metadata_url')) - Setting().set('oidc_oauth_token_url', - request.form.get('oidc_oauth_token_url')) - Setting().set('oidc_oauth_authorize_url', - request.form.get('oidc_oauth_authorize_url')) - Setting().set('oidc_oauth_logout_url', - request.form.get('oidc_oauth_logout_url')) - Setting().set('oidc_oauth_username', - request.form.get('oidc_oauth_username')) - Setting().set('oidc_oauth_firstname', - request.form.get('oidc_oauth_firstname')) - Setting().set('oidc_oauth_last_name', - request.form.get('oidc_oauth_last_name')) - Setting().set('oidc_oauth_email', - request.form.get('oidc_oauth_email')) - Setting().set('oidc_oauth_account_name_property', - request.form.get('oidc_oauth_account_name_property')) - Setting().set('oidc_oauth_account_description_property', - request.form.get('oidc_oauth_account_description_property')) - result = { - 'status': True, - 'msg': - 'Saved successfully. Please reload PDA to take effect.' - } - else: - return abort(400) - - return render_template('admin_setting_authentication.html', - result=result) + return result @admin_bp.route('/templates', methods=['GET', 'POST']) @@ -2097,16 +1848,16 @@ def global_search(): results = server.global_search(object_type='all', query=query) # Filter results to domains to which the user has access permission - if current_user.role.name not in [ 'Administrator', 'Operator' ]: + if current_user.role.name not in ['Administrator', 'Operator']: allowed_domains = db.session.query(Domain) \ .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter( - db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )) \ + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )) \ .with_entities(Domain.name) \ .all() allowed_domains = [value for value, in allowed_domains] diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 19fd277..2636cf1 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -45,6 +45,7 @@ index_bp = Blueprint('index', template_folder='templates', url_prefix='/') + @index_bp.before_app_first_request def register_modules(): global google @@ -68,7 +69,7 @@ def before_request(): # Check site is in maintenance mode maintenance = Setting().get('maintenance') if maintenance and current_user.is_authenticated and current_user.role.name not in [ - 'Administrator', 'Operator' + 'Administrator', 'Operator' ]: return render_template('maintenance.html') @@ -98,7 +99,11 @@ def google_login(): ) abort(400) else: - redirect_uri = url_for('google_authorized', _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + redirect_uri = url_for('google_authorized', **params) return google.authorize_redirect(redirect_uri) @@ -110,7 +115,11 @@ def github_login(): ) abort(400) else: - redirect_uri = url_for('github_authorized', _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + redirect_uri = url_for('github_authorized', **params) return github.authorize_redirect(redirect_uri) @@ -122,9 +131,11 @@ def azure_login(): ) abort(400) else: - redirect_uri = url_for('azure_authorized', - _external=True, - _scheme='https') + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + redirect_uri = url_for('azure_authorized', **params) return azure.authorize_redirect(redirect_uri) @@ -136,7 +147,11 @@ def oidc_login(): ) abort(400) else: - redirect_uri = url_for('oidc_authorized', _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + redirect_uri = url_for('oidc_authorized', **params) return oidc.authorize_redirect(redirect_uri) @@ -149,18 +164,18 @@ def login(): if 'google_token' in session: user_data = json.loads(google.get('userinfo').text) - first_name = user_data['given_name'] - surname = user_data['family_name'] - email = user_data['email'] - user = User.query.filter_by(username=email).first() + google_first_name = user_data['given_name'] + google_last_name = user_data['family_name'] + google_email = user_data['email'] + user = User.query.filter_by(username=google_email).first() if user is None: - user = User.query.filter_by(email=email).first() + user = User.query.filter_by(email=google_email).first() if not user: - user = User(username=email, - firstname=first_name, - lastname=surname, + user = User(username=google_email, + firstname=google_first_name, + lastname=google_last_name, plain_text_password=None, - email=email) + email=google_email) result = user.create_local_user() if not result['status']: @@ -172,10 +187,18 @@ def login(): return authenticate_user(user, 'Google OAuth') if 'github_token' in session: - me = json.loads(github.get('user').text) - github_username = me['login'] - github_name = me['name'] - github_email = me['email'] + user_data = json.loads(github.get('user').text) + github_username = user_data['login'] + github_first_name = user_data['name'] + github_last_name = '' + github_email = user_data['email'] + + # If the user's full name from GitHub contains at least two words, use the first word as the first name and + # the rest as the last name. + github_name_parts = github_first_name.split(' ') + if len(github_name_parts) > 1: + github_first_name = github_name_parts[0] + github_last_name = ' '.join(github_name_parts[1:]) user = User.query.filter_by(username=github_username).first() if user is None: @@ -183,8 +206,8 @@ def login(): if not user: user = User(username=github_username, plain_text_password=None, - firstname=github_name, - lastname='', + firstname=github_first_name, + lastname=github_last_name, email=github_email) result = user.create_local_user() @@ -198,8 +221,8 @@ def login(): if 'azure_token' in session: azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text - current_app.logger.info('Azure login returned: '+azure_info) - me = json.loads(azure_info) + current_app.logger.info('Azure login returned: ' + azure_info) + user_data = json.loads(azure_info) azure_info = azure.post('me/getMemberGroups', json={'securityEnabledOnly': False}).text @@ -211,15 +234,15 @@ def login(): else: mygroups = [] - azure_username = me["userPrincipalName"] - azure_givenname = me["givenName"] - azure_familyname = me["surname"] - if "mail" in me: - azure_email = me["mail"] + azure_username = user_data["userPrincipalName"] + azure_first_name = user_data["givenName"] + azure_last_name = user_data["surname"] + if "mail" in user_data: + azure_email = user_data["mail"] else: azure_email = "" if not azure_email: - azure_email = me["userPrincipalName"] + azure_email = user_data["userPrincipalName"] # Handle foreign principals such as guest users azure_email = re.sub(r"#.*$", "", azure_email) @@ -229,8 +252,8 @@ def login(): if not user: user = User(username=azure_username, plain_text_password=None, - firstname=azure_givenname, - lastname=azure_familyname, + firstname=azure_first_name, + lastname=azure_last_name, email=azure_email) result = user.create_local_user() @@ -250,30 +273,30 @@ def login(): if Setting().get('azure_sg_enabled'): if Setting().get('azure_admin_group') in mygroups: current_app.logger.info('Setting role for user ' + - azure_username + - ' to Administrator due to group membership') + azure_username + + ' to Administrator due to group membership') user.set_role("Administrator") else: if Setting().get('azure_operator_group') in mygroups: current_app.logger.info('Setting role for user ' + - azure_username + - ' to Operator due to group membership') + azure_username + + ' to Operator due to group membership') user.set_role("Operator") else: if Setting().get('azure_user_group') in mygroups: current_app.logger.info('Setting role for user ' + - azure_username + - ' to User due to group membership') + azure_username + + ' to User due to group membership') user.set_role("User") else: current_app.logger.warning('User ' + - azure_username + - ' has no relevant group memberships') + azure_username + + ' has no relevant group memberships') session.pop('azure_token', None) return render_template('login.html', - saml_enabled=SAML_ENABLED, - error=('User ' + azure_username + - ' is not in any authorised groups.')) + saml_enabled=SAML_ENABLED, + error=('User ' + azure_username + + ' is not in any authorised groups.')) # Handle account/group creation, if enabled if Setting().get('azure_group_accounts_enabled') and mygroups: @@ -369,23 +392,23 @@ def login(): return authenticate_user(user, 'Azure OAuth') if 'oidc_token' in session: - me = json.loads(oidc.get('userinfo').text) - oidc_username = me[Setting().get('oidc_oauth_username')] - oidc_givenname = me[Setting().get('oidc_oauth_firstname')] - oidc_familyname = me[Setting().get('oidc_oauth_last_name')] - oidc_email = me[Setting().get('oidc_oauth_email')] + user_data = json.loads(oidc.get('userinfo').text) + oidc_username = user_data[Setting().get('oidc_oauth_username')] + oidc_first_name = user_data[Setting().get('oidc_oauth_firstname')] + oidc_last_name = user_data[Setting().get('oidc_oauth_last_name')] + oidc_email = user_data[Setting().get('oidc_oauth_email')] user = User.query.filter_by(username=oidc_username).first() if not user: user = User(username=oidc_username, plain_text_password=None, - firstname=oidc_givenname, - lastname=oidc_familyname, + firstname=oidc_first_name, + lastname=oidc_last_name, email=oidc_email) result = user.create_local_user() else: - user.firstname = oidc_givenname - user.lastname = oidc_familyname + user.firstname = oidc_first_name + user.lastname = oidc_last_name user.email = oidc_email user.plain_text_password = None result = user.update_local_user() @@ -394,20 +417,22 @@ def login(): session.pop('oidc_token', None) return redirect(url_for('index.login')) - #This checks if the account_name_property and account_description property were included in settings. - if Setting().get('oidc_oauth_account_name_property') and Setting().get('oidc_oauth_account_description_property'): + # This checks if the account_name_property and account_description property were included in settings. + if Setting().get('oidc_oauth_account_name_property') and Setting().get( + 'oidc_oauth_account_description_property'): - #Gets the name_property and description_property. + # Gets the name_property and description_property. name_prop = Setting().get('oidc_oauth_account_name_property') desc_prop = Setting().get('oidc_oauth_account_description_property') account_to_add = [] - #If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP). - if name_prop in me and desc_prop in me: - accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop] - accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop] + # If the name_property and desc_property exist in me (A variable that contains all the userinfo from the + # IdP). + if name_prop in user_data and desc_prop in user_data: + accounts_name_prop = [user_data[name_prop]] if type(user_data[name_prop]) is not list else user_data[name_prop] + accounts_desc_prop = [user_data[desc_prop]] if type(user_data[desc_prop]) is not list else user_data[desc_prop] - #Run on all groups the user is in by the index num. + # Run on all groups the user is in by the index num. for i in range(len(accounts_name_prop)): description = '' if i < len(accounts_desc_prop): @@ -417,7 +442,7 @@ def login(): account_to_add.append(account) user_accounts = user.get_accounts() - # Add accounts + # Add accounts for account in account_to_add: if account not in user_accounts: account.add_user(user) @@ -426,7 +451,7 @@ def login(): if Setting().get('delete_sso_accounts'): for account in user_accounts: if account not in account_to_add: - account.remove_user(user) + account.remove_user(user) session['user_id'] = user.id session['authentication_type'] = 'OAuth' @@ -490,34 +515,36 @@ def login(): saml_enabled=SAML_ENABLED, error='Token required') - if Setting().get('autoprovisioning') and auth_method!='LOCAL': - urn_value=Setting().get('urn_value') - Entitlements=user.read_entitlements(Setting().get('autoprovisioning_attribute')) - if len(Entitlements)==0 and Setting().get('purge'): + if Setting().get('autoprovisioning') and auth_method != 'LOCAL': + urn_value = Setting().get('urn_value') + Entitlements = user.read_entitlements(Setting().get('autoprovisioning_attribute')) + if len(Entitlements) == 0 and Setting().get('purge'): user.set_role("User") user.revoke_privilege(True) - elif len(Entitlements)!=0: + elif len(Entitlements) != 0: if checkForPDAEntries(Entitlements, urn_value): user.updateUser(Entitlements) else: - current_app.logger.warning('Not a single powerdns-admin record was found, possibly a typo in the prefix') + current_app.logger.warning( + 'Not a single powerdns-admin record was found, possibly a typo in the prefix') if Setting().get('purge'): user.set_role("User") user.revoke_privilege(True) - current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.' ) + current_app.logger.warning('Procceding to revoke every privilige from ' + user.username + '.') return authenticate_user(user, auth_method, remember_me) + def checkForPDAEntries(Entitlements, urn_value): """ Run through every record located in the ldap attribute given and determine if there are any valid powerdns-admin records """ - urnArguments=[x.lower() for x in urn_value.split(':')] + urnArguments = [x.lower() for x in urn_value.split(':')] for Entitlement in Entitlements: - entArguments=Entitlement.split(':powerdns-admin') - entArguments=[x.lower() for x in entArguments[0].split(':')] - if (entArguments==urnArguments): + entArguments = Entitlement.split(':powerdns-admin') + entArguments = [x.lower() for x in entArguments[0].split(':')] + if (entArguments == urnArguments): return True return False @@ -553,14 +580,15 @@ def signin_history(username, authenticator, success): # Write history History(msg='User {} authentication {}'.format(username, str_success), - detail = json.dumps({ - 'username': username, - 'authenticator': authenticator, - 'ip_address': request_ip, - 'success': 1 if success else 0 - }), + detail=json.dumps({ + 'username': username, + 'authenticator': authenticator, + 'ip_address': request_ip, + 'success': 1 if success else 0 + }), created_by='System').add() + # Get a list of Azure security groups the user is a member of def get_azure_groups(uri): azure_info = azure.get(uri).text @@ -576,30 +604,33 @@ def get_azure_groups(uri): mygroups = [] return mygroups + # Handle user login, write history and, if set, handle showing the register_otp QR code. # if Setting for OTP on first login is enabled, and OTP field is also enabled, # but user isn't using it yet, enable OTP, get QR code and display it, logging the user out. def authenticate_user(user, authenticator, remember=False): login_user(user, remember=remember) signin_history(user.username, authenticator, True) - if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret and session['authentication_type'] not in ['OAuth']: + if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret \ + and session['authentication_type'] not in ['OAuth']: user.update_profile(enable_otp=True) user_id = current_user.id prepare_welcome_user(user_id) return redirect(url_for('index.welcome')) return redirect(url_for('index.login')) + # Prepare user to enter /welcome screen, otherwise they won't have permission to do so def prepare_welcome_user(user_id): logout_user() session['welcome_user_id'] = 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'): + ) and 'samlSessionIndex' in session and current_app.config.get('SAML_LOGOUT'): req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) if current_app.config.get('SAML_LOGOUT_URL'): @@ -651,13 +682,12 @@ def logout(): def password_policy_check(user, password): - def check_policy(chars, user_password, setting): - lenreq = int(Setting().get(setting)) + setting_as_int = int(Setting().get(setting)) test_string = user_password for c in chars: test_string = test_string.replace(c, '') - return (lenreq, len(user_password) - len(test_string)) + return (setting_as_int, len(user_password) - len(test_string)) def matches_policy(item, policy_fails): return "*" if item in policy_fails else "" @@ -704,12 +734,14 @@ def password_policy_check(user, password): (pwd_min_lowercase_setting, pwd_lowercase) = check_policy(string.digits, password, 'pwd_min_lowercase') if pwd_lowercase < pwd_min_lowercase_setting: policy_fails["lowercase"] = True - policy.append(f"{matches_policy('lowercase', policy_fails)}lowercase={pwd_lowercase}/{pwd_min_lowercase_setting}") + policy.append( + f"{matches_policy('lowercase', policy_fails)}lowercase={pwd_lowercase}/{pwd_min_lowercase_setting}") # Uppercase (pwd_min_uppercase_setting, pwd_uppercase) = check_policy(string.digits, password, 'pwd_min_uppercase') if pwd_uppercase < pwd_min_uppercase_setting: policy_fails["uppercase"] = True - policy.append(f"{matches_policy('uppercase', policy_fails)}uppercase={pwd_uppercase}/{pwd_min_uppercase_setting}") + policy.append( + f"{matches_policy('uppercase', policy_fails)}uppercase={pwd_uppercase}/{pwd_min_uppercase_setting}") # Special (pwd_min_special_setting, pwd_special) = check_policy(string.digits, password, 'pwd_min_special') if pwd_special < pwd_min_special_setting: @@ -728,7 +760,8 @@ def password_policy_check(user, password): pwd_complexity = result['guesses_log10'] if pwd_complexity < pwd_min_complexity_setting: policy_fails["complexity"] = True - policy.append(f"{matches_policy('complexity', policy_fails)}complexity={pwd_complexity:.0f}/{pwd_min_complexity_setting}") + policy.append( + f"{matches_policy('complexity', policy_fails)}complexity={pwd_complexity:.0f}/{pwd_min_complexity_setting}") policy_str = {"password": f"Fails policy: {', '.join(policy)}. Items prefixed with '*' failed."} @@ -738,77 +771,78 @@ def password_policy_check(user, password): @index_bp.route('/register', methods=['GET', 'POST']) def register(): - CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE') - if Setting().get('signup_enabled'): - if current_user.is_authenticated: - return redirect(url_for('index.index')) - if request.method == 'GET': - return render_template('register.html', captcha_enable=CAPTCHA_ENABLE) - elif request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - firstname = request.form.get('firstname', '').strip() - lastname = request.form.get('lastname', '').strip() - email = request.form.get('email', '').strip() - rpassword = request.form.get('rpassword', '') + CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE') + if Setting().get('signup_enabled'): + if current_user.is_authenticated: + return redirect(url_for('index.index')) + if request.method == 'GET': + return render_template('register.html', captcha_enable=CAPTCHA_ENABLE) + elif request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + firstname = request.form.get('firstname', '').strip() + lastname = request.form.get('lastname', '').strip() + email = request.form.get('email', '').strip() + rpassword = request.form.get('rpassword', '') - is_valid_email = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') + is_valid_email = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') - error_messages = {} - if not firstname: - error_messages['firstname'] = 'First Name is required' - if not lastname: - error_messages['lastname'] = 'Last Name is required' - if not username: - error_messages['username'] = 'Username is required' - if not password: - error_messages['password'] = 'Password is required' - if not rpassword: - error_messages['rpassword'] = 'Password confirmation is required' - if not email: - error_messages['email'] = 'Email is required' - if not is_valid_email.match(email): - error_messages['email'] = 'Invalid email address' - if password != rpassword: - error_messages['password'] = 'Password confirmation does not match' - error_messages['rpassword'] = 'Password confirmation does not match' + error_messages = {} + if not firstname: + error_messages['firstname'] = 'First Name is required' + if not lastname: + error_messages['lastname'] = 'Last Name is required' + if not username: + error_messages['username'] = 'Username is required' + if not password: + error_messages['password'] = 'Password is required' + if not rpassword: + error_messages['rpassword'] = 'Password confirmation is required' + if not email: + error_messages['email'] = 'Email is required' + if not is_valid_email.match(email): + error_messages['email'] = 'Invalid email address' + if password != rpassword: + error_messages['password'] = 'Password confirmation does not match' + error_messages['rpassword'] = 'Password confirmation does not match' - if not captcha.validate(): - return render_template( - 'register.html', error='Invalid CAPTCHA answer', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE) + if not captcha.validate(): + return render_template( + 'register.html', error='Invalid CAPTCHA answer', error_messages=error_messages, + captcha_enable=CAPTCHA_ENABLE) - if error_messages: - return render_template('register.html', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE) + if error_messages: + return render_template('register.html', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE) - user = User(username=username, - plain_text_password=password, - firstname=firstname, - lastname=lastname, - email=email - ) + user = User(username=username, + plain_text_password=password, + firstname=firstname, + lastname=lastname, + email=email + ) - (password_policy_pass, password_policy) = password_policy_check(user, password) - if not password_policy_pass: - return render_template('register.html', error_messages=password_policy, captcha_enable=CAPTCHA_ENABLE) + (password_policy_pass, password_policy) = password_policy_check(user, password) + if not password_policy_pass: + return render_template('register.html', error_messages=password_policy, captcha_enable=CAPTCHA_ENABLE) - try: - result = user.create_local_user() - if result and result['status']: - if Setting().get('verify_user_email'): - send_account_verification(email) - if Setting().get('otp_force') and Setting().get('otp_field_enabled'): - user.update_profile(enable_otp=True) - prepare_welcome_user(user.id) - return redirect(url_for('index.welcome')) - else: - return redirect(url_for('index.login')) + try: + result = user.create_local_user() + if result and result['status']: + if Setting().get('verify_user_email'): + send_account_verification(email) + if Setting().get('otp_force') and Setting().get('otp_field_enabled'): + user.update_profile(enable_otp=True) + prepare_welcome_user(user.id) + return redirect(url_for('index.welcome')) + else: + return redirect(url_for('index.login')) + else: + return render_template('register.html', + error=result['msg'], captcha_enable=CAPTCHA_ENABLE) + except Exception as e: + return render_template('register.html', error=e, captcha_enable=CAPTCHA_ENABLE) else: - return render_template('register.html', - error=result['msg'], captcha_enable=CAPTCHA_ENABLE) - except Exception as e: - return render_template('register.html', error=e, captcha_enable=CAPTCHA_ENABLE) - else: - return render_template('errors/404.html'), 404 + return render_template('errors/404.html'), 404 # Show welcome page on first login if otp_force is enabled @@ -827,12 +861,15 @@ def welcome(): if otp_token and otp_token.isdigit(): good_token = user.verify_totp(otp_token) if not good_token: - return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Invalid token") + return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, + error="Invalid token") else: - return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, error="Token required") + return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user, + error="Token required") session.pop('welcome_user_id') return redirect(url_for('index.index')) + @index_bp.route('/confirm/', methods=['GET']) def confirm_email(token): email = confirm_token(token) @@ -919,10 +956,10 @@ def dyndns_update(): .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter( - db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )).all() + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )).all() except Exception as e: current_app.logger.error('DynDNS Error: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) @@ -982,13 +1019,13 @@ def dyndns_update(): if result['status'] == 'ok': history = History( msg='DynDNS update: updated {} successfully'.format(hostname), - detail = json.dumps({ - 'domain': domain.name, - 'record': hostname, - 'type': rtype, - 'old_value': oldip, - 'new_value': str(ip) - }), + detail=json.dumps({ + 'domain': domain.name, + 'record': hostname, + 'type': rtype, + 'old_value': oldip, + 'new_value': str(ip) + }), created_by=current_user.username, domain_id=domain.id) history.add() @@ -999,7 +1036,7 @@ def dyndns_update(): elif r.is_allowed_edit(): ondemand_creation = DomainSetting.query.filter( DomainSetting.domain == domain).filter( - DomainSetting.setting == 'create_via_dyndns').first() + DomainSetting.setting == 'create_via_dyndns').first() if (ondemand_creation is not None) and (strtobool( ondemand_creation.value) == True): @@ -1024,11 +1061,11 @@ def dyndns_update(): msg= 'DynDNS update: created record {0} in zone {1} successfully' .format(hostname, domain.name, str(ip)), - detail = json.dumps({ - 'domain': domain.name, - 'record': hostname, - 'value': str(ip) - }), + detail=json.dumps({ + 'domain': domain.name, + 'record': hostname, + 'value': str(ip) + }), created_by=current_user.username, domain_id=domain.id) history.add() @@ -1088,7 +1125,7 @@ def saml_authorized(): req = saml.prepare_flask_request(request) auth = saml.init_saml_auth(req) auth.process_response() - current_app.logger.debug( auth.get_attributes() ) + current_app.logger.debug(auth.get_attributes()) errors = auth.get_errors() if len(errors) == 0: session['samlUserdata'] = auth.get_attributes() @@ -1097,7 +1134,7 @@ def saml_authorized(): self_url = OneLogin_Saml2_Utils.get_self_url(req) self_url = self_url + req['script_name'] if 'RelayState' in request.form and self_url != request.form[ - 'RelayState']: + 'RelayState']: return redirect(auth.redirect_to(request.form['RelayState'])) if current_app.config.get('SAML_ATTRIBUTE_USERNAME', False): username = session['samlUserdata'][ @@ -1129,7 +1166,7 @@ def saml_authorized(): admin_group_name = current_app.config.get('SAML_GROUP_ADMIN_NAME', None) operator_group_name = current_app.config.get('SAML_GROUP_OPERATOR_NAME', - None) + None) group_to_account_mapping = create_group_to_account_mapping() if email_attribute_name in session['samlUserdata']: @@ -1170,13 +1207,13 @@ def saml_authorized(): account.add_user(user) history = History(msg='Adding {0} to account {1}'.format( user.username, account.name), - created_by='SAML Assertion') + created_by='SAML Assertion') history.add() for account in user_accounts - saml_accounts: account.remove_user(user) history = History(msg='Removing {0} from account {1}'.format( user.username, account.name), - created_by='SAML Assertion') + created_by='SAML Assertion') history.add() if admin_attribute_name and 'true' in session['samlUserdata'].get( admin_attribute_name, []): @@ -1190,7 +1227,7 @@ def saml_authorized(): 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') + created_by='SAML Assertion') history.add() user.plain_text_password = None user.update_profile() @@ -1234,15 +1271,16 @@ def uplift_to_admin(user): 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') + created_by='SAML Assertion') history.add() + def uplift_to_operator(user): if user.role.name != 'Operator': user.role_id = Role.query.filter_by(name='Operator').first().id history = History(msg='Promoting {0} to operator'.format( user.username), - created_by='SAML Assertion') + created_by='SAML Assertion') history.add() diff --git a/powerdnsadmin/services/azure.py b/powerdnsadmin/services/azure.py index 65f3bf3..901cc45 100644 --- a/powerdnsadmin/services/azure.py +++ b/powerdnsadmin/services/azure.py @@ -20,8 +20,6 @@ def azure_oauth(): 'client_secret': Setting().get('azure_oauth_secret'), 'api_base_url': Setting().get('azure_oauth_api_url'), 'request_token_url': None, - 'access_token_url': Setting().get('azure_oauth_token_url'), - 'authorize_url': Setting().get('azure_oauth_authorize_url'), 'client_kwargs': {'scope': Setting().get('azure_oauth_scope')}, 'fetch_token': fetch_azure_token, } @@ -30,6 +28,9 @@ def azure_oauth(): if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url + else: + authlib_params['access_token_url'] = Setting().get('azure_oauth_token_url') + authlib_params['authorize_url'] = Setting().get('azure_oauth_authorize_url') azure = authlib_oauth_client.register( 'azure', @@ -38,14 +39,16 @@ def azure_oauth(): @current_app.route('/azure/authorized') def azure_authorized(): - session['azure_oauthredir'] = url_for('.azure_authorized', - _external=True, - _scheme='https') + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + session['azure_oauthredir'] = url_for('.azure_authorized', **params) token = azure.authorize_access_token() if token is None: return 'Access denied: reason=%s error=%s' % ( request.args['error'], request.args['error_description']) session['azure_token'] = (token) - return redirect(url_for('index.login', _external=True, _scheme='https')) + return redirect(url_for('index.login', **params)) return azure diff --git a/powerdnsadmin/services/github.py b/powerdnsadmin/services/github.py index ff4a20f..f322e8c 100644 --- a/powerdnsadmin/services/github.py +++ b/powerdnsadmin/services/github.py @@ -21,8 +21,6 @@ def github_oauth(): 'request_token_params': {'scope': Setting().get('github_oauth_scope')}, 'api_base_url': Setting().get('github_oauth_api_url'), 'request_token_url': None, - 'access_token_url': Setting().get('github_oauth_token_url'), - 'authorize_url': Setting().get('github_oauth_authorize_url'), 'client_kwargs': {'scope': Setting().get('github_oauth_scope')}, 'fetch_token': fetch_github_token, 'update_token': update_token @@ -32,6 +30,9 @@ def github_oauth(): if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url + else: + authlib_params['access_token_url'] = Setting().get('github_oauth_token_url') + authlib_params['authorize_url'] = Setting().get('github_oauth_authorize_url') github = authlib_oauth_client.register( 'github', @@ -40,13 +41,16 @@ def github_oauth(): @current_app.route('/github/authorized') def github_authorized(): - session['github_oauthredir'] = url_for('.github_authorized', - _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + session['github_oauthredir'] = url_for('.github_authorized', **params) token = github.authorize_access_token() if token is None: return 'Access denied: reason=%s error=%s' % ( request.args['error'], request.args['error_description']) - session['github_token'] = (token) - return redirect(url_for('index.login')) + session['github_token'] = token + return redirect(url_for('index.login', **params)) return github diff --git a/powerdnsadmin/services/google.py b/powerdnsadmin/services/google.py index 5604819..011c120 100644 --- a/powerdnsadmin/services/google.py +++ b/powerdnsadmin/services/google.py @@ -20,8 +20,6 @@ def google_oauth(): 'client_secret': Setting().get('google_oauth_client_secret'), 'api_base_url': Setting().get('google_base_url'), 'request_token_url': None, - 'access_token_url': Setting().get('google_token_url'), - 'authorize_url': Setting().get('google_authorize_url'), 'client_kwargs': {'scope': Setting().get('google_oauth_scope')}, 'fetch_token': fetch_google_token, 'update_token': update_token @@ -31,6 +29,9 @@ def google_oauth(): if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url + else: + authlib_params['access_token_url'] = Setting().get('google_token_url') + authlib_params['authorize_url'] = Setting().get('google_authorize_url') google = authlib_oauth_client.register( 'google', @@ -39,16 +40,18 @@ def google_oauth(): @current_app.route('/google/authorized') def google_authorized(): - session['google_oauthredir'] = url_for( - '.google_authorized', _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + session['google_oauthredir'] = url_for('.google_authorized', **params) token = google.authorize_access_token() if token is None: return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], request.args['error_description'] ) - session['google_token'] = (token) - return redirect(url_for('index.login')) + session['google_token'] = token + return redirect(url_for('index.login', **params)) return google - diff --git a/powerdnsadmin/services/oidc.py b/powerdnsadmin/services/oidc.py index 7b0cd46..25c73f0 100644 --- a/powerdnsadmin/services/oidc.py +++ b/powerdnsadmin/services/oidc.py @@ -20,8 +20,6 @@ def oidc_oauth(): 'client_secret': Setting().get('oidc_oauth_secret'), 'api_base_url': Setting().get('oidc_oauth_api_url'), 'request_token_url': None, - 'access_token_url': Setting().get('oidc_oauth_token_url'), - 'authorize_url': Setting().get('oidc_oauth_authorize_url'), 'client_kwargs': {'scope': Setting().get('oidc_oauth_scope')}, 'fetch_token': fetch_oidc_token, 'update_token': update_token @@ -31,6 +29,9 @@ def oidc_oauth(): if isinstance(server_metadata_url, str) and len(server_metadata_url.strip()) > 0: authlib_params['server_metadata_url'] = server_metadata_url + else: + authlib_params['access_token_url'] = Setting().get('oidc_oauth_token_url') + authlib_params['authorize_url'] = Setting().get('oidc_oauth_authorize_url') oidc = authlib_oauth_client.register( 'oidc', @@ -39,13 +40,16 @@ def oidc_oauth(): @current_app.route('/oidc/authorized') def oidc_authorized(): - session['oidc_oauthredir'] = url_for('.oidc_authorized', - _external=True) + use_ssl = current_app.config.get('SERVER_EXTERNAL_SSL') + params = {'_external': True} + if isinstance(use_ssl, bool): + params['_scheme'] = 'https' if use_ssl else 'http' + session['oidc_oauthredir'] = url_for('.oidc_authorized', **params) token = oidc.authorize_access_token() if token is None: return 'Access denied: reason=%s error=%s' % ( request.args['error'], request.args['error_description']) - session['oidc_token'] = (token) - return redirect(url_for('index.login')) + session['oidc_token'] = token + return redirect(url_for('index.login', **params)) - return oidc \ No newline at end of file + return oidc diff --git a/powerdnsadmin/static/custom/js/app-authentication-settings-editor.js b/powerdnsadmin/static/custom/js/app-authentication-settings-editor.js new file mode 100644 index 0000000..fe3a942 --- /dev/null +++ b/powerdnsadmin/static/custom/js/app-authentication-settings-editor.js @@ -0,0 +1,801 @@ +let AuthenticationSettingsModel = function (user_data, api_url, csrf_token, selector) { + let self = this; + let target = null; + self.api_url = api_url; + self.csrf_token = csrf_token; + self.selector = selector; + self.loading = false; + self.saving = false; + self.saved = false; + self.save_failed = false; + self.messages = []; + self.messages_class = 'info'; + self.tab_active = ''; + self.tab_default = 'local'; + + let defaults = { + // Local Authentication Settings + local_db_enabled: 1, + signup_enabled: 1, + pwd_enforce_characters: 0, + pwd_min_len: 10, + pwd_min_lowercase: 3, + pwd_min_uppercase: 2, + pwd_min_digits: 2, + pwd_min_special: 1, + pwd_enforce_complexity: 0, + pwd_min_complexity: 11, + + // LDAP Authentication Settings + ldap_enabled: 0, + ldap_type: 'ldap', + ldap_uri: '', + ldap_base_dn: '', + ldap_admin_username: '', + ldap_admin_password: '', + ldap_domain: '', + ldap_filter_basic: '', + ldap_filter_username: '', + ldap_filter_group: '', + ldap_filter_groupname: '', + ldap_sg_enabled: 0, + ldap_admin_group: '', + ldap_operator_group: '', + ldap_user_group: '', + autoprovisioning: 0, + autoprovisioning_attribute: '', + urn_value: '', + purge: 0, + + // Google OAuth2 Settings + google_oauth_enabled: 0, + google_oauth_client_id: '', + google_oauth_client_secret: '', + google_oauth_scope: '', + google_base_url: '', + google_oauth_auto_configure: 1, + google_oauth_metadata_url: '', + google_token_url: '', + google_authorize_url: '', + + // GitHub OAuth2 Settings + github_oauth_enabled: 0, + github_oauth_key: '', + github_oauth_secret: '', + github_oauth_scope: '', + github_oauth_api_url: '', + github_oauth_auto_configure: 0, + github_oauth_metadata_url: '', + github_oauth_token_url: '', + github_oauth_authorize_url: '', + + // Azure AD OAuth2 Settings + azure_oauth_enabled: 0, + azure_oauth_key: '', + azure_oauth_secret: '', + azure_oauth_scope: '', + azure_oauth_api_url: '', + azure_oauth_auto_configure: 1, + azure_oauth_metadata_url: '', + azure_oauth_token_url: '', + azure_oauth_authorize_url: '', + azure_sg_enabled: 0, + azure_admin_group: '', + azure_operator_group: '', + azure_user_group: '', + azure_group_accounts_enabled: 0, + azure_group_accounts_name: '', + azure_group_accounts_name_re: '', + azure_group_accounts_description: '', + azure_group_accounts_description_re: '', + + // OIDC OAuth2 Settings + oidc_oauth_enabled: 0, + oidc_oauth_key: '', + oidc_oauth_secret: '', + oidc_oauth_scope: '', + oidc_oauth_api_url: '', + oidc_oauth_auto_configure: 1, + oidc_oauth_metadata_url: '', + oidc_oauth_token_url: '', + oidc_oauth_authorize_url: '', + oidc_oauth_logout_url: '', + oidc_oauth_username: '', + oidc_oauth_email: '', + oidc_oauth_firstname: '', + oidc_oauth_last_name: '', + oidc_oauth_account_name_property: '', + oidc_oauth_account_description_property: '', + } + + self.init = function (autoload) { + self.loading = ko.observable(self.loading); + self.saving = ko.observable(self.saving); + self.saved = ko.observable(self.saved); + self.save_failed = ko.observable(self.save_failed); + self.messages = ko.observableArray(self.messages); + self.messages_class = ko.observable(self.messages_class); + self.tab_active = ko.observable(self.tab_active); + self.tab_default = ko.observable(self.tab_default); + self.update(user_data); + + let el = null; + if (typeof selector !== 'undefined') { + el = $(selector) + } + + if (el !== null && el.length > 0) { + target = el; + ko.applyBindings(self, el[0]); + } else { + ko.applyBindings(self); + } + + if (self.hasHash()) { + self.activateTab(self.getHash()); + } else { + self.activateDefaultTab(); + } + + self.setupListeners(); + self.setupValidation(); + + if (autoload) { + self.load(); + } + } + + self.load = function () { + self.loading(true); + $.ajax({ + url: self.api_url, + type: 'POST', + data: {_csrf_token: csrf_token}, + dataType: 'json', + success: self.onDataLoaded + }); + } + + self.save = function () { + if (!target.valid()) { + return false; + } + self.saving(true); + $.ajax({ + url: self.api_url, + type: 'POST', + data: {_csrf_token: csrf_token, commit: 1, data: ko.toJSON(self)}, + dataType: 'json', + success: self.onDataSaved + }); + } + + self.update = function (instance) { + for (const [key, value] of Object.entries($.extend(defaults, instance))) { + if (ko.isObservable(self[key])) { + self[key](value); + } else { + self[key] = ko.observable(value); + } + } + } + + self.setupListeners = function () { + if ('onhashchange' in window) { + $(window).bind('hashchange', self.onHashChange); + } + } + + self.destroyListeners = function () { + if ('onhashchange' in window) { + $(window).unbind('hashchange', self.onHashChange); + } + } + + self.setupValidation = function () { + let uuidRegExp = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})|[0-9]+$/i; + + let footerErrorElements = [ + 'input#local_db_enabled', + ]; + + let errorCheckSelectors = [ + 'input.error:not([disabled])', + 'select.error:not([disabled])', + 'textarea.error:not([disabled])', + ]; + + let errorCheckQuery = errorCheckSelectors.join(','); + let tabs = target.find('.tab-content > *[data-tab]') + + let onElementChanged = function (event) { + target.valid(); + } + + let auth_enabled = function (value, element, params) { + let enabled = 0; + if (self.local_db_enabled()) { + enabled++; + } + if (self.ldap_enabled()) { + enabled++; + } + if (self.google_oauth_enabled()) { + enabled++; + } + if (self.github_oauth_enabled()) { + enabled++; + } + if (self.azure_oauth_enabled()) { + enabled++; + } + if (self.oidc_oauth_enabled()) { + enabled++; + } + return enabled > 0; + }; + + let ldap_exclusive = function (value, element, params) { + let enabled = 0; + if (self.ldap_sg_enabled() === 1) { + enabled++; + } + if (self.autoprovisioning() === 1) { + enabled++; + } + return enabled < 2; + } + + let uuid = function (value, element, params) { + return uuidRegExp.test(value); + } + + let local_enabled = function (element) { + return self.local_db_enabled(); + }; + + let ldap_enabled = function (element) { + return self.ldap_enabled(); + }; + + let google_oauth_enabled = function (element) { + return self.google_oauth_enabled(); + }; + + let github_oauth_enabled = function (element) { + return self.github_oauth_enabled(); + }; + + let azure_oauth_enabled = function (element) { + return self.azure_oauth_enabled(); + }; + + let oidc_oauth_enabled = function (element) { + return self.oidc_oauth_enabled(); + }; + + let enforce_characters = function (element) { + return self.local_db_enabled() === 1 && self.pwd_enforce_characters() === 1; + }; + + let enforce_complexity = function (element) { + return self.local_db_enabled() === 1 && self.pwd_enforce_complexity() === 1; + }; + + let ldap_type_openldap = function (element) { + return self.ldap_enabled() && self.ldap_type() === 'ldap'; + }; + + let ldap_type_ad = function (element) { + return self.ldap_enabled() && self.ldap_type() === 'ad'; + }; + + let ldap_sg_enabled = function (element) { + return self.ldap_enabled() === 1 && self.ldap_sg_enabled() === 1; + } + + let ldap_ap_enabled = function (element) { + return self.ldap_enabled() === 1 && self.autoprovisioning() === 1; + } + + let azure_gs_enabled = function (element) { + return self.azure_oauth_enabled() === 1 && self.azure_sg_enabled() === 1; + } + + let azure_gas_enabled = function (element) { + return self.azure_oauth_enabled() && self.azure_group_accounts_enabled(); + } + + let google_oauth_auto_configure_enabled = function (element) { + return self.google_oauth_enabled() && self.google_oauth_auto_configure(); + } + + let google_oauth_auto_configure_disabled = function (element) { + return self.google_oauth_enabled() && !self.google_oauth_auto_configure(); + } + + let github_oauth_auto_configure_enabled = function (element) { + return self.github_oauth_enabled() && self.github_oauth_auto_configure(); + } + + let github_oauth_auto_configure_disabled = function (element) { + return self.github_oauth_enabled() && !self.github_oauth_auto_configure(); + } + + let azure_oauth_auto_configure_enabled = function (element) { + return self.azure_oauth_enabled() && self.azure_oauth_auto_configure(); + } + + let azure_oauth_auto_configure_disabled = function (element) { + return self.azure_oauth_enabled() && !self.azure_oauth_auto_configure(); + } + + let oidc_oauth_auto_configure_enabled = function (element) { + return self.oidc_oauth_enabled() && self.oidc_oauth_auto_configure(); + } + + let oidc_oauth_auto_configure_disabled = function (element) { + return self.oidc_oauth_enabled() && !self.oidc_oauth_auto_configure(); + } + + jQuery.validator.addMethod('auth_enabled', auth_enabled, 'At least one authentication method must be enabled.'); + jQuery.validator.addMethod('ldap_exclusive', ldap_exclusive, 'The LDAP group security and role auto-provisioning features are mutually exclusive.'); + jQuery.validator.addMethod('uuid', uuid, 'A valid UUID is required.'); + + target.validate({ + ignore: '', + errorPlacement: function (error, element) { + let useFooter = false; + for (let i = 0; i < footerErrorElements.length; i++) { + if (element.is(footerErrorElements[i])) { + useFooter = true; + } + } + if (useFooter) { + target.find('.card-footer > .error').append(error); + } else if (element.is('input[type=radio]')) { + error.insertAfter(element.parents('div.radio')); + } else { + element.after(error); + } + }, + showErrors: function (errorMap, errorList) { + this.defaultShowErrors(); + tabs.each(function (index, tab) { + tab = $(tab); + let tabId = tab.data('tab'); + let tabLink = target.find('.nav-tabs > li > a[data-tab="' + tabId + '"]'); + if (tab.find(errorCheckQuery).length > 0) { + tabLink.addClass('error'); + } else { + tabLink.removeClass('error'); + } + }); + }, + rules: { + local_db_enabled: 'auth_enabled', + ldap_enabled: 'auth_enabled', + google_oauth_enabled: 'auth_enabled', + github_oauth_enabled: 'auth_enabled', + azure_oauth_enabled: 'auth_enabled', + oidc_oauth_enabled: 'auth_enabled', + pwd_min_len: { + required: enforce_characters, + digits: true, + min: 1, + max: 64, + }, + pwd_min_lowercase: { + required: enforce_characters, + digits: true, + min: 0, + max: 64, + }, + pwd_min_uppercase: { + required: enforce_characters, + digits: true, + min: 0, + max: 64, + }, + pwd_min_digits: { + required: enforce_characters, + digits: true, + min: 0, + max: 64, + }, + pwd_min_special: { + required: enforce_characters, + digits: true, + min: 0, + max: 64, + }, + pwd_min_complexity: { + required: enforce_complexity, + digits: true, + min: 1, + max: 1000, + }, + ldap_type: ldap_enabled, + ldap_uri: { + required: ldap_enabled, + minlength: 11, + maxlength: 255, + }, + ldap_base_dn: { + required: ldap_enabled, + minlength: 4, + maxlength: 255, + }, + ldap_admin_username: { + required: ldap_type_openldap, + minlength: 4, + maxlength: 255, + }, + ldap_admin_password: { + required: ldap_type_openldap, + minlength: 1, + maxlength: 255, + }, + ldap_domain: { + required: ldap_type_ad, + minlength: 1, + maxlength: 255, + }, + ldap_filter_basic: { + required: ldap_enabled, + minlength: 3, + maxlength: 1000, + }, + ldap_filter_username: { + required: ldap_enabled, + minlength: 1, + maxlength: 100, + }, + ldap_filter_group: { + required: ldap_type_openldap, + minlength: 3, + maxlength: 100, + }, + ldap_filter_groupname: { + required: ldap_type_openldap, + minlength: 1, + maxlength: 100, + }, + ldap_sg_enabled: { + required: ldap_enabled, + ldap_exclusive: true, + }, + ldap_admin_group: { + required: ldap_sg_enabled, + minlength: 3, + maxlength: 100, + }, + ldap_operator_group: { + required: ldap_sg_enabled, + minlength: 3, + maxlength: 100, + }, + ldap_user_group: { + required: ldap_sg_enabled, + minlength: 3, + maxlength: 100, + }, + autoprovisioning: { + required: ldap_enabled, + ldap_exclusive: true, + }, + autoprovisioning_attribute: { + required: ldap_ap_enabled, + minlength: 1, + maxlength: 100, + }, + urn_value: { + required: ldap_ap_enabled, + minlength: 1, + maxlength: 100, + }, + purge: ldap_enabled, + google_oauth_client_id: { + required: google_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + google_oauth_client_secret: { + required: google_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + google_oauth_scope: { + required: google_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + google_base_url: { + required: google_oauth_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + google_oauth_metadata_url: { + required: google_oauth_auto_configure_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + google_token_url: { + required: google_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + google_authorize_url: { + required: google_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + github_oauth_key: { + required: github_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + github_oauth_secret: { + required: github_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + github_oauth_scope: { + required: github_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + github_oauth_api_url: { + required: github_oauth_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + github_oauth_metadata_url: { + required: github_oauth_auto_configure_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + github_oauth_token_url: { + required: github_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + github_oauth_authorize_url: { + required: github_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + azure_oauth_key: { + required: azure_oauth_enabled, + minlength: 1, + maxlength: 255, + uuid: true, + }, + azure_oauth_secret: { + required: azure_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + azure_oauth_scope: { + required: azure_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + azure_oauth_api_url: { + required: azure_oauth_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + azure_oauth_metadata_url: { + required: azure_oauth_auto_configure_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + azure_oauth_token_url: { + required: azure_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + azure_oauth_authorize_url: { + required: azure_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + azure_sg_enabled: azure_oauth_enabled, + azure_admin_group: { + uuid: azure_gs_enabled, + }, + azure_operator_group: { + uuid: azure_gs_enabled, + }, + azure_user_group: { + uuid: azure_gs_enabled, + }, + azure_group_accounts_enabled: azure_oauth_enabled, + azure_group_accounts_name: { + required: azure_gas_enabled, + minlength: 1, + maxlength: 255, + }, + azure_group_accounts_name_re: { + required: azure_gas_enabled, + minlength: 1, + maxlength: 255, + }, + azure_group_accounts_description: { + required: azure_gas_enabled, + minlength: 1, + maxlength: 255, + }, + azure_group_accounts_description_re: { + required: azure_gas_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_key: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_secret: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_scope: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_api_url: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + oidc_oauth_metadata_url: { + required: oidc_oauth_auto_configure_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + oidc_oauth_token_url: { + required: oidc_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + oidc_oauth_authorize_url: { + required: oidc_oauth_auto_configure_disabled, + minlength: 1, + maxlength: 255, + url: true, + }, + oidc_oauth_logout_url: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + url: true, + }, + oidc_oauth_username: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_email: { + required: oidc_oauth_enabled, + minlength: 1, + maxlength: 255, + }, + oidc_oauth_firstname: { + minlength: 0, + maxlength: 255, + }, + oidc_oauth_last_name: { + minlength: 0, + maxlength: 255, + }, + oidc_oauth_account_name_property: { + minlength: 0, + maxlength: 255, + }, + oidc_oauth_account_description_property: { + minlength: 0, + maxlength: 255, + }, + }, + messages: { + ldap_sg_enabled: { + ldap_exclusive: 'The LDAP group security feature is mutually exclusive with the LDAP role auto-provisioning feature.', + }, + autoprovisioning: { + ldap_exclusive: 'The LDAP role auto-provisioning feature is mutually exclusive with the LDAP group security feature.', + }, + }, + }); + + target.find('input, select, textarea, label').on('change,keyup,blur,click', onElementChanged); + target.valid(); + } + + self.activateTab = function (tab) { + $('[role="tablist"] a.nav-link').blur(); + self.tab_active(tab); + window.location.hash = tab; + } + + self.activateDefaultTab = function () { + self.activateTab(self.tab_default()); + } + + self.getHash = function () { + return window.location.hash.substring(1); + } + + self.hasHash = function () { + return window.location.hash.length > 1; + } + + self.onDataLoaded = function (result) { + if (result.status == 0) { + self.messages_class('danger'); + self.messages(result.messages); + self.loading(false); + return false; + } + + self.update(result.data); + self.messages_class('info'); + self.messages(result.messages); + self.loading(false); + } + + self.onDataSaved = function (result) { + if (result.status == 0) { + self.saved(false); + self.save_failed(true); + self.messages_class('danger'); + self.messages(result.messages); + self.saving(false); + return false; + } + + self.update(result.data); + self.saved(true); + self.save_failed(false); + self.messages_class('info'); + self.messages(result.messages); + self.saving(false); + } + + self.onHashChange = function (event) { + let hash = window.location.hash.trim(); + if (hash.length > 1) { + self.activateTab(hash.substring(1)); + } else { + self.activateDefaultTab(); + } + } + + self.onSaveClick = function (model, event) { + self.save(); + return false; + } + + self.onTabClick = function (model, event) { + self.activateTab($(event.target).data('tab')); + return false; + } +} diff --git a/powerdnsadmin/templates/admin_setting_authentication.html b/powerdnsadmin/templates/admin_setting_authentication.html index cbe6800..c44367d 100644 --- a/powerdnsadmin/templates/admin_setting_authentication.html +++ b/powerdnsadmin/templates/admin_setting_authentication.html @@ -12,6 +12,7 @@
@@ -25,68 +26,101 @@
-
-
-

Settings Editor

-
- -
- {% if result %} -
- - {{ result['msg'] }} +
+
+
+

Settings Editor

+
+ +
+
+
+ +
+
- {% endif %} - - + + +
- -
- + +
@@ -1510,423 +1529,19 @@ {% endblock %} +{% block head_styles %} + +{% endblock %} + {% block extrascripts %} - {% assets "js_validation" -%} - - {%- endassets %} - {% endblock %} - -{% block modals %} - - - -{% endblock %} diff --git a/powerdnsadmin/templates/base.html b/powerdnsadmin/templates/base.html index 408f4be..c8708e8 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -228,7 +228,7 @@
PowerDNS-Admin - A PowerDNS web interface with advanced features. - Version 0.4.0 + Version 0.4.1
diff --git a/yarn.lock b/yarn.lock index 38a4fcd..6420088 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42,7 +42,7 @@ resolved "https://registry.yarnpkg.com/@foliojs-fork/restructure/-/restructure-2.0.2.tgz#73759aba2aff1da87b7c4554e6839c70d43c92b4" integrity sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA== -"@fortawesome/fontawesome-free@6.3.0": +"@fortawesome/fontawesome-free@6.3.0", "@fortawesome/fontawesome-free@^5.15.4": version "6.3.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.3.0.tgz#b5877182692a6f7a39d1108837bec24247ba4bd7" integrity sha512-qVtd5i1Cc7cdrqnTWqTObKQHjPWAiRwjUPaXObaeNPcy7+WKxJumGBx66rfSFgK6LNpIasVKkEgW8oyf0tmPLA== @@ -1026,7 +1026,7 @@ jquery-ui-dist@^1.13.0, jquery-ui-dist@^1.13.2: dependencies: jquery ">=1.8.0 <4.0.0" -jquery-validation@^1.19.3: +jquery-validation@^1.19.3, jquery-validation@^1.19.5: version "1.19.5" resolved "https://registry.yarnpkg.com/jquery-validation/-/jquery-validation-1.19.5.tgz#557495b7cad79716897057c4447ad3cd76fda811" integrity sha512-X2SmnPq1mRiDecVYL8edWx+yTBZDyC8ohWXFhXdtqFHgU9Wd4KHkvcbCoIZ0JaSaumzS8s2gXSkP8F7ivg/8ZQ== @@ -1081,6 +1081,11 @@ jtimeout@^3.2.0: dependencies: jquery ">=1.7.1 <4.0.0" +knockout@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/knockout/-/knockout-3.5.1.tgz#62c81e81843bea2008fd23c575edd9ca978e75cf" + integrity sha512-wRJ9I4az0QcsH7A4v4l0enUpkS++MBx0BnL/68KaLzJg7x1qmbjSlwEoCNol7KTYZ+pmtI7Eh2J0Nu6/2Z5J/Q== + levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
Associate: {{ history_assoc_account }}