diff --git a/.gitignore b/.gitignore index eb7fa03..bd129d0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,14 @@ nosetests.xml flask config.py logfile.log +settings.json +advanced_settings.json +idp.crt log.txt db_repository/* upload/avatar/* tmp/* .ropeproject +.sonarlint/* +pdns.db diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..1171f8f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python +python: + - "3.5.2" +before_install: + - 'travis_retry sudo apt-get update' + - 'travis_retry sudo apt-get install python3-dev libxml2-dev libxmlsec1-dev' +install: + - pip install -r requirements.txt +before_script: + - mv config_template.py config.py +script: + - sh run_travis.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8ee6b7d..65c0655 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,21 @@ ENV ENVIRONMENT=${ENVIRONMENT} WORKDIR /powerdns-admin RUN apt-get update -y -RUN apt-get install -y python3-pip python3-dev libmysqlclient-dev supervisor +RUN apt-get install -y python3-pip python3-dev supervisor + +# lib for building mysql db driver +RUN apt-get install -y libmysqlclient-dev + +# lib for buiding ldap and ssl-based application RUN apt-get install -y libsasl2-dev libldap2-dev libssl-dev +# lib for building python3-saml +RUN apt-get install -y libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev pkg-config + COPY ./requirements.txt /powerdns-admin/requirements.txt RUN pip3 install -r requirements.txt ADD ./supervisord.conf /etc/supervisord.conf ADD . /powerdns-admin/ COPY ./configs/${ENVIRONMENT}.py /powerdns-admin/config.py + diff --git a/README.md b/README.md index d7bbdb4..7b17f6b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ A PowerDNS web interface with advanced features. - User access management based on domain - User activity logging - Local DB / LDAP / Active Directory user authentication +- Support SAML authentication +- Google oauth authentication +- Github oauth authentication - Support Two-factor authentication (TOTP) - Dashboard and pdns service statistics - DynDNS 2 protocol support diff --git a/app/__init__.py b/app/__init__.py index 96e36d8..897db56 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -11,7 +11,6 @@ login_manager = LoginManager() login_manager.init_app(app) db = SQLAlchemy(app) - def enable_github_oauth(GITHUB_ENABLE): if not GITHUB_ENABLE: return None, None @@ -89,5 +88,9 @@ def enable_google_oauth(GOOGLE_ENABLE): google = enable_google_oauth(app.config.get('GOOGLE_OAUTH_ENABLE')) - from app import views, models + +if app.config.get('SAML_ENABLED') and app.config.get('SAML_ENCRYPT'): + from app.lib import certutil + if not certutil.check_certificate(): + certutil.create_self_signed_cert() diff --git a/app/lib/certutil.py b/app/lib/certutil.py new file mode 100644 index 0000000..8f1b93b --- /dev/null +++ b/app/lib/certutil.py @@ -0,0 +1,48 @@ +from OpenSSL import crypto +from datetime import datetime +import pytz +import os +CRYPT_PATH = os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + "/../../") +CERT_FILE = CRYPT_PATH + "/saml_cert.crt" +KEY_FILE = CRYPT_PATH + "/saml_cert.key" + + +def check_certificate(): + if not os.path.isfile(CERT_FILE): + return False + st_cert = open(CERT_FILE, 'rt').read() + cert = crypto.load_certificate(crypto.FILETYPE_PEM, st_cert) + now = datetime.now(pytz.utc) + begin = datetime.strptime(cert.get_notBefore(), "%Y%m%d%H%M%SZ").replace(tzinfo=pytz.UTC) + begin_ok = begin < now + end = datetime.strptime(cert.get_notAfter(), "%Y%m%d%H%M%SZ").replace(tzinfo=pytz.UTC) + end_ok = end > now + if begin_ok and end_ok: + return True + return False + +def create_self_signed_cert(): + + # create a key pair + k = crypto.PKey() + k.generate_key(crypto.TYPE_RSA, 2048) + + # create a self-signed cert + cert = crypto.X509() + cert.get_subject().C = "DE" + cert.get_subject().ST = "NRW" + cert.get_subject().L = "Dortmund" + cert.get_subject().O = "Dummy Company Ltd" + cert.get_subject().OU = "Dummy Company Ltd" + cert.get_subject().CN = "PowerDNS-Admin" + cert.set_serial_number(1000) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(10*365*24*60*60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(k) + cert.sign(k, 'sha256') + + open(CERT_FILE, "wt").write( + crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + open(KEY_FILE, "wt").write( + crypto.dump_privatekey(crypto.FILETYPE_PEM, k)) \ No newline at end of file diff --git a/app/lib/utils.py b/app/lib/utils.py index e122043..84c4a75 100644 --- a/app/lib/utils.py +++ b/app/lib/utils.py @@ -7,6 +7,44 @@ import hashlib from app import app from distutils.version import StrictVersion from urllib.parse import urlparse +from datetime import datetime, timedelta +from threading import Thread + +from .certutil import * + +if app.config['SAML_ENABLED']: + from onelogin.saml2.auth import OneLogin_Saml2_Auth + from onelogin.saml2.utils import OneLogin_Saml2_Utils + from onelogin.saml2.settings import OneLogin_Saml2_Settings + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser + idp_timestamp = datetime(1970, 1, 1) + idp_data = None + idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL']) + if idp_data is None: + print('SAML: IDP Metadata initial load failed') + exit(-1) + idp_timestamp = datetime.now() + + +def get_idp_data(): + global idp_data, idp_timestamp + lifetime = timedelta(minutes=app.config['SAML_METADATA_CACHE_LIFETIME']) + if idp_timestamp+lifetime < datetime.now(): + background_thread = Thread(target=retreive_idp_data) + background_thread.start() + return idp_data + + +def retreive_idp_data(): + global idp_data, idp_timestamp + new_idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL']) + if new_idp_data is not None: + idp_data = new_idp_data + idp_timestamp = datetime.now() + print("SAML: IDP Metadata successfully retreived from: " + app.config['SAML_METADATA_URL']) + else: + print("SAML: IDP Metadata could not be retreived") + if 'TIMEOUT' in app.config.keys(): TIMEOUT = app.config['TIMEOUT'] @@ -62,7 +100,8 @@ def fetch_remote(remote_url, method='GET', data=None, accept=None, params=None, def fetch_json(remote_url, method='GET', data=None, params=None, headers=None): - r = fetch_remote(remote_url, method=method, data=data, params=params, headers=headers, accept='application/json; q=1') + r = fetch_remote(remote_url, method=method, data=data, params=params, headers=headers, + accept='application/json; q=1') if method == "DELETE": return True @@ -159,3 +198,78 @@ def email_to_gravatar_url(email="", size=100): """ hash_string = hashlib.md5(email.encode('utf-8')).hexdigest() return "https://s.gravatar.com/avatar/{0}?s={1}".format(hash_string, size) + + +def prepare_flask_request(request): + # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields + url_data = urlparse.urlparse(request.url) + return { + 'https': 'on' if request.scheme == 'https' else 'off', + 'http_host': request.host, + 'server_port': url_data.port, + 'script_name': request.path, + 'get_data': request.args.copy(), + 'post_data': request.form.copy(), + # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 + 'lowercase_urlencoding': True, + 'query_string': request.query_string + } + + +def init_saml_auth(req): + own_url = '' + if req['https'] is 'on': + own_url = 'https://' + else: + own_url = 'http://' + own_url += req['http_host'] + metadata = get_idp_data() + settings = {} + settings['sp'] = {} + settings['sp']['NameIDFormat'] = idp_data['sp']['NameIDFormat'] + settings['sp']['entityId'] = app.config['SAML_SP_ENTITY_ID'] + cert = open(CERT_FILE, "r").readlines() + key = open(KEY_FILE, "r").readlines() + settings['sp']['privateKey'] = "".join(key) + settings['sp']['x509cert'] = "".join(cert) + settings['sp']['assertionConsumerService'] = {} + settings['sp']['assertionConsumerService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + settings['sp']['assertionConsumerService']['url'] = own_url+'/saml/authorized' + settings['sp']['attributeConsumingService'] = {} + settings['sp']['singleLogoutService'] = {} + settings['sp']['singleLogoutService']['binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + settings['sp']['singleLogoutService']['url'] = own_url+'/saml/sls' + settings['idp'] = metadata['idp'] + settings['strict'] = True + settings['debug'] = app.config['SAML_DEBUG'] + settings['security'] = {} + settings['security']['digestAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + settings['security']['metadataCacheDuration'] = None + settings['security']['metadataValidUntil'] = None + settings['security']['requestedAuthnContext'] = True + settings['security']['signatureAlgorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' + settings['security']['wantAssertionsEncrypted'] = False + settings['security']['wantAttributeStatement'] = True + settings['security']['wantNameId'] = True + settings['security']['authnRequestsSigned'] = app.config['SAML_SIGN_REQUEST'] + settings['security']['logoutRequestSigned'] = app.config['SAML_SIGN_REQUEST'] + settings['security']['logoutResponseSigned'] = app.config['SAML_SIGN_REQUEST'] + settings['security']['nameIdEncrypted'] = False + settings['security']['signMetadata'] = True + settings['security']['wantAssertionsSigned'] = True + settings['security']['wantMessagesSigned'] = True + settings['security']['wantNameIdEncrypted'] = False + settings['contactPerson'] = {} + settings['contactPerson']['support'] = {} + settings['contactPerson']['support']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME'] + settings['contactPerson']['support']['givenName'] = app.config['SAML_SP_CONTACT_MAIL'] + settings['contactPerson']['technical'] = {} + settings['contactPerson']['technical']['emailAddress'] = app.config['SAML_SP_CONTACT_NAME'] + settings['contactPerson']['technical']['givenName'] = app.config['SAML_SP_CONTACT_MAIL'] + settings['organization'] = {} + settings['organization']['en-US'] = {} + settings['organization']['en-US']['displayname'] = 'PowerDNS-Admin' + settings['organization']['en-US']['name'] = 'PowerDNS-Admin' + settings['organization']['en-US']['url'] = own_url + auth = OneLogin_Saml2_Auth(req, settings) + return auth \ No newline at end of file diff --git a/app/models.py b/app/models.py index 36dffe9..25024b7 100644 --- a/app/models.py +++ b/app/models.py @@ -140,7 +140,9 @@ class User(db.Model): def check_password(self, hashed_password): # Check hased password. Useing bcrypt, the salt is saved into the hash itself - return bcrypt.checkpw(self.plain_text_password.encode('utf-8'), hashed_password.encode('utf-8')) + if (self.plain_text_password): + return bcrypt.checkpw(self.plain_text_password.encode('utf-8'), hashed_password.encode('utf-8')) + return False def get_user_info_by_id(self): user_info = User.query.get(int(self.id)) @@ -276,9 +278,9 @@ class User(db.Model): self.set_admin(isadmin) self.update_profile() return True - - logging.error('Unsupported authentication method') - return False + else: + logging.error('Unsupported authentication method') + return False def create_user(self): """ @@ -286,10 +288,10 @@ class User(db.Model): We will create a local user (in DB) in order to manage user profile such as name, roles,... """ - + # Set an invalid password hash for non local users self.password = '*' - + db.session.add(self) db.session.commit() @@ -653,9 +655,41 @@ class Domain(db.Model): logging.debug(traceback.print_exc()) return {'status': 'error', 'msg': 'Cannot add this domain.'} + 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 doesnt exist.'} + headers = {} + headers['X-API-Key'] = PDNS_API_KEY + if soa_edit_api == 'OFF': + post_data = { + "soa_edit_api": None, + "kind": domain.type + } + else: + post_data = { + "soa_edit_api": soa_edit_api, + "kind": domain.type + } + try: + jdata = utils.fetch_json( + urljoin(PDNS_STATS_URL, API_EXTENDED_URL + '/servers/localhost/zones/{0}'.format(domain.name)), headers=headers, + method='PUT', data=post_data) + if 'error' in jdata.keys(): + logging.error(jdata['error']) + return {'status': 'error', 'msg': jdata['error']} + else: + logging.info('soa-edit-api changed for domain {0} successfully'.format(domain_name)) + return {'status': 'ok', 'msg': 'soa-edit-api changed successfully'} + except Exception as e: + logging.debug(e) + logging.debug(traceback.format_exc()) + logging.error('Cannot change soa-edit-api for domain {0}'.format(domain_name)) + return {'status': 'error', 'msg': 'Cannot change soa-edit-api this domain.'} + def create_reverse_domain(self, domain_name, domain_reverse_name): """ - Check the existing reverse lookup domain, + Check the existing reverse lookup domain, if not exists create a new one automatically """ domain_obj = Domain.query.filter(Domain.name == domain_name).first() @@ -799,6 +833,50 @@ class Domain(db.Model): else: return {'status': 'error', 'msg': 'This domain doesnot exist'} + def enable_domain_dnssec(self, domain_name): + """ + Enable domain DNSSEC + """ + domain = Domain.query.filter(Domain.name == domain_name).first() + if domain: + headers = {} + headers['X-API-Key'] = PDNS_API_KEY + post_data = { + "keytype": "ksk", + "active": True + } + try: + jdata = utils.fetch_json(urljoin(PDNS_STATS_URL, API_EXTENDED_URL + '/servers/localhost/zones/{0}/cryptokeys'.format(domain.name)), headers=headers, method='POST',data=post_data) + if 'error' in jdata: + return {'status': 'error', 'msg': 'DNSSEC is not enabled for this domain', 'jdata' : jdata} + else: + return {'status': 'ok'} + except: + logging.error(traceback.print_exc()) + return {'status': 'error', 'msg': 'There was something wrong, please contact administrator'} + else: + return {'status': 'error', 'msg': 'This domain does not exist'} + + def delete_dnssec_key(self, domain_name, key_id): + """ + Remove keys DNSSEC + """ + domain = Domain.query.filter(Domain.name == domain_name).first() + if domain: + headers = {} + headers['X-API-Key'] = PDNS_API_KEY + url = '/servers/localhost/zones/{0}/cryptokeys/{1}'.format(domain.name, key_id) + + try: + jdata = utils.fetch_json(urljoin(PDNS_STATS_URL, API_EXTENDED_URL + url), headers=headers, method='DELETE') + if 'error' in jdata: + return {'status': 'error', 'msg': 'DNSSEC is not disabled for this domain', 'jdata' : jdata} + else: + return {'status': 'ok'} + except: + return {'status': 'error', 'msg': 'There was something wrong, please contact administrator','id': key_id, 'url': url} + else: + return {'status': 'error', 'msg': 'This domain doesnot exist'} class DomainUser(db.Model): __tablename__ = 'domain_user' @@ -953,7 +1031,7 @@ class Record(object): if r_type == 'PTR': # only ptr if ':' in r['record_name']: # dirty ipv6 check r_name = r['record_name'] - + record = { "name": r_name, "type": r_type, @@ -962,7 +1040,7 @@ class Record(object): "ttl": int(r['record_ttl']) if r['record_ttl'] else 3600, } records.append(record) - + deleted_records, new_records = self.compare(domain, records) records = [] @@ -974,7 +1052,7 @@ class Record(object): if r_type == 'PTR': # only ptr if ':' in r['name']: # dirty ipv6 check r_name = dns.reversename.from_address(r['name']).to_text() - + record = { "name": r_name, "type": r_type, @@ -1035,12 +1113,12 @@ class Record(object): r_name = key[0] r_type = key[1] r_changetype = key[2] - + if PRETTY_IPV6_PTR: # only if activated if r_type == 'PTR': # only ptr if ':' in r_name: # dirty ipv6 check r_name = dns.reversename.from_address(r_name).to_text() - + new_record = { "name": r_name, "type": r_type, diff --git a/app/static/custom/js/custom.js b/app/static/custom/js/custom.js index ddd6d7b..936b8ee 100644 --- a/app/static/custom/js/custom.js +++ b/app/static/custom/js/custom.js @@ -1,3 +1,5 @@ +var dnssecKeyList = [] + function applyChanges(data, url, showResult, refreshPage) { var success = false; $.ajax({ @@ -116,7 +118,22 @@ function SelectElement(elementID, valueToSelect) element.value = valueToSelect; } -function getdnssec(url){ +function enable_dns_sec(url) { + $.getJSON(url, function(data) { + var modal = $("#modal_dnssec_info"); + + if (data['status'] == 'error'){ + modal.find('.modal-body p').text(data['msg']); + } + else { + modal.modal('hide'); + //location.reload(); + window.location.reload(true); + } + }) +} + +function getdnssec(url, domain){ $.getJSON(url, function(data) { var modal = $("#modal_dnssec_info"); @@ -127,23 +144,36 @@ function getdnssec(url){ else { dnssec_msg = ''; var dnssec = data['dnssec']; - for (var i = 0; i < dnssec.length; i++) { - if (dnssec[i]['active']){ - dnssec_msg += '
'+ - '