diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index 4c005da..fec231e 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -4,8 +4,10 @@ name: MegaLinter on: + workflow_dispatch: push: branches-ignore: + - "*" - "dev" - "main" - "master" diff --git a/configs/development.py b/configs/development.py index 6d1dd0d..e92ac72 100644 --- a/configs/development.py +++ b/configs/development.py @@ -27,6 +27,7 @@ CAPTCHA_SESSION_KEY = 'captcha_image' SESSION_TYPE = 'sqlalchemy' ### DATABASE - MySQL +## Don't forget to uncomment the import in the top #SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( # urllib.parse.quote_plus(SQLA_DB_USER), # urllib.parse.quote_plus(SQLA_DB_PASSWORD), @@ -34,6 +35,15 @@ SESSION_TYPE = 'sqlalchemy' # SQLA_DB_NAME #) +### DATABASE - PostgreSQL +## Don't forget to uncomment the import in the top +#SQLALCHEMY_DATABASE_URI = 'postgres://{}:{}@{}/{}'.format( +# urllib.parse.quote_plus(SQLA_DB_USER), +# urllib.parse.quote_plus(SQLA_DB_PASSWORD), +# SQLA_DB_HOST, +# SQLA_DB_NAME +#) + ### DATABASE - SQLite SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') 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/docker-test/Dockerfile b/docker-test/Dockerfile index 17f934f..7191825 100644 --- a/docker-test/Dockerfile +++ b/docker-test/Dockerfile @@ -11,6 +11,7 @@ RUN apt-get update -y \ libffi-dev \ libldap2-dev \ libmariadb-dev-compat \ + libpq-dev \ libsasl2-dev \ libssl-dev \ libxml2-dev \ diff --git a/docker/Dockerfile b/docker/Dockerfile index b553998..55ccdfd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,6 +2,7 @@ FROM alpine:3.17 AS builder ARG BUILD_DEPENDENCIES="build-base \ libffi-dev \ + libpq-dev \ libxml2-dev \ mariadb-connector-c-dev \ openldap-dev \ diff --git a/docs/wiki/README.md b/docs/wiki/README.md index 7c5c794..36fd312 100644 --- a/docs/wiki/README.md +++ b/docs/wiki/README.md @@ -38,7 +38,7 @@ ## Using PowerDNS-Admin -- Setting up a domain +- Setting up a zone - Adding a record - diff --git a/docs/wiki/configuration/Configure-Active-Directory-Authentication-using-Group-Security.md b/docs/wiki/configuration/Configure-Active-Directory-Authentication-using-Group-Security.md index 417bdc3..f6c032f 100644 --- a/docs/wiki/configuration/Configure-Active-Directory-Authentication-using-Group-Security.md +++ b/docs/wiki/configuration/Configure-Active-Directory-Authentication-using-Group-Security.md @@ -11,7 +11,7 @@ Active Directory Setup - Tested with Windows Server 2012 5) Fill in the required info - * LDAP URI - ldap://ip.of.your.domain.controller:389 -* LDAP Base DN - dc=youdomain,dc=com +* LDAP Base DN - dc=yourdomain,dc=com * Active Directory domain - yourdomain.com * Basic filter - (objectCategory=person) * the brackets here are **very important** 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/docs/wiki/configuration/basic_settings.md b/docs/wiki/configuration/basic_settings.md new file mode 100644 index 0000000..6a47d6a --- /dev/null +++ b/docs/wiki/configuration/basic_settings.md @@ -0,0 +1,17 @@ +### PowerDNSAdmin basic settings + +PowerDNSAdmin has many features and settings available to be turned either off or on. +In this docs those settings will be explain. +To find the settings in the the dashboard go to settings>basic. + +allow_user_create_domain: This setting is used to allow users with the `user` role to create a domain, not possible by +default. + +allow_user_remove_domain: Same as `allow_user_create_domain` but for removing a domain. + +allow_user_view_history: Allow a user with the role `user` to view and access the history. + +custom_history_header: This is a string type variable, when inputting an header name, if exists in the request it will +be in the created_by column in the history, if empty or not mentioned will default to the api_key description. + +site_name: This will be the site name. diff --git a/docs/wiki/database-setup/Setup-PostgreSQL.md b/docs/wiki/database-setup/Setup-PostgreSQL.md index a6e3364..197aae5 100644 --- a/docs/wiki/database-setup/Setup-PostgreSQL.md +++ b/docs/wiki/database-setup/Setup-PostgreSQL.md @@ -15,10 +15,9 @@ The below will create a database called powerdnsadmindb and a user of powerdnsad ``` $ sudo su - postgres $ createuser powerdnsadmin -$ createdb powerdnsadmindb +$ createdb -E UTF8 -l en_US.UTF-8 -O powerdnsadmin -T template0 powerdnsadmindb 'The database for PowerDNS-Admin' $ psql -postgres=# alter user powerdnsadmin with encrypted password 'powerdnsadmin'; -postgres=# grant all privileges on database powerdnsadmindb to powerdnsadmin; +postgres=# ALTER ROLE powerdnsadmin WITH PASSWORD 'powerdnsadmin_password'; ``` Note: @@ -51,18 +50,14 @@ On debian based systems these files are located in: ## Install required packages: ### Red-hat based systems: +TODO: confirm this is correct ``` sudo yum install postgresql-libs ``` ### Debian based systems: ``` -apt install libpq-dev python-dev -``` - -### Install python packages: -``` -pip3 install psycopg2 +apt install python3-psycopg2 ``` ## Known Issues: diff --git a/docs/wiki/install/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md b/docs/wiki/install/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md index d3bc834..ad51c2b 100644 --- a/docs/wiki/install/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md +++ b/docs/wiki/install/Running-PowerDNS-Admin-on-Ubuntu-or-Debian.md @@ -7,19 +7,30 @@ First setup your database accordingly: ### Install required packages for building python libraries from requirements.txt file +For Debian 11 (bullseye) and above: ```bash -sudo apt install -y python3-dev git libsasl2-dev libldap2-dev libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config apt-transport-https virtualenv build-essential curl +sudo apt install -y python3-dev git libsasl2-dev libldap2-dev python3-venv libmariadb-dev pkg-config build-essential curl libpq-dev +``` +Older systems might also need the following: +```bash +sudo apt install -y libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev apt-transport-https virtualenv ``` ### Install NodeJs ```bash -curl -sL https://deb.nodesource.com/setup_14.x | bash - -apt install -y nodejs +curl -sL https://deb.nodesource.com/setup_14.x | sudo bash - +sudo apt install -y nodejs ``` ### Install yarn to build asset files - +For Debian 11 (bullseye) and above: +```bash +curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/yarnkey.gpg >/dev/null +echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | sudo tee /etc/apt/sources.list.d/yarn.list +sudo apt update && sudo apt install -y yarn +``` +For older Debian systems: ```bash sudo curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list diff --git a/migrations/versions/3f76448bb6de_add_user_confirmed_column.py b/migrations/versions/3f76448bb6de_add_user_confirmed_column.py index 490aebe..6dcf16a 100644 --- a/migrations/versions/3f76448bb6de_add_user_confirmed_column.py +++ b/migrations/versions/3f76448bb6de_add_user_confirmed_column.py @@ -23,7 +23,7 @@ def upgrade(): with op.batch_alter_table('user') as batch_op: user = sa.sql.table('user', sa.sql.column('confirmed')) batch_op.execute(user.update().values(confirmed=False)) - batch_op.alter_column('confirmed', nullable=False) + batch_op.alter_column('confirmed', nullable=False, existing_type=sa.Boolean(), existing_nullable=True, existing_server_default=False) def downgrade(): 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/decorators.py b/powerdnsadmin/decorators.py index 50e4a3f..cfa5f9d 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -133,50 +133,63 @@ def api_basic_auth(f): @wraps(f) def decorated_function(*args, **kwargs): auth_header = request.headers.get('Authorization') - if auth_header: - auth_header = auth_header.replace('Basic ', '', 1) - try: - auth_header = str(base64.b64decode(auth_header), 'utf-8') - username, password = auth_header.split(":") - except binascii.Error as e: - current_app.logger.error( - 'Invalid base64-encoded of credential. Error {0}'.format( - e)) - abort(401) - except TypeError as e: - current_app.logger.error('Error: {0}'.format(e)) - abort(401) - - user = User(username=username, - password=password, - plain_text_password=password) - - try: - if Setting().get('verify_user_email') and user.email and not user.confirmed: - current_app.logger.warning( - 'Basic authentication failed for user {} because of unverified email address' - .format(username)) - abort(401) - - auth_method = request.args.get('auth_method', 'LOCAL') - auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' - auth = user.is_validate(method=auth_method, - src_ip=request.remote_addr) - - if not auth: - current_app.logger.error('Checking user password failed') - abort(401) - else: - user = User.query.filter(User.username == username).first() - current_user = user # lgtm [py/unused-local-variable] - except Exception as e: - current_app.logger.error('Error: {0}'.format(e)) - abort(401) - else: + if not auth_header: current_app.logger.error('Error: Authorization header missing!') abort(401) + if auth_header[:6] != "Basic ": + current_app.logger.error('Error: Unsupported authorization mechanism!') + abort(401) + + # Remove "Basic " from the header value + auth_header = auth_header[6:] + + try: + auth_header = str(base64.b64decode(auth_header), 'utf-8') + # NK: We use auth_components here as we don't know if we'll have a :, we split it maximum 1 times to grab the + # username, the rest of the string would be the password. + auth_components = auth_header.split(':', maxsplit=1) + except (binascii.Error, UnicodeDecodeError) as e: + current_app.logger.error( + 'Invalid base64-encoded of credential. Error {0}'.format( + e)) + abort(401) + except TypeError as e: + current_app.logger.error('Error: {0}'.format(e)) + abort(401) + + # If we don't have two auth components (username, password), we can abort + if len(auth_components) != 2: + abort(401) + + (username, password) = auth_components + + user = User(username=username, + password=password, + plain_text_password=password) + + try: + if Setting().get('verify_user_email') and user.email and not user.confirmed: + current_app.logger.warning( + 'Basic authentication failed for user {} because of unverified email address' + .format(username)) + abort(401) + + auth_method = request.args.get('auth_method', 'LOCAL') + auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' + auth = user.is_validate(method=auth_method, src_ip=request.remote_addr) + + if not auth: + current_app.logger.error('Checking user password failed') + abort(401) + else: + user = User.query.filter(User.username == username).first() + current_user = user # lgtm [py/unused-local-variable] + except Exception as e: + current_app.logger.error('Error: {0}'.format(e)) + abort(401) + return f(*args, **kwargs) return decorated_function @@ -257,7 +270,7 @@ def api_can_create_domain(f): if current_user.role.name not in [ 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): - msg = "User {0} does not have enough privileges to create domain" + msg = "User {0} does not have enough privileges to create zone" current_app.logger.error(msg.format(current_user.username)) raise NotEnoughPrivileges() @@ -286,7 +299,7 @@ def apikey_can_create_domain(f): if g.apikey.role.name not in [ 'Administrator', 'Operator' ] and not Setting().get('allow_user_create_domain'): - msg = "ApiKey #{0} does not have enough privileges to create domain" + msg = "ApiKey #{0} does not have enough privileges to create zone" current_app.logger.error(msg.format(g.apikey.id)) raise NotEnoughPrivileges() @@ -316,7 +329,7 @@ def apikey_can_remove_domain(http_methods=[]): g.apikey.role.name not in ['Administrator', 'Operator'] and not Setting().get('allow_user_remove_domain') ): - msg = "ApiKey #{0} does not have enough privileges to remove domain" + msg = "ApiKey #{0} does not have enough privileges to remove zone" current_app.logger.error(msg.format(g.apikey.id)) raise NotEnoughPrivileges() return f(*args, **kwargs) @@ -331,7 +344,7 @@ def apikey_is_admin(f): @wraps(f) def decorated_function(*args, **kwargs): if g.apikey.role.name != 'Administrator': - msg = "Apikey {0} does not have enough privileges to create domain" + msg = "Apikey {0} does not have enough privileges to create zone" current_app.logger.error(msg.format(g.apikey.id)) raise NotEnoughPrivileges() return f(*args, **kwargs) @@ -447,10 +460,8 @@ def apikey_auth(f): if auth_header: try: apikey_val = str(base64.b64decode(auth_header), 'utf-8') - except binascii.Error as e: - current_app.logger.error( - 'Invalid base64-encoded of credential. Error {0}'.format( - e)) + except (binascii.Error, UnicodeDecodeError) as e: + current_app.logger.error('Invalid base64-encoded X-API-KEY. Error {0}'.format(e)) abort(401) except TypeError as e: current_app.logger.error('Error: {0}'.format(e)) 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/lib/errors.py b/powerdnsadmin/lib/errors.py index e1e0785..7588724 100644 --- a/powerdnsadmin/lib/errors.py +++ b/powerdnsadmin/lib/errors.py @@ -21,7 +21,7 @@ class StructuredException(Exception): class DomainNotExists(StructuredException): status_code = 404 - def __init__(self, name=None, message="Domain does not exist"): + def __init__(self, name=None, message="Zone does not exist"): StructuredException.__init__(self) self.message = message self.name = name @@ -30,7 +30,7 @@ class DomainNotExists(StructuredException): class DomainAlreadyExists(StructuredException): status_code = 409 - def __init__(self, name=None, message="Domain already exists"): + def __init__(self, name=None, message="Zone already exists"): StructuredException.__init__(self) self.message = message self.name = name @@ -39,7 +39,7 @@ class DomainAlreadyExists(StructuredException): class DomainAccessForbidden(StructuredException): status_code = 403 - def __init__(self, name=None, message="Domain access not allowed"): + def __init__(self, name=None, message="Zone access not allowed"): StructuredException.__init__(self) self.message = message self.name = name @@ -47,7 +47,7 @@ class DomainAccessForbidden(StructuredException): class DomainOverrideForbidden(StructuredException): status_code = 409 - def __init__(self, name=None, message="Domain override of record not allowed"): + def __init__(self, name=None, message="Zone override of record not allowed"): StructuredException.__init__(self) self.message = message self.name = name @@ -67,7 +67,7 @@ class ApiKeyNotUsable(StructuredException): def __init__( self, name=None, - message=("Api key must have domains or accounts" + message=("Api key must have zones or accounts" " or an administrative role")): StructuredException.__init__(self) self.message = message diff --git a/powerdnsadmin/lib/utils.py b/powerdnsadmin/lib/utils.py index 3b666ef..f8cc997 100644 --- a/powerdnsadmin/lib/utils.py +++ b/powerdnsadmin/lib/utils.py @@ -229,7 +229,7 @@ def ensure_list(l): def pretty_domain_name(domain_name): # Add a debugging statement to print out the domain name - print("Received domain name:", domain_name) + print("Received zone name:", domain_name) # Check if the domain name is encoded using Punycode if domain_name.endswith('.xn--'): @@ -238,9 +238,9 @@ def pretty_domain_name(domain_name): domain_name = idna.decode(domain_name) except Exception as e: # If the decoding fails, raise an exception with more information - raise Exception('Cannot decode IDN domain: {}'.format(e)) + raise Exception('Cannot decode IDN zone: {}'.format(e)) - # Return the "pretty" version of the domain name + # Return the "pretty" version of the zone name return domain_name diff --git a/powerdnsadmin/models/domain.py b/powerdnsadmin/models/domain.py index 500ad74..bfa0445 100644 --- a/powerdnsadmin/models/domain.py +++ b/powerdnsadmin/models/domain.py @@ -68,13 +68,13 @@ class Domain(db.Model): return True except Exception as e: current_app.logger.error( - 'Can not create setting {0} for domain {1}. {2}'.format( + 'Can not create setting {0} for zone {1}. {2}'.format( setting, self.name, e)) return False def get_domain_info(self, domain_name): """ - Get all domains which has in PowerDNS + Get all zones which has in PowerDNS """ headers = {'X-API-Key': self.PDNS_API_KEY} jdata = utils.fetch_json(urljoin( @@ -88,7 +88,7 @@ class Domain(db.Model): def get_domains(self): """ - Get all domains which has in PowerDNS + Get all zones which has in PowerDNS """ headers = {'X-API-Key': self.PDNS_API_KEY} jdata = utils.fetch_json( @@ -108,17 +108,17 @@ class Domain(db.Model): return domain.id except Exception as e: current_app.logger.error( - 'Domain does not exist. ERROR: {0}'.format(e)) + 'Zone does not exist. ERROR: {0}'.format(e)) return None def search_idn_domains(self, search_string): """ - Search for IDN domains using the provided search string. + Search for IDN zones using the provided search string. """ - # Compile the regular expression pattern for matching IDN domain names + # Compile the regular expression pattern for matching IDN zone names idn_pattern = re.compile(r'^xn--') - # Search for domain names that match the IDN pattern + # Search for zone names that match the IDN pattern idn_domains = [ domain for domain in self.get_domains() if idn_pattern.match(domain) ] @@ -129,12 +129,12 @@ class Domain(db.Model): def update(self): """ - Fetch zones (domains) from PowerDNS and update into DB + Fetch zones (zones) from PowerDNS and update into DB """ db_domain = Domain.query.all() list_db_domain = [d.name for d in db_domain] dict_db_domain = dict((x.name, x) for x in db_domain) - current_app.logger.info("Found {} domains in PowerDNS-Admin".format( + current_app.logger.info("Found {} zones in PowerDNS-Admin".format( len(list_db_domain))) headers = {'X-API-Key': self.PDNS_API_KEY} try: @@ -149,17 +149,17 @@ class Domain(db.Model): "Found {} zones in PowerDNS server".format(len(list_jdomain))) try: - # domains should remove from db since it doesn't exist in powerdns anymore + # zones should remove from db since it doesn't exist in powerdns anymore should_removed_db_domain = list( set(list_db_domain).difference(list_jdomain)) for domain_name in should_removed_db_domain: self.delete_domain_from_pdnsadmin(domain_name, do_commit=False) except Exception as e: current_app.logger.error( - 'Can not delete domain from DB. DETAIL: {0}'.format(e)) + 'Can not delete zone from DB. DETAIL: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) - # update/add new domain + # update/add new zone account_cache = {} for data in jdata: if 'account' in data: @@ -187,16 +187,16 @@ class Domain(db.Model): self.add_domain_to_powerdns_admin(domain=data, do_commit=False) db.session.commit() - current_app.logger.info('Update domain finished') + current_app.logger.info('Update zone finished') return { 'status': 'ok', - 'msg': 'Domain table has been updated successfully' + 'msg': 'Zone table has been updated successfully' } except Exception as e: db.session.rollback() current_app.logger.error( - 'Cannot update domain table. Error: {0}'.format(e)) - return {'status': 'error', 'msg': 'Cannot update domain table'} + 'Cannot update zone table. Error: {0}'.format(e)) + return {'status': 'error', 'msg': 'Cannot update zone table'} def update_pdns_admin_domain(self, domain, account_id, data, do_commit=True): # existing domain, only update if something actually has changed @@ -218,11 +218,11 @@ class Domain(db.Model): try: if do_commit: db.session.commit() - current_app.logger.info("Updated PDNS-Admin domain {0}".format( + current_app.logger.info("Updated PDNS-Admin zone {0}".format( domain.name)) except Exception as e: db.session.rollback() - current_app.logger.info("Rolled back Domain {0} {1}".format( + current_app.logger.info("Rolled back zone {0} {1}".format( domain.name, e)) raise @@ -234,7 +234,7 @@ class Domain(db.Model): domain_master_ips=[], account_name=None): """ - Add a domain to power dns + Add a zone to power dns """ headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -269,23 +269,23 @@ class Domain(db.Model): if 'error' in jdata.keys(): current_app.logger.error(jdata['error']) if jdata.get('http_code') == 409: - return {'status': 'error', 'msg': 'Domain already exists'} + return {'status': 'error', 'msg': 'Zone already exists'} return {'status': 'error', 'msg': jdata['error']} else: current_app.logger.info( - 'Added domain successfully to PowerDNS: {0}'.format( + 'Added zone successfully to PowerDNS: {0}'.format( domain_name)) self.add_domain_to_powerdns_admin(domain_dict=post_data) - return {'status': 'ok', 'msg': 'Added domain successfully'} + return {'status': 'ok', 'msg': 'Added zone successfully'} except Exception as e: - current_app.logger.error('Cannot add domain {0} {1}'.format( + current_app.logger.error('Cannot add zone {0} {1}'.format( domain_name, e)) current_app.logger.debug(traceback.format_exc()) - return {'status': 'error', 'msg': 'Cannot add this domain.'} + return {'status': 'error', 'msg': 'Cannot add this zone.'} def add_domain_to_powerdns_admin(self, domain=None, domain_dict=None, do_commit=True): """ - Read Domain from PowerDNS and add into PDNS-Admin + Read zone from PowerDNS and add into PDNS-Admin """ headers = {'X-API-Key': self.PDNS_API_KEY} if not domain: @@ -299,7 +299,7 @@ class Domain(db.Model): timeout=int(Setting().get('pdns_api_timeout')), verify=Setting().get('verify_ssl_connections')) except Exception as e: - current_app.logger.error('Can not read domain from PDNS') + current_app.logger.error('Can not read zone from PDNS') current_app.logger.error(e) current_app.logger.debug(traceback.format_exc()) @@ -325,20 +325,20 @@ class Domain(db.Model): if do_commit: db.session.commit() current_app.logger.info( - "Synced PowerDNS Domain to PDNS-Admin: {0}".format(d.name)) + "Synced PowerDNS zone to PDNS-Admin: {0}".format(d.name)) return { 'status': 'ok', - 'msg': 'Added Domain successfully to PowerDNS-Admin' + 'msg': 'Added zone successfully to PowerDNS-Admin' } except Exception as e: db.session.rollback() - current_app.logger.info("Rolled back Domain {0}".format(d.name)) + current_app.logger.info("Rolled back zone {0}".format(d.name)) raise def update_soa_setting(self, domain_name, soa_edit_api): domain = Domain.query.filter(Domain.name == domain_name).first() if not domain: - return {'status': 'error', 'msg': 'Domain does not exist.'} + return {'status': 'error', 'msg': 'Zone does not exist.'} headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -365,7 +365,7 @@ class Domain(db.Model): return {'status': 'error', 'msg': jdata['error']} else: current_app.logger.info( - 'soa-edit-api changed for domain {0} successfully'.format( + 'soa-edit-api changed for zone {0} successfully'.format( domain_name)) return { 'status': 'ok', @@ -375,11 +375,11 @@ class Domain(db.Model): current_app.logger.debug(e) current_app.logger.debug(traceback.format_exc()) current_app.logger.error( - 'Cannot change soa-edit-api for domain {0}'.format( + 'Cannot change soa-edit-api for zone {0}'.format( domain_name)) return { 'status': 'error', - 'msg': 'Cannot change soa-edit-api for this domain.' + 'msg': 'Cannot change soa-edit-api for this zone.' } def update_kind(self, domain_name, kind, masters=[]): @@ -388,7 +388,7 @@ class Domain(db.Model): """ domain = Domain.query.filter(Domain.name == domain_name).first() if not domain: - return {'status': 'error', 'msg': 'Domain does not exist.'} + return {'status': 'error', 'msg': 'Znoe does not exist.'} headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -409,26 +409,26 @@ class Domain(db.Model): return {'status': 'error', 'msg': jdata['error']} else: current_app.logger.info( - 'Update domain kind for {0} successfully'.format( + 'Update zone kind for {0} successfully'.format( domain_name)) return { 'status': 'ok', - 'msg': 'Domain kind changed successfully' + 'msg': 'Zone kind changed successfully' } except Exception as e: current_app.logger.error( - 'Cannot update kind for domain {0}. Error: {1}'.format( + 'Cannot update kind for zone {0}. Error: {1}'.format( domain_name, e)) current_app.logger.debug(traceback.format_exc()) return { 'status': 'error', - 'msg': 'Cannot update kind for this domain.' + 'msg': 'Cannot update kind for this zone.' } def create_reverse_domain(self, domain_name, domain_reverse_name): """ - Check the existing reverse lookup domain, + Check the existing reverse lookup zone, if not exists create a new one automatically """ domain_obj = Domain.query.filter(Domain.name == domain_name).first() @@ -448,7 +448,7 @@ class Domain(db.Model): result = self.add(domain_reverse_name, 'Master', 'DEFAULT', [], []) self.update() if result['status'] == 'ok': - history = History(msg='Add reverse lookup domain {0}'.format( + history = History(msg='Add reverse lookup zone {0}'.format( domain_reverse_name), detail=json.dumps({ 'domain_type': 'Master', @@ -459,7 +459,7 @@ class Domain(db.Model): else: return { 'status': 'error', - 'msg': 'Adding reverse lookup domain failed' + 'msg': 'Adding reverse lookup zone failed' } domain_user_ids = self.get_user() if len(domain_user_ids) > 0: @@ -469,13 +469,13 @@ class Domain(db.Model): 'status': 'ok', 'msg': - 'New reverse lookup domain created with granted privileges' + 'New reverse lookup zone created with granted privileges' } return { 'status': 'ok', - 'msg': 'New reverse lookup domain created without users' + 'msg': 'New reverse lookup zone created without users' } - return {'status': 'ok', 'msg': 'Reverse lookup domain already exists'} + return {'status': 'ok', 'msg': 'Reverse lookup zone already exists'} def get_reverse_domain_name(self, reverse_host_address): c = 1 @@ -504,22 +504,22 @@ class Domain(db.Model): def delete(self, domain_name): """ - Delete a single domain name from powerdns + Delete a single zone name from powerdns """ try: self.delete_domain_from_powerdns(domain_name) self.delete_domain_from_pdnsadmin(domain_name) - return {'status': 'ok', 'msg': 'Delete domain successfully'} + return {'status': 'ok', 'msg': 'Delete zone successfully'} except Exception as e: current_app.logger.error( - 'Cannot delete domain {0}'.format(domain_name)) + 'Cannot delete zone {0}'.format(domain_name)) current_app.logger.error(e) current_app.logger.debug(traceback.format_exc()) - return {'status': 'error', 'msg': 'Cannot delete domain'} + return {'status': 'error', 'msg': 'Cannot delete zone'} def delete_domain_from_powerdns(self, domain_name): """ - Delete a single domain name from powerdns + Delete a single zone name from powerdns """ headers = {'X-API-Key': self.PDNS_API_KEY} @@ -531,12 +531,12 @@ class Domain(db.Model): method='DELETE', verify=Setting().get('verify_ssl_connections')) current_app.logger.info( - 'Deleted domain successfully from PowerDNS: {0}'.format( + 'Deleted zone successfully from PowerDNS: {0}'.format( domain_name)) - return {'status': 'ok', 'msg': 'Delete domain successfully'} + return {'status': 'ok', 'msg': 'Delete zone successfully'} def delete_domain_from_pdnsadmin(self, domain_name, do_commit=True): - # Revoke permission before deleting domain + # Revoke permission before deleting zone domain = Domain.query.filter(Domain.name == domain_name).first() domain_user = DomainUser.query.filter( DomainUser.domain_id == domain.id) @@ -548,7 +548,7 @@ class Domain(db.Model): domain_setting.delete() domain.apikeys[:] = [] - # Remove history for domain + # Remove history for zone if not Setting().get('preserve_history'): domain_history = History.query.filter( History.domain_id == domain.id @@ -556,17 +556,17 @@ class Domain(db.Model): if domain_history: domain_history.delete() - # then remove domain + # then remove zone Domain.query.filter(Domain.name == domain_name).delete() if do_commit: db.session.commit() current_app.logger.info( - "Deleted domain successfully from pdnsADMIN: {}".format( + "Deleted zone successfully from pdnsADMIN: {}".format( domain_name)) def get_user(self): """ - Get users (id) who have access to this domain name + Get users (id) who have access to this zone name """ user_ids = [] query = db.session.query( @@ -596,7 +596,7 @@ class Domain(db.Model): except Exception as e: db.session.rollback() current_app.logger.error( - 'Cannot revoke user privileges on domain {0}. DETAIL: {1}'. + 'Cannot revoke user privileges on zone {0}. DETAIL: {1}'. format(self.name, e)) current_app.logger.debug(print(traceback.format_exc())) @@ -608,7 +608,7 @@ class Domain(db.Model): except Exception as e: db.session.rollback() current_app.logger.error( - 'Cannot grant user privileges to domain {0}. DETAIL: {1}'. + 'Cannot grant user privileges to zone {0}. DETAIL: {1}'. format(self.name, e)) current_app.logger.debug(print(traceback.format_exc())) @@ -625,7 +625,7 @@ class Domain(db.Model): def add_user(self, user): """ - Add a single user to Domain by User + Add a single user to zone by User """ try: du = DomainUser(self.id, user.id) @@ -635,7 +635,7 @@ class Domain(db.Model): except Exception as e: db.session.rollback() current_app.logger.error( - 'Cannot add user privileges on domain {0}. DETAIL: {1}'. + 'Cannot add user privileges on zone {0}. DETAIL: {1}'. format(self.name, e)) return False @@ -667,11 +667,11 @@ class Domain(db.Model): 'There was something wrong, please contact administrator' } else: - return {'status': 'error', 'msg': 'This domain does not exist'} + return {'status': 'error', 'msg': 'This zone does not exist'} def get_domain_dnssec(self, domain_name): """ - Get domain DNSSEC information + Get zone DNSSEC information """ domain = Domain.query.filter(Domain.name == domain_name).first() if domain: @@ -689,13 +689,13 @@ class Domain(db.Model): if 'error' in jdata: return { 'status': 'error', - 'msg': 'DNSSEC is not enabled for this domain' + 'msg': 'DNSSEC is not enabled for this zone' } else: return {'status': 'ok', 'dnssec': jdata} except Exception as e: current_app.logger.error( - 'Cannot get domain dnssec. DETAIL: {0}'.format(e)) + 'Cannot get zone dnssec. DETAIL: {0}'.format(e)) return { 'status': 'error', @@ -703,11 +703,11 @@ class Domain(db.Model): 'There was something wrong, please contact administrator' } else: - return {'status': 'error', 'msg': 'This domain does not exist'} + return {'status': 'error', 'msg': 'This zone does not exist'} def enable_domain_dnssec(self, domain_name): """ - Enable domain DNSSEC + Enable zone DNSSEC """ domain = Domain.query.filter(Domain.name == domain_name).first() if domain: @@ -728,7 +728,7 @@ class Domain(db.Model): return { 'status': 'error', 'msg': - 'API-RECTIFY could not be enabled for this domain', + 'API-RECTIFY could not be enabled for this zone', 'jdata': jdata } @@ -749,7 +749,7 @@ class Domain(db.Model): 'status': 'error', 'msg': - 'Cannot enable DNSSEC for this domain. Error: {0}'. + 'Cannot enable DNSSEC for this zone. Error: {0}'. format(jdata['error']), 'jdata': jdata @@ -769,7 +769,7 @@ class Domain(db.Model): } else: - return {'status': 'error', 'msg': 'This domain does not exist'} + return {'status': 'error', 'msg': 'This zone does not exist'} def delete_dnssec_key(self, domain_name, key_id): """ @@ -794,13 +794,13 @@ class Domain(db.Model): 'status': 'error', 'msg': - 'Cannot disable DNSSEC for this domain. Error: {0}'. + 'Cannot disable DNSSEC for this zone. Error: {0}'. format(jdata['error']), 'jdata': jdata } - # Disable API-RECTIFY for domain, AFTER deactivating DNSSEC + # Disable API-RECTIFY for zone, AFTER deactivating DNSSEC post_data = {"api_rectify": False} jdata = utils.fetch_json( urljoin( @@ -815,7 +815,7 @@ class Domain(db.Model): return { 'status': 'error', 'msg': - 'API-RECTIFY could not be disabled for this domain', + 'API-RECTIFY could not be disabled for this zone', 'jdata': jdata } @@ -834,22 +834,22 @@ class Domain(db.Model): } else: - return {'status': 'error', 'msg': 'This domain does not exist'} + return {'status': 'error', 'msg': 'This zone does not exist'} def assoc_account(self, account_id, update=True): """ - Associate domain with a domain, specified by account id + Associate account with a zone, specified by account id """ domain_name = self.name # Sanity check - domain name if domain_name == "": - return {'status': False, 'msg': 'No domain name specified'} + return {'status': False, 'msg': 'No zone name specified'} # read domain and check that it exists domain = Domain.query.filter(Domain.name == domain_name).first() if not domain: - return {'status': False, 'msg': 'Domain does not exist'} + return {'status': False, 'msg': 'Zone does not exist'} headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} @@ -875,9 +875,9 @@ class Domain(db.Model): else: if update: self.update() - msg_str = 'Account changed for domain {0} successfully' + msg_str = 'Account changed for zone {0} successfully' current_app.logger.info(msg_str.format(domain_name)) - history = History(msg='Update domain {0} associate account {1}'.format(domain.name, 'none' if account_name == '' else account_name), + history = History(msg='Update zone {0} associate account {1}'.format(domain.name, 'none' if account_name == '' else account_name), detail = json.dumps({ 'assoc_account': 'None' if account_name == '' else account_name, 'dissoc_account': 'None' if account_name_old == '' else account_name_old @@ -889,16 +889,16 @@ class Domain(db.Model): except Exception as e: current_app.logger.debug(e) current_app.logger.debug(traceback.format_exc()) - msg_str = 'Cannot change account for domain {0}' + msg_str = 'Cannot change account for zone {0}' current_app.logger.error(msg_str.format(domain_name)) return { 'status': 'error', - 'msg': 'Cannot change account for this domain.' + 'msg': 'Cannot change account for this zone.' } def get_account(self): """ - Get current account associated with this domain + Get current account associated with this zone """ domain = Domain.query.filter(Domain.name == self.name).first() @@ -907,7 +907,7 @@ class Domain(db.Model): def is_valid_access(self, user_id): """ Check if the user is allowed to access this - domain name + zone name """ return db.session.query(Domain) \ .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ @@ -919,8 +919,8 @@ class Domain(db.Model): AccountUser.user_id == user_id )).filter(Domain.id == self.id).first() - # Return None if this domain does not exist as record, - # Return the parent domain that hold the record if exist + # Return None if this zone does not exist as record, + # Return the parent zone that hold the record if exist def is_overriding(self, domain_name): upper_domain_name = '.'.join(domain_name.split('.')[1:]) while upper_domain_name != '': @@ -929,7 +929,7 @@ class Domain(db.Model): if 'rrsets' in upper_domain: for r in upper_domain['rrsets']: if domain_name.rstrip('.') in r['name'].rstrip('.'): - current_app.logger.error('Domain already exists as a record: {} under domain: {}'.format(r['name'].rstrip('.'), upper_domain_name)) + current_app.logger.error('Zone already exists as a record: {} under zone: {}'.format(r['name'].rstrip('.'), upper_domain_name)) return upper_domain_name upper_domain_name = '.'.join(upper_domain_name.split('.')[1:]) return None diff --git a/powerdnsadmin/models/domain_template.py b/powerdnsadmin/models/domain_template.py index 1b3c6ff..70463ac 100644 --- a/powerdnsadmin/models/domain_template.py +++ b/powerdnsadmin/models/domain_template.py @@ -45,11 +45,11 @@ class DomainTemplate(db.Model): return {'status': 'ok', 'msg': 'Template has been created'} except Exception as e: current_app.logger.error( - 'Can not update domain template table. Error: {0}'.format(e)) + 'Can not update zone template table. Error: {0}'.format(e)) db.session.rollback() return { 'status': 'error', - 'msg': 'Can not update domain template table' + 'msg': 'Can not update zone template table' } def delete_template(self): @@ -60,6 +60,6 @@ class DomainTemplate(db.Model): return {'status': 'ok', 'msg': 'Template has been deleted'} except Exception as e: current_app.logger.error( - 'Can not delete domain template. Error: {0}'.format(e)) + 'Can not delete zone template. Error: {0}'.format(e)) db.session.rollback() - return {'status': 'error', 'msg': 'Can not delete domain template'} \ No newline at end of file + return {'status': 'error', 'msg': 'Can not delete zone template'} diff --git a/powerdnsadmin/models/domain_template_record.py b/powerdnsadmin/models/domain_template_record.py index bd86c6e..465b07d 100644 --- a/powerdnsadmin/models/domain_template_record.py +++ b/powerdnsadmin/models/domain_template_record.py @@ -39,9 +39,9 @@ class DomainTemplateRecord(db.Model): db.session.commit() except Exception as e: current_app.logger.error( - 'Can not update domain template table. Error: {0}'.format(e)) + 'Can not update zone template table. Error: {0}'.format(e)) db.session.rollback() return { 'status': 'error', - 'msg': 'Can not update domain template table' + 'msg': 'Can not update zone template table' } diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index ae70a9e..3b239b4 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -46,7 +46,7 @@ class Record(object): def get_rrsets(self, domain): """ - Query domain's rrsets via PDNS API + Query zone's rrsets via PDNS API """ headers = {'X-API-Key': self.PDNS_API_KEY} try: @@ -59,7 +59,7 @@ class Record(object): verify=Setting().get('verify_ssl_connections')) except Exception as e: current_app.logger.error( - "Cannot fetch domain's record data from remote powerdns api. DETAIL: {0}" + "Cannot fetch zone's record data from remote powerdns api. DETAIL: {0}" .format(e)) return [] @@ -77,7 +77,7 @@ class Record(object): def add(self, domain_name, rrset): """ - Add a record to a domain (Used by auto_ptr and DynDNS) + Add a record to a zone (Used by auto_ptr and DynDNS) Args: domain_name(str): The zone name @@ -115,7 +115,7 @@ class Record(object): return {'status': 'ok', 'msg': 'Record was added successfully'} except Exception as e: current_app.logger.error( - "Cannot add record to domain {}. Error: {}".format( + "Cannot add record to zone {}. Error: {}".format( domain_name, e)) current_app.logger.debug("Submitted record rrset: \n{}".format( utils.pretty_json(rrset))) @@ -172,7 +172,7 @@ class Record(object): record['record_name'] = utils.to_idna(record["record_name"], "encode") #TODO: error handling # If the record is an alias (CNAME), we will also make sure that - # the target domain is properly converted to punycode (IDN) + # the target zone is properly converted to punycode (IDN) if record['record_type'] == 'CNAME' or record['record_type'] == 'SOA': record['record_data'] = utils.to_idna(record['record_data'], 'encode') #TODO: error handling @@ -343,7 +343,7 @@ class Record(object): def apply(self, domain_name, submitted_records): """ - Apply record changes to a domain. This function + Apply record changes to a zone. This function will make 1 call to the PDNS API to DELETE and REPLACE records (rrsets) """ @@ -377,7 +377,7 @@ class Record(object): return {'status': 'ok', 'msg': 'Record was applied successfully', 'data': (new_rrsets, del_rrsets)} except Exception as e: current_app.logger.error( - "Cannot apply record changes to domain {0}. Error: {1}".format( + "Cannot apply record changes to zone {0}. Error: {1}".format( domain_name, e)) current_app.logger.debug(traceback.format_exc()) return { @@ -480,7 +480,7 @@ class Record(object): } except Exception as e: current_app.logger.error( - "Cannot update auto-ptr record changes to domain {0}. Error: {1}" + "Cannot update auto-ptr record changes to zone {0}. Error: {1}" .format(domain_name, e)) current_app.logger.debug(traceback.format_exc()) return { @@ -492,7 +492,7 @@ class Record(object): def delete(self, domain): """ - Delete a record from domain + Delete a record from zone """ headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} data = { @@ -517,7 +517,7 @@ class Record(object): return {'status': 'ok', 'msg': 'Record was removed successfully'} except Exception as e: current_app.logger.error( - "Cannot remove record {0}/{1}/{2} from domain {3}. DETAIL: {4}" + "Cannot remove record {0}/{1}/{2} from zone {3}. DETAIL: {4}" .format(self.name, self.type, self.data, domain, e)) return { 'status': 'error', @@ -540,7 +540,7 @@ class Record(object): def exists(self, domain): """ - Check if record is present within domain records, and if it's present set self to found record + Check if record is present within zone records, and if it's present set self to found record """ rrsets = self.get_rrsets(domain) for r in rrsets: @@ -588,7 +588,7 @@ class Record(object): return {'status': 'ok', 'msg': 'Record was updated successfully'} except Exception as e: current_app.logger.error( - "Cannot add record {0}/{1}/{2} to domain {3}. DETAIL: {4}". + "Cannot add record {0}/{1}/{2} to zone {3}. DETAIL: {4}". format(self.name, self.type, self.data, domain, e)) return { 'status': 'error', @@ -614,11 +614,11 @@ class Record(object): db.session.commit() return { 'status': True, - 'msg': 'Synced local serial for domain name {0}'.format(domain) + 'msg': 'Synced local serial for zone name {0}'.format(domain) } else: return { 'status': False, 'msg': - 'Could not find domain name {0} in local db'.format(domain) + 'Could not find zone name {0} in local db'.format(domain) } diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index e820af9..06e17d2 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -13,8 +13,132 @@ class Setting(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), unique=True, index=True) value = db.Column(db.Text()) - + + types = { + 'maintenance': bool, + 'fullscreen_layout': bool, + 'record_helper': bool, + 'login_ldap_first': bool, + 'default_record_table_size': int, + 'default_domain_table_size': int, + 'auto_ptr': bool, + 'record_quick_edit': bool, + 'pretty_ipv6_ptr': bool, + 'dnssec_admins_only': bool, + 'allow_user_create_domain': bool, + 'allow_user_remove_domain': bool, + 'allow_user_view_history': bool, + 'custom_history_header': str, + 'delete_sso_accounts': bool, + 'bg_domain_updates': bool, + 'enable_api_rr_history': bool, + 'preserve_history': bool, + 'site_name': str, + 'site_url': str, + 'session_timeout': int, + 'warn_session_timeout': bool, + 'pdns_api_url': str, + 'pdns_api_key': str, + 'pdns_api_timeout': int, + 'pdns_version': str, + 'verify_ssl_connections': bool, + 'verify_user_email': bool, + 'enforce_api_ttl': bool, + 'ttl_options': str, + 'otp_field_enabled': bool, + 'custom_css': str, + 'otp_force': bool, + 'max_history_records': int, + 'deny_domain_override': bool, + 'account_name_extra_chars': bool, + 'gravatar_enabled': bool, + 'forward_records_allow_edit': dict, + 'reverse_records_allow_edit': dict, + 'local_db_enabled': bool, + 'signup_enabled': bool, + 'pwd_enforce_characters': bool, + 'pwd_min_len': int, + 'pwd_min_lowercase': int, + 'pwd_min_uppercase': int, + 'pwd_min_digits': int, + 'pwd_min_special': int, + 'pwd_enforce_complexity': bool, + 'pwd_min_complexity': int, + 'ldap_enabled': bool, + 'ldap_type': str, + 'ldap_uri': str, + 'ldap_base_dn': str, + 'ldap_admin_username': str, + 'ldap_admin_password': str, + 'ldap_domain': str, + 'ldap_filter_basic': str, + 'ldap_filter_username': str, + 'ldap_filter_group': str, + 'ldap_filter_groupname': str, + 'ldap_sg_enabled': bool, + 'ldap_admin_group': str, + 'ldap_operator_group': str, + 'ldap_user_group': str, + 'autoprovisioning': bool, + 'autoprovisioning_attribute': str, + 'urn_value': str, + 'purge': bool, + 'google_oauth_enabled': bool, + 'google_oauth_client_id': str, + 'google_oauth_client_secret': str, + 'google_oauth_scope': str, + 'google_base_url': str, + 'google_oauth_auto_configure': bool, + 'google_oauth_metadata_url': str, + 'google_token_url': str, + 'google_authorize_url': str, + 'github_oauth_enabled': bool, + 'github_oauth_key': str, + 'github_oauth_secret': str, + 'github_oauth_scope': str, + 'github_oauth_api_url': str, + 'github_oauth_auto_configure': bool, + 'github_oauth_metadata_url': str, + 'github_oauth_token_url': str, + 'github_oauth_authorize_url': str, + 'azure_oauth_enabled': bool, + 'azure_oauth_key': str, + 'azure_oauth_secret': str, + 'azure_oauth_scope': str, + 'azure_oauth_api_url': str, + 'azure_oauth_auto_configure': bool, + 'azure_oauth_metadata_url': str, + 'azure_oauth_token_url': str, + 'azure_oauth_authorize_url': str, + 'azure_sg_enabled': bool, + 'azure_admin_group': str, + 'azure_operator_group': str, + 'azure_user_group': str, + 'azure_group_accounts_enabled': bool, + 'azure_group_accounts_name': str, + 'azure_group_accounts_name_re': str, + 'azure_group_accounts_description': str, + 'azure_group_accounts_description_re': str, + 'oidc_oauth_enabled': bool, + 'oidc_oauth_key': str, + 'oidc_oauth_secret': str, + 'oidc_oauth_scope': str, + 'oidc_oauth_api_url': str, + 'oidc_oauth_auto_configure': bool, + 'oidc_oauth_metadata_url': str, + 'oidc_oauth_token_url': str, + 'oidc_oauth_authorize_url': str, + 'oidc_oauth_logout_url': str, + 'oidc_oauth_username': str, + 'oidc_oauth_email': str, + 'oidc_oauth_firstname': str, + 'oidc_oauth_last_name': str, + 'oidc_oauth_account_name_property': str, + 'oidc_oauth_account_description_property': str, + } + defaults = { + # General Settings 'maintenance': False, 'fullscreen_layout': True, 'record_helper': True, @@ -28,7 +152,8 @@ class Setting(db.Model): 'allow_user_create_domain': False, 'allow_user_remove_domain': False, 'allow_user_view_history': False, - 'delete_sso_accounts': False, + 'custom_history_header': '', + 'delete_sso_accounts': False, 'bg_domain_updates': False, 'enable_api_rr_history': True, 'preserve_history': False, @@ -41,53 +166,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', - '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_base_url': 'https://www.googleapis.com/oauth2/v3/', + 'github_oauth_auto_configure': False, + 'github_oauth_metadata_url': '', + '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': '', @@ -97,22 +251,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, @@ -189,14 +347,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, + } + + 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): @@ -210,6 +457,34 @@ class Setting(db.Model): self.name = name self.value = value + def convert_type(self, name, value): + import json + if name in self.types: + var_type = self.types[name] + + # Handle boolean values + if var_type == bool: + if value == 'True' or value == 'true' or value == '1' or value == True: + return True + else: + return False + + # Handle float values + if var_type == float: + return float(value) + + # Handle integer values + if var_type == int: + return int(value) + + if var_type == dict or var_type == list: + return json.loads(value) + + if var_type == str: + return str(value) + + return value + def set_maintenance(self, mode): maintenance = Setting.query.filter( Setting.name == 'maintenance').first() @@ -262,7 +537,7 @@ class Setting(db.Model): current_setting = Setting(name=setting, value=None) db.session.add(current_setting) - value = str(value) + value = str(self.convert_type(setting, value)) try: current_setting.value = value @@ -284,16 +559,28 @@ class Setting(db.Model): result = self.query.filter(Setting.name == setting).first() if result is not None: - if hasattr(result,'value'): + if hasattr(result, 'value'): result = result.value - return strtobool(result) if result in [ - 'True', 'False' - ] else result + + return self.convert_type(setting, result) else: return self.defaults[setting] else: current_app.logger.error('Unknown setting queried: {0}'.format(setting)) + def get_group(self, group): + if not isinstance(group, list): + group = self.groups[group] + + result = {} + records = self.query.all() + + for record in records: + if record.name in group: + result[record.name] = self.convert_type(record.name, record.value) + + return result + def get_records_allow_to_edit(self): return list( set(self.get_forward_records_allow_to_edit() + diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index 78104f6..e989aa0 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -5,6 +5,7 @@ import bcrypt import pyotp import ldap import ldap.filter +from collections import OrderedDict from flask import current_app from flask_login import AnonymousUserMixin from sqlalchemy import orm @@ -90,8 +91,8 @@ class User(db.Model): return ''.format(self.username) def get_totp_uri(self): - return "otpauth://totp/PowerDNS-Admin:{0}?secret={1}&issuer=PowerDNS-Admin".format( - self.username, self.otp_secret) + return "otpauth://totp/{0}:{1}?secret={2}&issuer=PowerDNS-Admin".format( + Setting().get('site_name'), self.username, self.otp_secret) def verify_totp(self, token): totp = pyotp.TOTP(self.otp_secret) @@ -254,82 +255,82 @@ class User(db.Model): if LDAP_TYPE == 'ldap': groupSearchFilter = "(&({0}={1}){2})".format(LDAP_FILTER_GROUPNAME, ldap_username, LDAP_FILTER_GROUP) current_app.logger.debug('Ldap groupSearchFilter {0}'.format(groupSearchFilter)) - if (self.ldap_search(groupSearchFilter, - LDAP_ADMIN_GROUP)): + if (LDAP_ADMIN_GROUP and self.ldap_search(groupSearchFilter, LDAP_ADMIN_GROUP)): role_name = 'Administrator' current_app.logger.info( 'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin' - .format(self.username, - LDAP_ADMIN_GROUP)) - elif (self.ldap_search(groupSearchFilter, - LDAP_OPERATOR_GROUP)): + .format(self.username, LDAP_ADMIN_GROUP)) + elif (LDAP_OPERATOR_GROUP and self.ldap_search(groupSearchFilter, LDAP_OPERATOR_GROUP)): role_name = 'Operator' current_app.logger.info( 'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin' - .format(self.username, - LDAP_OPERATOR_GROUP)) - elif (self.ldap_search(groupSearchFilter, - LDAP_USER_GROUP)): + .format(self.username, LDAP_OPERATOR_GROUP)) + elif (LDAP_USER_GROUP and self.ldap_search(groupSearchFilter, LDAP_USER_GROUP)): current_app.logger.info( 'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin' - .format(self.username, - LDAP_USER_GROUP)) + .format(self.username, LDAP_USER_GROUP)) else: current_app.logger.error( - 'User {0} is not part of the "{1}", "{2}" or "{3}" groups that allow access to PowerDNS-Admin' - .format(self.username, - LDAP_ADMIN_GROUP, - LDAP_OPERATOR_GROUP, - LDAP_USER_GROUP)) - return False - elif LDAP_TYPE == 'ad': - ldap_admin_group_filter, ldap_operator_group, ldap_user_group = "", "", "" - if LDAP_ADMIN_GROUP: - ldap_admin_group_filter = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_ADMIN_GROUP) - if LDAP_OPERATOR_GROUP: - ldap_operator_group = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_OPERATOR_GROUP) - if LDAP_USER_GROUP: - ldap_user_group = "(memberOf:1.2.840.113556.1.4.1941:={0})".format(LDAP_USER_GROUP) - searchFilter = "(&({0}={1})(|{2}{3}{4}))".format(LDAP_FILTER_USERNAME, self.username, - ldap_admin_group_filter, - ldap_operator_group, ldap_user_group) - ldap_result = self.ldap_search(searchFilter, LDAP_BASE_DN) - user_ad_member_of = ldap_result[0][0][1].get( - 'memberOf') - - if not user_ad_member_of: - current_app.logger.error( - 'User {0} does not belong to any group while LDAP_GROUP_SECURITY_ENABLED is ON' + 'User {0} is not part of any security groups that allow access to PowerDNS-Admin' .format(self.username)) return False + elif LDAP_TYPE == 'ad': + ldap_group_security_roles = OrderedDict( + Administrator=LDAP_ADMIN_GROUP, + Operator=LDAP_OPERATOR_GROUP, + User=LDAP_USER_GROUP, + ) + user_dn = ldap_result[0][0][0] + sf_groups = "" - user_ad_member_of = [g.decode("utf-8") for g in user_ad_member_of] + for group in ldap_group_security_roles.values(): + if not group: + continue - if (LDAP_ADMIN_GROUP in user_ad_member_of): - role_name = 'Administrator' - current_app.logger.info( - 'User {0} is part of the "{1}" group that allows admin access to PowerDNS-Admin' - .format(self.username, - LDAP_ADMIN_GROUP)) - elif (LDAP_OPERATOR_GROUP in user_ad_member_of): - role_name = 'Operator' - current_app.logger.info( - 'User {0} is part of the "{1}" group that allows operator access to PowerDNS-Admin' - .format(self.username, - LDAP_OPERATOR_GROUP)) - elif (LDAP_USER_GROUP in user_ad_member_of): - current_app.logger.info( - 'User {0} is part of the "{1}" group that allows user access to PowerDNS-Admin' - .format(self.username, - LDAP_USER_GROUP)) - else: + sf_groups += f"(distinguishedName={group})" + + sf_member_user = f"(member:1.2.840.113556.1.4.1941:={user_dn})" + search_filter = f"(&(|{sf_groups}){sf_member_user})" + current_app.logger.debug(f"LDAP groupSearchFilter '{search_filter}'") + + ldap_user_groups = [ + group[0][0] + for group in self.ldap_search( + search_filter, + LDAP_BASE_DN + ) + ] + + if not ldap_user_groups: current_app.logger.error( - 'User {0} is not part of the "{1}", "{2}" or "{3}" groups that allow access to PowerDNS-Admin' - .format(self.username, - LDAP_ADMIN_GROUP, - LDAP_OPERATOR_GROUP, - LDAP_USER_GROUP)) + f"User '{self.username}' " + "does not belong to any group " + "while LDAP_GROUP_SECURITY_ENABLED is ON" + ) return False + + current_app.logger.debug( + "LDAP User security groups " + f"for user '{self.username}': " + " ".join(ldap_user_groups) + ) + + for role, ldap_group in ldap_group_security_roles.items(): + # Continue when groups is not defined or + # user is'nt member of LDAP group + if not ldap_group or not ldap_group in ldap_user_groups: + continue + + role_name = role + current_app.logger.info( + f"User '{self.username}' member of " + f"the '{ldap_group}' group that allows " + f"'{role}' access to to PowerDNS-Admin" + ) + + # Stop loop on first found + break + else: current_app.logger.error('Invalid LDAP type') return False @@ -527,7 +528,7 @@ class User(db.Model): def get_domains(self): """ - Get list of domains which the user is granted to have + Get list of zones which the user is granted to have access. Note: This doesn't include the permission granting from Account @@ -680,7 +681,7 @@ class User(db.Model): def addMissingDomain(self, autoprovision_domain, current_domains): """ - Add domain gathered by autoprovisioning to the current domains list of a user + Add domain gathered by autoprovisioning to the current zones list of a user """ from ..models.domain import Domain user = db.session.query(User).filter(User.username == self.username).first() diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index 0d85d29..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 @@ -838,10 +838,10 @@ class DetailedHistory(): detail_dict = json.loads(history.detail) - if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation + if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a zone creation self.detailed_msg = render_template_string(""" - +
Domain Type:{{ domaintype }}
Zone Type:{{ domaintype }}
Account:{{ account }}
""", @@ -881,7 +881,7 @@ class DetailedHistory(): authenticator=detail_dict['authenticator'], ip_address=detail_dict['ip_address']) - elif 'add_rrsets' in detail_dict: # this is a domain record change + elif 'add_rrsets' in detail_dict: # this is a zone record change # changes_set = [] self.detailed_msg = "" # extract_changelogs_from_a_history_entry(changes_set, history, 0) @@ -897,11 +897,12 @@ class DetailedHistory(): description=DetailedHistory.get_key_val(detail_dict, "description")) - elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain + 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(""" - +
Users with access to this domain{{ users_with_access }}
Users with access to this zone{{ users_with_access }}
Number of users:{{ users_with_access | length }}
""", @@ -913,7 +914,7 @@ class DetailedHistory(): Key: {{ keyname }} Role:{{ rolename }} Description:{{ description }} - Accessible domains with this API key:{{ linked_domains }} + Accessible zones with this API key:{{ linked_domains }} Accessible accounts with this API key:{{ linked_accounts }} """, @@ -932,7 +933,7 @@ class DetailedHistory(): Key: {{ keyname }} Role:{{ rolename }} Description:{{ description }} - Accessible domains with this API key:{{ linked_domains }} + Accessible zones with this API key:{{ linked_domains }} """, keyname=DetailedHistory.get_key_val(detail_dict, "key"), @@ -942,11 +943,11 @@ class DetailedHistory(): linked_domains=DetailedHistory.get_key_val(detail_dict, "domains")) - elif 'Update type for domain' in history.msg: + elif any(msg in history.msg for msg in ['Update type for zone', 'Update type for domain']): self.detailed_msg = render_template_string(""" - - + +
Domain: {{ domain }}
Domain type:{{ domain_type }}
Zone: {{ domain }}
Zone type:{{ domain_type }}
Masters:{{ masters }}
""", @@ -957,8 +958,8 @@ class DetailedHistory(): elif 'reverse' in history.msg: self.detailed_msg = render_template_string(""" - - + +
Domain Type: {{ domain_type }}
Domain Master IPs:{{ domain_master_ips }}
Zone Type: {{ domain_type }}
Zone Master IPs:{{ domain_master_ips }}
""", domain_type=DetailedHistory.get_key_val(detail_dict, @@ -977,7 +978,8 @@ class DetailedHistory(): 'status'), history_msg=DetailedHistory.get_key_val(detail_dict, 'msg')) - elif 'Update domain' in history.msg and 'associate account' in history.msg: # When an account gets associated or dissociate with domains + 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(''' @@ -1164,7 +1166,7 @@ def history_table(): # ajax call data else: # if the user isn't an administrator or operator, # allow_user_view_history must be enabled to get here, - # so include history for the domains for the user + # so include history for the zones for the user base_query = db.session.query(History) \ .join(Domain, History.domain_id == Domain.id) \ .outerjoin(DomainUser, Domain.id == DomainUser.domain_id) \ @@ -1233,9 +1235,14 @@ def history_table(): # ajax call data 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 + " access control") if domain_name != "*" else History.msg.like( - "%domain%access control") + "%domain%access control"), + History.msg.like( + "%zone " + domain_name + " access control") if domain_name != "*" else History.msg.like( + "%zone%access control") ), History.created_on <= max_date if max_date != None else True, History.created_on >= min_date if min_date != None else True, @@ -1247,8 +1254,12 @@ def history_table(): # ajax call data histories = base_query \ .filter( db.and_( - History.msg.like("Apply record changes to domain " + domain_name) if domain_name != "*" \ - else History.msg.like("Apply record changes to domain%"), + db.or_( + History.msg.like("Apply record changes to domain " + domain_name) if domain_name != "*" \ + else History.msg.like("Apply record changes to domain%"), + History.msg.like("Apply record changes to zone " + domain_name) if domain_name != "*" \ + else History.msg.like("Apply record changes to zone%"), + ), History.created_on <= max_date if max_date != None else True, History.created_on >= min_date if min_date != None else True, History.created_by == changed_by if changed_by != None else True @@ -1391,6 +1402,7 @@ def setting_basic(): 'default_domain_table_size', 'default_record_table_size', 'delete_sso_accounts', + 'custom_history_header', 'deny_domain_override', 'dnssec_admins_only', 'enable_api_rr_history', @@ -1532,253 +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 - 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) - result = {'status': True, 'msg': 'Saved successfully'} - elif conf_type == 'ldap': - ldap_enabled = True if request.form.get('ldap_enabled') else False +@admin_bp.route('/setting/authentication/api', methods=['POST']) +@login_required +@admin_role_required +def setting_authentication_api(): + result = {'status': 1, 'messages': [], 'data': {}} - 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('commit') == '1': + model = Setting() + data = json.loads(request.form.get('data')) - 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')) + for key, value in data.items(): + if key in model.groups['authentication']: + model.set(key, value) - Setting().set('purge', True - if request.form.get('purge') == 'ON' else False) + result['data'] = Setting().get_group('authentication') - 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_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_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_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_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_metadata_url', - request.form.get('oidc_oauth_metadata_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']) @@ -1815,7 +1608,7 @@ def create_template(): t = DomainTemplate(name=name, description=description) result = t.create() if result['status'] == 'ok': - history = History(msg='Add domain template {0}'.format(name), + history = History(msg='Add zone template {0}'.format(name), detail=json.dumps({ 'name': name, 'description': description @@ -1828,7 +1621,7 @@ def create_template(): return redirect(url_for('admin.create_template')) except Exception as e: current_app.logger.error( - 'Cannot create domain template. Error: {0}'.format(e)) + 'Cannot create zone template. Error: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) abort(500) @@ -1862,7 +1655,7 @@ def create_template_from_zone(): t = DomainTemplate(name=name, description=description) result = t.create() if result['status'] == 'ok': - history = History(msg='Add domain template {0}'.format(name), + history = History(msg='Add zone template {0}'.format(name), detail=json.dumps({ 'name': name, 'description': description @@ -1870,7 +1663,7 @@ def create_template_from_zone(): created_by=current_user.username) history.add() - # After creating the domain in Domain Template in the, + # After creating the zone in Zone Template in the, # local DB. We add records into it Record Template. records = [] domain = Domain.query.filter(Domain.name == domain_name).first() @@ -1899,7 +1692,7 @@ def create_template_from_zone(): 'msg': result['msg'] }), 200) else: - # Revert the domain template (remove it) + # Revert the zone template (remove it) # ff we cannot add records. t.delete_template() return make_response( @@ -1956,7 +1749,7 @@ def edit_template(template): ttl_options=ttl_options) except Exception as e: current_app.logger.error( - 'Cannot open domain template page. DETAIL: {0}'.format(e)) + 'Cannot open zone template page. DETAIL: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) abort(500) return redirect(url_for('admin.templates')) @@ -1994,7 +1787,7 @@ def apply_records(template): jdata.pop('_csrf_token', None) # don't store csrf token in the history. history = History( - msg='Apply domain template record changes to domain template {0}' + msg='Apply zone template record changes to zone template {0}' .format(template), detail=json.dumps(jdata), created_by=current_user.username) @@ -2025,7 +1818,7 @@ def delete_template(template): result = t.delete_template() if result['status'] == 'ok': history = History( - msg='Deleted domain template {0}'.format(template), + msg='Deleted zone template {0}'.format(template), detail=json.dumps({'name': template}), created_by=current_user.username) history.add() @@ -2055,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] @@ -2129,3 +1922,10 @@ def validateURN(value): return False return True + + +def safe_cast(val, to_type, default=None): + try: + return to_type(val) + except (ValueError, TypeError): + return default diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index 2c9a2cc..9f72b4e 100644 --- a/powerdnsadmin/routes/api.py +++ b/powerdnsadmin/routes/api.py @@ -48,6 +48,12 @@ user_detailed_schema = UserDetailedSchema() account_schema = AccountSchema(many=True) account_single_schema = AccountSchema() +def is_custom_header_api(): + custom_header_setting = Setting().get('custom_history_header') + if custom_header_setting != '' and custom_header_setting in request.headers: + return request.headers[custom_header_setting] + else: + return g.apikey.description def get_user_domains(): domains = db.session.query(Domain) \ @@ -205,7 +211,7 @@ def api_login_create_zone(): accept='application/json; q=1', verify=Setting().get('verify_ssl_connections')) except Exception as e: - current_app.logger.error("Cannot create domain. Error: {}".format(e)) + current_app.logger.error("Cannot create zone. Error: {}".format(e)) abort(500) if resp.status_code == 201: @@ -216,7 +222,7 @@ def api_login_create_zone(): domain.update() domain_id = domain.get_id_by_name(data['name'].rstrip('.')) - history = History(msg='Add domain {0}'.format( + history = History(msg='Add zone {0}'.format( data['name'].rstrip('.')), detail=json.dumps(data), created_by=current_user.username, @@ -224,7 +230,7 @@ def api_login_create_zone(): history.add() if current_user.role.name not in ['Administrator', 'Operator']: - current_app.logger.debug("User is ordinary user, assigning created domain") + current_app.logger.debug("User is ordinary user, assigning created zone") domain = Domain(name=data['name'].rstrip('.')) domain.update() domain.grant_privileges([current_user.id]) @@ -290,7 +296,7 @@ def api_login_delete_zone(domain_name): domain_id = domain.get_id_by_name(domain_name) domain.update() - history = History(msg='Delete domain {0}'.format( + history = History(msg='Delete zone {0}'.format( utils.pretty_domain_name(domain_name)), detail='', created_by=current_user.username, @@ -341,13 +347,13 @@ def api_generate_apikey(): abort(400) if role_name == 'User' and len(domains) == 0 and len(accounts) == 0: - current_app.logger.error("Apikey with User role must have domains or accounts") + current_app.logger.error("Apikey with User role must have zones or accounts") raise ApiKeyNotUsable() if role_name == 'User' and len(domains) > 0: domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all() if len(domain_obj_list) == 0: - msg = "One of supplied domains does not exist" + msg = "One of supplied zones does not exist" current_app.logger.error(msg) raise DomainNotExists(message=msg) @@ -377,13 +383,13 @@ def api_generate_apikey(): domain_list = [item.name for item in domain_obj_list] user_domain_list = [item.name for item in user_domain_obj_list] - current_app.logger.debug("Input domain list: {0}".format(domain_list)) - current_app.logger.debug("User domain list: {0}".format(user_domain_list)) + current_app.logger.debug("Input zone list: {0}".format(domain_list)) + current_app.logger.debug("User zone list: {0}".format(user_domain_list)) inter = set(domain_list).intersection(set(user_domain_list)) if not (len(inter) == len(domain_list)): - msg = "You don't have access to one of domains" + msg = "You don't have access to one of zones" current_app.logger.error(msg) raise DomainAccessForbidden(message=msg) @@ -411,7 +417,7 @@ def api_get_apikeys(domain_name): if current_user.role.name not in ['Administrator', 'Operator']: if domain_name: - msg = "Check if domain {0} exists and is allowed for user.".format( + msg = "Check if zone {0} exists and is allowed for user.".format( domain_name) current_app.logger.debug(msg) apikeys = get_user_apikeys(domain_name) @@ -421,7 +427,7 @@ def api_get_apikeys(domain_name): current_app.logger.debug(apikey_schema.dump(apikeys)) else: - msg_str = "Getting all allowed domains for user {0}" + msg_str = "Getting all allowed zones for user {0}" msg = msg_str.format(current_user.username) current_app.logger.debug(msg) @@ -432,7 +438,7 @@ def api_get_apikeys(domain_name): current_app.logger.error('Error: {0}'.format(e)) abort(500) else: - current_app.logger.debug("Getting all domains for administrative user") + current_app.logger.debug("Getting all zones for administrative user") try: apikeys = ApiKey.query.all() current_app.logger.debug(apikey_schema.dump(apikeys)) @@ -482,7 +488,7 @@ def api_delete_apikey(apikey_id): inter = set(apikey_domains_list).intersection(set(user_domains_list)) if not (len(inter) == len(apikey_domains_list)): - msg = "You don't have access to some domains apikey belongs to" + msg = "You don't have access to some zones apikey belongs to" current_app.logger.error(msg) raise DomainAccessForbidden(message=msg) @@ -552,7 +558,7 @@ def api_update_apikey(apikey_id): if domains is not None: domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all() if len(domain_obj_list) != len(domains): - msg = "One of supplied domains does not exist" + msg = "One of supplied zones does not exist" current_app.logger.error(msg) raise DomainNotExists(message=msg) @@ -572,12 +578,12 @@ def api_update_apikey(apikey_id): target_accounts = current_accounts if len(target_domains) == 0 and len(target_accounts) == 0: - current_app.logger.error("Apikey with User role must have domains or accounts") + current_app.logger.error("Apikey with User role must have zones or accounts") raise ApiKeyNotUsable() if domains is not None and set(domains) == set(current_domains): current_app.logger.debug( - "Domains are the same, apikey domains won't be updated") + "Zones are the same, apikey zones won't be updated") domains = None if accounts is not None and set(accounts) == set(current_accounts): @@ -604,19 +610,19 @@ def api_update_apikey(apikey_id): domain_list = [item.name for item in domain_obj_list] user_domain_list = [item.name for item in user_domain_obj_list] - current_app.logger.debug("Input domain list: {0}".format(domain_list)) + current_app.logger.debug("Input zone list: {0}".format(domain_list)) current_app.logger.debug( - "User domain list: {0}".format(user_domain_list)) + "User zone list: {0}".format(user_domain_list)) inter = set(domain_list).intersection(set(user_domain_list)) if not (len(inter) == len(domain_list)): - msg = "You don't have access to one of domains" + msg = "You don't have access to one of zones" current_app.logger.error(msg) raise DomainAccessForbidden(message=msg) if apikey_id not in apikeys_ids: - msg = 'Apikey does not belong to domain to which user has access' + msg = 'Apikey does not belong to zone to which user has access' current_app.logger.error(msg) raise DomainAccessForbidden() @@ -960,9 +966,9 @@ def api_delete_account(account_id): # Remove account association from domains first if len(account.domains) > 0: for domain in account.domains: - current_app.logger.info(f"Disassociating domain {domain.name} with {account.name}") + current_app.logger.info(f"Disassociating zone {domain.name} with {account.name}") Domain(name=domain.name).assoc_account(None, update=False) - current_app.logger.info("Syncing all domains") + current_app.logger.info("Syncing all zones") Domain().update() current_app.logger.debug( @@ -1104,31 +1110,32 @@ def api_zone_forward(server_id, zone_id): domain = Domain() domain.update() status = resp.status_code + created_by_value=is_custom_header_api() if 200 <= status < 300: current_app.logger.debug("Request to powerdns API successful") if Setting().get('enable_api_rr_history'): if request.method in ['POST', 'PATCH']: data = request.get_json(force=True) history = History( - msg='Apply record changes to domain {0}'.format(zone_id.rstrip('.')), + msg='Apply record changes to zone {0}'.format(zone_id.rstrip('.')), detail = json.dumps({ 'domain': zone_id.rstrip('.'), 'add_rrsets': list(filter(lambda r: r['changetype'] == "REPLACE", data['rrsets'])), 'del_rrsets': list(filter(lambda r: r['changetype'] == "DELETE", data['rrsets'])) }), - created_by=g.apikey.description, + created_by=created_by_value, domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) history.add() elif request.method == 'DELETE': history = History(msg='Deleted zone {0}'.format(zone_id.rstrip('.')), detail='', - created_by=g.apikey.description, + created_by=created_by_value, domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) history.add() elif request.method != 'GET': history = History(msg='Updated zone {0}'.format(zone_id.rstrip('.')), detail='', - created_by=g.apikey.description, + created_by=created_by_value, domain_id=Domain().get_id_by_name(zone_id.rstrip('.'))) history.add() return resp.content, resp.status_code, resp.headers.items() @@ -1152,21 +1159,22 @@ def api_create_zone(server_id): if resp.status_code == 201: current_app.logger.debug("Request to powerdns API successful") + created_by_value=is_custom_header_api() data = request.get_json(force=True) if g.apikey.role.name not in ['Administrator', 'Operator']: current_app.logger.debug( - "Apikey is user key, assigning created domain") + "Apikey is user key, assigning created zone") domain = Domain(name=data['name'].rstrip('.')) g.apikey.domains.append(domain) domain = Domain() domain.update() - history = History(msg='Add domain {0}'.format( + history = History(msg='Add zone {0}'.format( data['name'].rstrip('.')), detail=json.dumps(data), - created_by=g.apikey.description, + created_by=created_by_value, domain_id=domain.get_id_by_name(data['name'].rstrip('.'))) history.add() @@ -1190,7 +1198,7 @@ def api_get_zones(server_id): accounts_domains = [d.name for a in g.apikey.accounts for d in a.domains] allowed_domains = set(domain_list + accounts_domains) - current_app.logger.debug("Account domains: {}".format('/'.join(accounts_domains))) + current_app.logger.debug("Account zones: {}".format('/'.join(accounts_domains))) content = json.dumps([i for i in json.loads(resp.content) if i['name'].rstrip('.') in allowed_domains]) return content, resp.status_code, resp.headers.items() @@ -1228,14 +1236,14 @@ def health(): domain_to_query = domain.query.first() if not domain_to_query: - current_app.logger.error("No domain found to query a health check") + current_app.logger.error("No zone found to query a health check") return make_response("Unknown", 503) try: domain.get_domain_info(domain_to_query.name) except Exception as e: current_app.logger.error( - "Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name)) + "Health Check - Failed to query authoritative server for zone {}".format(domain_to_query.name)) return make_response("Down", 503) return make_response("Up", 200) diff --git a/powerdnsadmin/routes/base.py b/powerdnsadmin/routes/base.py index 7af342c..f805c90 100644 --- a/powerdnsadmin/routes/base.py +++ b/powerdnsadmin/routes/base.py @@ -60,15 +60,31 @@ def login_via_authorization_header_or_remote_user(request): # Try to login using Basic Authentication auth_header = request.headers.get('Authorization') if auth_header: + + if auth_header[:6] != "Basic ": + return None + auth_method = request.args.get('auth_method', 'LOCAL') auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' - auth_header = auth_header.replace('Basic ', '', 1) + + # Remove "Basic " from the header value + auth_header = auth_header[6:] + try: auth_header = str(base64.b64decode(auth_header), 'utf-8') - username, password = auth_header.split(":") - except TypeError as e: + except (UnicodeDecodeError, TypeError) as e: return None + # NK: We use auth_components here as we don't know if we'll have a :, we split it maximum 1 times to grab the + # username, the rest of the string would be the password. + auth_components = auth_header.split(':', maxsplit=1) + + # If we don't have two auth components (username, password), we can return + if len(auth_components) != 2: + return None + + (username, password) = auth_components + user = User(username=username, password=password, plain_text_password=password) diff --git a/powerdnsadmin/routes/dashboard.py b/powerdnsadmin/routes/dashboard.py index 0f24121..e517207 100644 --- a/powerdnsadmin/routes/dashboard.py +++ b/powerdnsadmin/routes/dashboard.py @@ -141,7 +141,7 @@ def domains_custom(tab_id): filtered_count = domains.count() start = int(request.args.get("start", 0)) - length = min(int(request.args.get("length", 0)), 100) + length = min(int(request.args.get("length", 0)), max(100, int(Setting().get('default_domain_table_size')))) if length != -1: domains = domains[start:start + length] @@ -176,10 +176,10 @@ def dashboard(): BG_DOMAIN_UPDATE = Setting().get('bg_domain_updates') if not BG_DOMAIN_UPDATE: - current_app.logger.info('Updating domains in foreground...') + current_app.logger.info('Updating zones in foreground...') Domain().update() else: - current_app.logger.info('Updating domains in background...') + current_app.logger.info('Updating zones in background...') show_bg_domain_button = BG_DOMAIN_UPDATE if BG_DOMAIN_UPDATE and current_user.role.name not in ['Administrator', 'Operator']: @@ -196,7 +196,7 @@ def dashboard(): @login_required @operator_role_required def domains_updater(): - current_app.logger.debug('Update domains in background') + current_app.logger.debug('Update zones in background') d = Domain().update() response_data = { diff --git a/powerdnsadmin/routes/domain.py b/powerdnsadmin/routes/domain.py index 5fef976..bee1250 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -66,7 +66,7 @@ def domain(domain_name): current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets))) # API server might be down, misconfigured - if not rrsets and domain.type != 'Slave': + if not rrsets and domain.type != 'slave': abort(500) quick_edit = Setting().get('record_quick_edit') @@ -178,7 +178,7 @@ def remove(): if result['status'] == 'error': abort(500) - history = History(msg='Delete domain {0}'.format( + history = History(msg='Delete zone {0}'.format( pretty_domain_name(domain_name)), created_by=current_user.username) history.add() @@ -206,7 +206,7 @@ def changelog(domain_name): current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets))) # API server might be down, misconfigured - if not rrsets and domain.type != 'Slave': + if not rrsets and domain.type != 'slave': abort(500) records_allow_to_edit = Setting().get_records_allow_to_edit() @@ -294,7 +294,7 @@ def record_changelog(domain_name, record_name, record_type): current_app.logger.debug("Fetched rrsets: \n{}".format(pretty_json(rrsets))) # API server might be down, misconfigured - if not rrsets and domain.type != 'Slave': + if not rrsets and domain.type != 'slave': abort(500) # get all changelogs for this domain, in descening order @@ -362,7 +362,7 @@ def add(): if ' ' in domain_name or not domain_name or not domain_type: return render_template( 'errors/400.html', - msg="Please enter a valid domain name"), 400 + msg="Please enter a valid zone name"), 400 if domain_name.endswith('.'): domain_name = domain_name[:-1] @@ -385,11 +385,11 @@ def add(): try: domain_name = to_idna(domain_name, 'encode') except: - current_app.logger.error("Cannot encode the domain name {}".format(domain_name)) + current_app.logger.error("Cannot encode the zone name {}".format(domain_name)) current_app.logger.debug(traceback.format_exc()) return render_template( 'errors/400.html', - msg="Please enter a valid domain name"), 400 + msg="Please enter a valid zone name"), 400 if domain_type == 'slave': if request.form.getlist('domain_master_address'): @@ -429,7 +429,7 @@ def add(): else: accounts = current_user.get_accounts() - msg = 'Domain already exists as a record under domain: {}'.format(upper_domain) + msg = 'Zone already exists as a record under zone: {}'.format(upper_domain) return render_template('domain_add.html', domain_override_message=msg, @@ -443,7 +443,7 @@ def add(): account_name=account_name) if result['status'] == 'ok': domain_id = Domain().get_id_by_name(domain_name) - history = History(msg='Add domain {0}'.format( + history = History(msg='Add zone {0}'.format( pretty_domain_name(domain_name)), detail = json.dumps({ 'domain_type': domain_type, @@ -507,7 +507,7 @@ def add(): return render_template('errors/400.html', msg=result['msg']), 400 except Exception as e: - current_app.logger.error('Cannot add domain. Error: {0}'.format(e)) + current_app.logger.error('Cannot add zone. Error: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) abort(500) @@ -537,7 +537,7 @@ def delete(domain_name): if result['status'] == 'error': abort(500) - history = History(msg='Delete domain {0}'.format( + history = History(msg='Delete zone {0}'.format( pretty_domain_name(domain_name)), created_by=current_user.username) history.add() @@ -560,13 +560,17 @@ def setting(domain_name): d = Domain(name=domain_name) domain_user_ids = d.get_user() account = d.get_account() + domain_info = d.get_domain_info(domain_name) return render_template('domain_setting.html', domain=domain, users=users, domain_user_ids=domain_user_ids, accounts=accounts, - domain_account=account) + domain_account=account, + zone_type=domain_info["kind"].lower(), + masters=','.join(domain_info["masters"]), + soa_edit_api=domain_info["soa_edit_api"].upper()) if request.method == 'POST': # username in right column @@ -581,7 +585,7 @@ def setting(domain_name): d.grant_privileges(new_user_ids) history = History( - msg='Change domain {0} access control'.format( + msg='Change zone {0} access control'.format( pretty_domain_name(domain_name)), detail=json.dumps({'user_has_access': new_user_list}), created_by=current_user.username, @@ -619,7 +623,7 @@ def change_type(domain_name): kind=domain_type, masters=domain_master_ips) if status['status'] == 'ok': - history = History(msg='Update type for domain {0}'.format( + history = History(msg='Update type for zone {0}'.format( pretty_domain_name(domain_name)), detail=json.dumps({ "domain": domain_name, @@ -653,7 +657,7 @@ def change_soa_edit_api(domain_name): soa_edit_api=new_setting) if status['status'] == 'ok': history = History( - msg='Update soa_edit_api for domain {0}'.format( + msg='Update soa_edit_api for zone {0}'.format( pretty_domain_name(domain_name)), detail = json.dumps({ 'domain': domain_name, @@ -697,7 +701,7 @@ def record_apply(domain_name): domain = Domain.query.filter(Domain.name == domain_name).first() if domain: - current_app.logger.debug('Current domain serial: {0}'.format( + current_app.logger.debug('Current zone serial: {0}'.format( domain.serial)) if int(submitted_serial) != domain.serial: @@ -714,14 +718,14 @@ def record_apply(domain_name): 'status': 'error', 'msg': - 'Domain name {0} does not exist'.format(pretty_domain_name(domain_name)) + 'Zone name {0} does not exist'.format(pretty_domain_name(domain_name)) }), 404) r = Record() result = r.apply(domain_name, submitted_record) if result['status'] == 'ok': history = History( - msg='Apply record changes to domain {0}'.format(pretty_domain_name(domain_name)), + msg='Apply record changes to zone {0}'.format(pretty_domain_name(domain_name)), detail = json.dumps({ 'domain': domain_name, 'add_rrsets': result['data'][0]['rrsets'], @@ -733,7 +737,7 @@ def record_apply(domain_name): return make_response(jsonify(result), 200) else: history = History( - msg='Failed to apply record changes to domain {0}'.format( + msg='Failed to apply record changes to zone {0}'.format( pretty_domain_name(domain_name)), detail = json.dumps({ 'domain': domain_name, @@ -760,7 +764,7 @@ def record_apply(domain_name): @can_access_domain def record_update(domain_name): """ - This route is used for domain work as Slave Zone only + This route is used for zone work as Slave Zone only Pulling the records update from its Master """ try: @@ -818,7 +822,7 @@ def dnssec_enable(domain_name): dnssec = domain.enable_domain_dnssec(domain_name) domain_object = Domain.query.filter(domain_name == Domain.name).first() history = History( - msg='DNSSEC was enabled for domain ' + domain_name , + msg='DNSSEC was enabled for zone ' + domain_name , created_by=current_user.username, domain_id=domain_object.id) history.add() @@ -837,7 +841,7 @@ def dnssec_disable(domain_name): domain.delete_dnssec_key(domain_name, key['id']) domain_object = Domain.query.filter(domain_name == Domain.name).first() history = History( - msg='DNSSEC was disabled for domain ' + domain_name , + msg='DNSSEC was disabled for zone ' + domain_name , created_by=current_user.username, domain_id=domain_object.id) history.add() @@ -914,7 +918,7 @@ def admin_setdomainsetting(domain_name): }), 400) except Exception as e: current_app.logger.error( - 'Cannot change domain setting. Error: {0}'.format(e)) + 'Cannot change zone setting. Error: {0}'.format(e)) current_app.logger.debug(traceback.format_exc()) return make_response( jsonify({ diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 72305a6..d56ce61 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -5,6 +5,8 @@ import traceback import datetime import ipaddress import base64 +import string +from zxcvbn import zxcvbn from distutils.util import strtobool from yaml import Loader, load from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort @@ -43,6 +45,7 @@ index_bp = Blueprint('index', template_folder='templates', url_prefix='/') + @index_bp.before_app_first_request def register_modules(): global google @@ -66,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') @@ -96,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) @@ -108,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) @@ -120,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) @@ -134,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) @@ -147,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']: @@ -170,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: @@ -181,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() @@ -196,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 @@ -209,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) @@ -227,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() @@ -248,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: @@ -367,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() @@ -392,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): @@ -415,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) @@ -424,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' @@ -488,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 @@ -524,9 +553,10 @@ def clear_session(): session.pop('user_id', None) session.pop('github_token', None) session.pop('google_token', None) + session.pop('azure_token', None) + session.pop('oidc_token', None) session.pop('authentication_type', None) session.pop('remote_user', None) - session.clear() logout_user() @@ -552,14 +582,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 @@ -575,30 +606,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'): @@ -649,75 +683,168 @@ def logout(): return redirect(redirect_uri) +def password_policy_check(user, password): + def check_policy(chars, user_password, setting): + setting_as_int = int(Setting().get(setting)) + test_string = user_password + for c in chars: + test_string = test_string.replace(c, '') + return (setting_as_int, len(user_password) - len(test_string)) + + def matches_policy(item, policy_fails): + return "*" if item in policy_fails else "" + + policy = [] + policy_fails = {} + + # If either policy is enabled check basics first ... this is obvious! + if Setting().get('pwd_enforce_characters') or Setting().get('pwd_enforce_complexity'): + # Cannot contain username + if user.username in password: + policy_fails["username"] = True + policy.append(f"{matches_policy('username', policy_fails)}cannot contain username") + + # Cannot contain password + if user.firstname in password: + policy_fails["firstname"] = True + policy.append(f"{matches_policy('firstname', policy_fails)}cannot contain firstname") + + # Cannot contain lastname + if user.lastname in password: + policy_fails["lastname"] = True + policy.append(f"{matches_policy('lastname', policy_fails)}cannot contain lastname") + + # Cannot contain email + if user.email in password: + policy_fails["email"] = True + policy.append(f"{matches_policy('email', policy_fails)}cannot contain email") + + # Check if we're enforcing character requirements + if Setting().get('pwd_enforce_characters'): + # Length + pwd_min_len_setting = int(Setting().get('pwd_min_len')) + pwd_len = len(password) + if pwd_len < pwd_min_len_setting: + policy_fails["length"] = True + policy.append(f"{matches_policy('length', policy_fails)}length={pwd_len}/{pwd_min_len_setting}") + # Digits + (pwd_min_digits_setting, pwd_digits) = check_policy(string.digits, password, 'pwd_min_digits') + if pwd_digits < pwd_min_digits_setting: + policy_fails["digits"] = True + policy.append(f"{matches_policy('digits', policy_fails)}digits={pwd_digits}/{pwd_min_digits_setting}") + # Lowercase + (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}") + # 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}") + # Special + (pwd_min_special_setting, pwd_special) = check_policy(string.digits, password, 'pwd_min_special') + if pwd_special < pwd_min_special_setting: + policy_fails["special"] = True + policy.append(f"{matches_policy('special', policy_fails)}special={pwd_special}/{pwd_min_special_setting}") + + if Setting().get('pwd_enforce_complexity'): + # Complexity checking + zxcvbn_inputs = [] + for input in (user.firstname, user.lastname, user.username, user.email): + if len(input): + zxcvbn_inputs.append(input) + + result = zxcvbn(password, user_inputs=zxcvbn_inputs) + pwd_min_complexity_setting = int(Setting().get('pwd_min_complexity')) + 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_str = {"password": f"Fails policy: {', '.join(policy)}. Items prefixed with '*' failed."} + + # NK: the first item in the tuple indicates a PASS, so, we check for any True's and negate that + return (not any(policy_fails.values()), policy_str) + + @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 + ) - 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')) + (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')) + 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 @@ -736,12 +863,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) @@ -828,10 +958,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()) @@ -891,13 +1021,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() @@ -908,7 +1038,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): @@ -933,11 +1063,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() @@ -997,7 +1127,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() @@ -1006,7 +1136,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'][ @@ -1038,7 +1168,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']: @@ -1079,13 +1209,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, []): @@ -1099,7 +1229,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() @@ -1143,15 +1273,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/routes/user.py b/powerdnsadmin/routes/user.py index 65d7e08..adba502 100644 --- a/powerdnsadmin/routes/user.py +++ b/powerdnsadmin/routes/user.py @@ -9,6 +9,8 @@ from flask_login import current_user, login_required, login_manager from ..models.user import User, Anonymous from ..models.setting import Setting +from .index import password_policy_check + user_bp = Blueprint('user', __name__, @@ -79,12 +81,23 @@ def profile(): .format(current_user.username) }), 400) + (password_policy_pass, password_policy) = password_policy_check(current_user.get_user_info_by_username(), new_password) + if not password_policy_pass: + if request.data: + return make_response( + jsonify({ + 'status': 'error', + 'msg': password_policy['password'], + }), 400) + return render_template('user_profile.html', error_messages=password_policy) + user = User(username=current_user.username, plain_text_password=new_password, firstname=firstname, lastname=lastname, email=email, reload_info=False) + user.update_profile() return render_template('user_profile.html') diff --git a/powerdnsadmin/services/azure.py b/powerdnsadmin/services/azure.py index 46fb1af..901cc45 100644 --- a/powerdnsadmin/services/azure.py +++ b/powerdnsadmin/services/azure.py @@ -15,28 +15,40 @@ def azure_oauth(): session['azure_token'] = token return token + authlib_params = { + 'client_id': Setting().get('azure_oauth_key'), + 'client_secret': Setting().get('azure_oauth_secret'), + 'api_base_url': Setting().get('azure_oauth_api_url'), + 'request_token_url': None, + 'client_kwargs': {'scope': Setting().get('azure_oauth_scope')}, + 'fetch_token': fetch_azure_token, + } + + server_metadata_url = Setting().get('azure_oauth_metadata_url') + + 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', - client_id=Setting().get('azure_oauth_key'), - 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, + **authlib_params ) @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 cf615e8..f322e8c 100644 --- a/powerdnsadmin/services/github.py +++ b/powerdnsadmin/services/github.py @@ -15,28 +15,42 @@ def github_oauth(): session['github_token'] = token return token + authlib_params = { + 'client_id': Setting().get('github_oauth_key'), + 'client_secret': Setting().get('github_oauth_secret'), + 'request_token_params': {'scope': Setting().get('github_oauth_scope')}, + 'api_base_url': Setting().get('github_oauth_api_url'), + 'request_token_url': None, + 'client_kwargs': {'scope': Setting().get('github_oauth_scope')}, + 'fetch_token': fetch_github_token, + 'update_token': update_token + } + + server_metadata_url = Setting().get('github_oauth_metadata_url') + + 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', - client_id=Setting().get('github_oauth_key'), - client_secret=Setting().get('github_oauth_secret'), - 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) + **authlib_params + ) @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 68775a2..011c120 100644 --- a/powerdnsadmin/services/google.py +++ b/powerdnsadmin/services/google.py @@ -15,30 +15,43 @@ def google_oauth(): session['google_token'] = token return token + authlib_params = { + 'client_id': Setting().get('google_oauth_client_id'), + 'client_secret': Setting().get('google_oauth_client_secret'), + 'api_base_url': Setting().get('google_base_url'), + 'request_token_url': None, + 'client_kwargs': {'scope': Setting().get('google_oauth_scope')}, + 'fetch_token': fetch_google_token, + 'update_token': update_token + } + + server_metadata_url = Setting().get('google_oauth_metadata_url') + + 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', - client_id=Setting().get('google_oauth_client_id'), - 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) + **authlib_params + ) @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 b5da89e..25c73f0 100644 --- a/powerdnsadmin/services/oidc.py +++ b/powerdnsadmin/services/oidc.py @@ -15,28 +15,41 @@ def oidc_oauth(): session['oidc_token'] = token return token + authlib_params = { + 'client_id': Setting().get('oidc_oauth_key'), + 'client_secret': Setting().get('oidc_oauth_secret'), + 'api_base_url': Setting().get('oidc_oauth_api_url'), + 'request_token_url': None, + 'client_kwargs': {'scope': Setting().get('oidc_oauth_scope')}, + 'fetch_token': fetch_oidc_token, + 'update_token': update_token + } + + server_metadata_url = Setting().get('oidc_oauth_metadata_url') + + 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', - client_id=Setting().get('oidc_oauth_key'), - 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'), - server_metadata_url=Setting().get('oidc_oauth_metadata_url'), - client_kwargs={'scope': Setting().get('oidc_oauth_scope')}, - fetch_token=fetch_oidc_token, - update_token=update_token) + **authlib_params + ) @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..da273b7 --- /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: true, + signup_enabled: true, + 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: false, + 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: false, + ldap_admin_group: '', + ldap_operator_group: '', + ldap_user_group: '', + autoprovisioning: false, + autoprovisioning_attribute: '', + urn_value: '', + purge: 0, + + // Google OAuth2 Settings + google_oauth_enabled: false, + google_oauth_client_id: '', + google_oauth_client_secret: '', + google_oauth_scope: '', + google_base_url: '', + google_oauth_auto_configure: true, + google_oauth_metadata_url: '', + google_token_url: '', + google_authorize_url: '', + + // GitHub OAuth2 Settings + github_oauth_enabled: false, + github_oauth_key: '', + github_oauth_secret: '', + github_oauth_scope: '', + github_oauth_api_url: '', + github_oauth_auto_configure: false, + github_oauth_metadata_url: '', + github_oauth_token_url: '', + github_oauth_authorize_url: '', + + // Azure AD OAuth2 Settings + azure_oauth_enabled: false, + azure_oauth_key: '', + azure_oauth_secret: '', + azure_oauth_scope: '', + azure_oauth_api_url: '', + 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: '', + azure_user_group: '', + azure_group_accounts_enabled: false, + azure_group_accounts_name: '', + azure_group_accounts_name_re: '', + azure_group_accounts_description: '', + azure_group_accounts_description_re: '', + + // OIDC OAuth2 Settings + oidc_oauth_enabled: false, + oidc_oauth_key: '', + oidc_oauth_secret: '', + oidc_oauth_scope: '', + oidc_oauth_api_url: '', + oidc_oauth_auto_configure: true, + 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_edit_account.html b/powerdnsadmin/templates/admin_edit_account.html index 4894df8..ca08ab7 100644 --- a/powerdnsadmin/templates/admin_edit_account.html +++ b/powerdnsadmin/templates/admin_edit_account.html @@ -96,7 +96,7 @@
-

Users on the right have access to manage records in all domains +

Users on the right have access to manage records in all zones associated with the account.

Click on users to move between columns.

@@ -113,12 +113,12 @@
-

Domains on the right are associated with the account. Red marked domain names are +

Zones on the right are associated with the account. Red marked zone names are already associated with other accounts. - Moving already associated domains to this account will overwrite the previous + Moving already associated zones to this account will overwrite the previous associated account.

-

Hover over the red domain names to show the associated account. Click on domains to +

Hover over the red zone names to show the associated account. Click on zones to move between columns.

- + - -
-
Associate: {{ history_assoc_account }}
- +
+ placeholder="Enter * to search for any zone" value="">
@@ -144,65 +144,64 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Filters
-
- -
-
- - - -
- - - -
 
 
- - - -
- - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Filters
+
+ +
+
+ + + +
+ + + +
 
 
+ + + +
+
+
@@ -447,7 +446,7 @@ $('#auth_name_filter').val(''); $('#user_name_filter').removeAttr('disabled'); canSearch = false; - main_field = "Domain Name" + main_field = "Zone Name" }); $('#account_tab').click(function () { diff --git a/powerdnsadmin/templates/admin_manage_account.html b/powerdnsadmin/templates/admin_manage_account.html index fe2c5bb..aa3f1ae 100644 --- a/powerdnsadmin/templates/admin_manage_account.html +++ b/powerdnsadmin/templates/admin_manage_account.html @@ -48,7 +48,7 @@ Contact Mail Member - Domain + Zone(s) Action diff --git a/powerdnsadmin/templates/admin_manage_keys.html b/powerdnsadmin/templates/admin_manage_keys.html index 8d79e10..96ee73b 100644 --- a/powerdnsadmin/templates/admin_manage_keys.html +++ b/powerdnsadmin/templates/admin_manage_keys.html @@ -43,7 +43,7 @@ Id Role Description - Domains + Zones Accounts Actions diff --git a/powerdnsadmin/templates/admin_manage_user.html b/powerdnsadmin/templates/admin_manage_user.html index 8d1ace8..81fb26c 100644 --- a/powerdnsadmin/templates/admin_manage_user.html +++ b/powerdnsadmin/templates/admin_manage_user.html @@ -143,7 +143,7 @@ var modal = $("#modal_revoke"); var username = $(this).prop('id'); var info = "Are you sure you want to revoke all privileges for user " + username + - "? They will not able to access any domain."; + "? They will not able to access any zone."; modal.find('.modal-body p').text(info); modal.find('#button_revoke_confirm').click(function () { var postdata = { diff --git a/powerdnsadmin/templates/admin_setting_authentication.html b/powerdnsadmin/templates/admin_setting_authentication.html index 26d9a72..c7f3e2e 100644 --- a/powerdnsadmin/templates/admin_setting_authentication.html +++ b/powerdnsadmin/templates/admin_setting_authentication.html @@ -12,6 +12,7 @@
@@ -25,57 +26,93 @@
-
-
-

Settings Editor

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

Settings Editor

+
+ +
+
+
+ +
+
- {% endif %} - - -
- + +
@@ -1351,429 +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 5a47bd2..c8708e8 100644 --- a/powerdnsadmin/templates/base.html +++ b/powerdnsadmin/templates/base.html @@ -101,14 +101,22 @@ {% endif %} - {% if current_user.role.name in ['Administrator', 'Operator'] %} - -
  • - - -

    Global Search

    +
  • +
  • + + +

    Global Search

    +
    +
  • + {% if current_user.role.name in ['Administrator', 'Operator'] or SETTING.get('allow_user_view_history') %} +
  • + + +

    Activity

  • + {% endif %} + {% if current_user.role.name in ['Administrator', 'Operator'] %}
  • @@ -121,12 +129,6 @@

    Server Configuration

  • -
  • - - -

    Activity

    -
    -
  • @@ -189,14 +191,6 @@ {% endif %}
  • - {% elif SETTING.get('allow_user_view_history') %} - -
  • - - -

    History

    -
    -
  • {% endif %} {% endif %} @@ -234,7 +228,7 @@
    PowerDNS-Admin - A PowerDNS web interface with advanced features. - Version 0.4.0 + Version 0.4.1
    diff --git a/powerdnsadmin/templates/dashboard.html b/powerdnsadmin/templates/dashboard.html index 1dde5af..83d71d3 100755 --- a/powerdnsadmin/templates/dashboard.html +++ b/powerdnsadmin/templates/dashboard.html @@ -269,7 +269,7 @@ @@ -41,7 +41,7 @@
    @@ -125,4 +125,4 @@ } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/powerdnsadmin/templates/domain_remove.html b/powerdnsadmin/templates/domain_remove.html index 75c3e26..7ea6e88 100644 --- a/powerdnsadmin/templates/domain_remove.html +++ b/powerdnsadmin/templates/domain_remove.html @@ -37,7 +37,7 @@

    -
    -

    This function is used to remove a domain from PowerDNS-Admin AND PowerDNS. All +

    This function is used to remove a zone from PowerDNS-Admin AND PowerDNS. All records and - user privileges associated with this domain will also be removed. This change cannot be + user privileges associated with this zone will also be removed. This change cannot be reverted.