Merge branch 'master' into feature-google-oauth

This commit is contained in:
Thomas
2017-11-01 22:18:43 +01:00
committed by GitHub
11 changed files with 348 additions and 63 deletions

View File

@ -7,6 +7,39 @@ import hashlib
from app import app
from distutils.version import StrictVersion
from datetime import datetime,timedelta
from threading import Thread
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
if app.config['SAML_ENABLED']:
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(app.config['SAML_METADATA_URL'])
if idp_data == 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 != 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']
@ -159,3 +192,74 @@ def email_to_gravatar_url(email, size=100):
hash_string = hashlib.md5(email).hexdigest()
return "https://s.gravatar.com/avatar/%s?s=%s" % (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'] == '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']
settings['sp']['privateKey'] = ''
settings['sp']['x509cert'] = ''
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/2000/09/xmldsig#sha1'
settings['security']['metadataCacheDuration'] = None
settings['security']['metadataValidUntil'] = None
settings['security']['requestedAuthnContext'] = True
settings['security']['signatureAlgorithm'] = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
settings['security']['wantAssertionsEncrypted'] = False
settings['security']['wantAttributeStatement'] = True
settings['security']['wantNameId'] = True
settings['security']['authnRequestsSigned'] = False
settings['security']['logoutRequestSigned'] = False
settings['security']['logoutResponseSigned'] = False
settings['security']['nameIdEncrypted'] = False
settings['security']['signMetadata'] = False
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

View File

@ -314,6 +314,13 @@ class User(db.Model):
user_domains.append(q[2])
return user_domains
def can_access_domain(self, domain_name):
if self.role.name == "Administrator":
return True
query = self.get_domain_query().filter(Domain.name == domain_name)
return query.count() >= 1
def delete(self):
"""
Delete a user

View File

@ -167,7 +167,7 @@ json_library = {
return r + (pEnd || '');
},
prettyPrint: function(obj) {
obj = obj.replace(/u'/g, "\'").replace(/'/g, "\"").replace(/(False|None)/g, "\"$1\"");
obj = obj.replace(/"/g, "\\\"").replace(/u'/g, "\'").replace(/'/g, "\"").replace(/(False|None)/g, "\"$1\"");
var jsonData = JSON.parse(obj);
var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg;
return JSON.stringify(jsonData, null, 3)
@ -175,4 +175,4 @@ json_library = {
.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(jsonLine, json_library.replacer);
}
};
};

View File

@ -324,6 +324,7 @@
record_data.val(data);
modal.modal('hide');
})
modal.modal('show');
} else if (record_type == "SOA") {
var modal = $("#modal_custom_record");
if (record_data.val() == "") {

View File

@ -101,11 +101,16 @@
{% if google_enabled %}
<a href="{{ url_for('google_login') }}">Google oauth login</a>
{% endif %}
{% if saml_enabled %}
<br>
<a href="{{ url_for('saml_login') }}">SAML login</a>
{% endif %}
{% if github_enabled %}
<br>
<a href="{{ url_for('github_login') }}">Github oauth login</a>
{% endif %}
<br>
{% if signup_enabled %}
<br>
<a href="{{ url_for('register') }}" class="text-center">Create an account </a>
{% endif %}
</div>

View File

@ -19,7 +19,7 @@
<div class="col-lg-12">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Edit my profile</h3>
<h3 class="box-title">Edit my profile{% if external_account %} [Disabled - Authenticated externally]{% endif %}</h3>
</div>
<div class="box-body">
<!-- Custom Tabs -->
@ -29,10 +29,10 @@
Info</a></li>
<li><a href="#tabs-avatar" data-toggle="tab">Change
Avatar</a></li>
<li><a href="#tabs-password" data-toggle="tab">Change
{% if not external_account %}<li><a href="#tabs-password" data-toggle="tab">Change
Password</a></li>
<li><a href="#tabs-authentication" data-toggle="tab">Authentication
</a></li>
</a></li>{% endif %}>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="tabs-personal">
@ -40,21 +40,21 @@
<div class="form-group">
<label for="firstname">First Name</label> <input type="text"
class="form-control" name="firstname" id="firstname"
placeholder="{{ current_user.firstname }}">
placeholder="{{ current_user.firstname }}" {% if external_account %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="lastname">Last Name</label> <input type="text"
class="form-control" name="lastname" id="lastname"
placeholder="{{ current_user.lastname }}">
placeholder="{{ current_user.lastname }}" {% if external_account %}disabled{% endif %}>
</div>
<div class="form-group">
<label for="email">E-mail</label> <input type="text"
class="form-control" name="email" id="email"
placeholder="{{ current_user.email }}">
</div>
placeholder="{{ current_user.email }}" {% if external_account %}disabled{% endif %}>
</div>{% if not external_account %}
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
</div>
</div>{% endif %}
</form>
</div>
<div class="tab-pane" id="tabs-avatar">
@ -69,25 +69,25 @@
else %} <img
src="{{ current_user.email|email_to_gravatar_url(size=200) }}"
alt="" /> {% endif %}
</div>
</div>{% if not external_account %}
<div>
<label for="file">Select image</label> <input type="file"
id="file" name="file">
</div>
</div>
</div>{% endif %}
</div>{% if not external_account %}
<div>
<span class="label label-danger">NOTE! </span> <span>&nbsp;Only
supports <strong>.PNG, .JPG, .JPEG</strong>. The best size
to use is <strong>200x200</strong>.
</span>
</div>
</div>
</div>{% endif %}
</div>{% if not external_account %}
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
</div>
</div>{% endif %}
</form>
</div>
<div class="tab-pane" id="tabs-password">
{% if not external_account %}<div class="tab-pane" id="tabs-password">
{% if not current_user.password %} Your account password is
managed via LDAP which isn't supported to change here. {% else
%}
@ -95,15 +95,15 @@
<div class="form-group">
<label for="password">New Password</label> <input
type="password" class="form-control" name="password"
id="newpassword" />
id="newpassword" {% if external_account %}disabled{% endif %} />
</div>
<div class="form-group">
<label for="rpassword">Re-type New Password</label> <input
type="password" class="form-control" name="rpassword"
id="rpassword" />
id="rpassword" {% if external_account %}disabled{% endif %} />
</div>
<div class="form-group">
<button type="submit" class="btn btn-flat btn-primary">Change
<button type="submit" class="btn btn-flat btn-primary" {% if external_account %}disabled{% endif %}>Change
password</button>
</div>
</form>
@ -112,7 +112,7 @@
<div class="tab-pane" id="tabs-authentication">
<form action="{{ user_profile }}" method="post">
<div class="form-group">
<input type="checkbox" id="otp_toggle" class="otp_toggle" {% if current_user.otp_secret %}checked{% endif %}>
<input type="checkbox" id="otp_toggle" class="otp_toggle" {% if current_user.otp_secret %}checked{% endif %} {% if external_account %}disabled{% endif %}>
<label for="otp_toggle">Enable Two Factor Authentication</label>
{% if current_user.otp_secret %}
<div id="token_information">
@ -124,7 +124,7 @@
{% endif %}
</div>
</form>
</div>
</div>{% endif %}
</div>
</div>
</div>

View File

@ -20,6 +20,8 @@ from .models import User, Domain, Record, Server, History, Anonymous, Setting, D
from app import app, login_manager, github, google
from lib import utils
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.utils import OneLogin_Saml2_Utils
jinja2.filters.FILTERS['display_record_name'] = utils.display_record_name
jinja2.filters.FILTERS['display_master_name'] = utils.display_master_name
@ -174,6 +176,71 @@ def github_login():
return abort(400)
return github.authorize(callback=url_for('authorized', _external=True))
@app.route('/saml/login')
def saml_login():
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
redirect_url=OneLogin_Saml2_Utils.get_self_url(req) + url_for('saml_authorized')
return redirect(auth.login(return_to=redirect_url))
@app.route('/saml/metadata')
def saml_metadata():
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
settings = auth.get_settings()
metadata = settings.get_sp_metadata()
errors = settings.validate_metadata(metadata)
if len(errors) == 0:
resp = make_response(metadata, 200)
resp.headers['Content-Type'] = 'text/xml'
else:
resp = make_response(errors.join(', '), 500)
return resp
@app.route('/saml/authorized', methods=['GET', 'POST'])
def saml_authorized():
errors = []
if not app.config.get('SAML_ENABLED'):
return abort(400)
req = utils.prepare_flask_request(request)
auth = utils.init_saml_auth(req)
auth.process_response()
errors = auth.get_errors()
if len(errors) == 0:
session['samlUserdata'] = auth.get_attributes()
session['samlNameId'] = auth.get_nameid()
session['samlSessionIndex'] = auth.get_session_index()
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']:
return redirect(auth.redirect_to(request.form['RelayState']))
user = User.query.filter_by(username=session['samlNameId'].lower()).first()
if not user:
# create user
user = User(username=session['samlNameId'],
plain_text_password=gen_salt(30),
email=session['samlNameId'])
user.create_local_user()
session['user_id'] = user.id
if session['samlUserdata'].has_key("email"):
user.email = session['samlUserdata']["email"][0].lower()
if session['samlUserdata'].has_key("givenname"):
user.firstname = session['samlUserdata']["givenname"][0]
if session['samlUserdata'].has_key("surname"):
user.lastname = session['samlUserdata']["surname"][0]
user.plain_text_password = gen_salt(30)
user.update_profile()
session['external_auth'] = True
login_user(user, remember=False)
return redirect(url_for('index'))
else:
return error(401,"an error occourred processing SAML response")
@app.route('/login', methods=['GET', 'POST'])
@login_manager.unauthorized_handler
def login():
@ -184,6 +251,7 @@ def login():
SIGNUP_ENABLED = app.config['SIGNUP_ENABLED']
GITHUB_ENABLE = app.config.get('GITHUB_OAUTH_ENABLE')
GOOGLE_ENABLE = app.config.get('GOOGLE_OAUTH_ENABLE')
SAML_ENABLED = app.config.get('SAML_ENABLED')
if g.user is not None and current_user.is_authenticated:
return redirect(url_for('dashboard'))
@ -214,11 +282,12 @@ def login():
if not user:
# create user
user = User(username=user_info['name'],
plain_text_password=gen_salt(7),
plain_text_password=gen_salt(30),
email=user_info['email'])
user.create_local_user()
session['user_id'] = user.id
session['external_auth'] = True
login_user(user, remember = False)
return redirect(url_for('index'))
@ -226,6 +295,7 @@ def login():
return render_template('login.html',
github_enabled=GITHUB_ENABLE,
google_enabled=GOOGLE_ENABLE,
saml_enabled=SAML_ENABLED,
ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
@ -252,19 +322,38 @@ def login():
try:
auth = user.is_validate(method=auth_method)
if auth == False:
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
except Exception, e:
error = e.message['desc'] if 'desc' in e.message else e
return render_template('login.html', error=error, ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error=error, ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
# check if user enabled OPT authentication
if user.otp_secret:
if otp_token:
good_token = user.verify_totp(otp_token)
if not good_token:
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Invalid credentials', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,
saml_enabled=SAML_ENABLED)
else:
return render_template('login.html', error='Token required', ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', error='Token required', ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE,
basic_enabled=BASIC_ENABLED,
signup_enabled=SIGNUP_ENABLED,
github_enabled = GITHUB_ENABLE,
saml_enabled = SAML_ENABLED)
login_user(user, remember = remember_me)
return redirect(request.args.get('next') or url_for('index'))
@ -281,7 +370,9 @@ def login():
try:
result = user.create_local_user()
if result == True:
return render_template('login.html', username=username, password=password, ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
return render_template('login.html', username=username, password=password, ldap_enabled=LDAP_ENABLED,
login_title=LOGIN_TITLE, basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED,
github_enabled=GITHUB_ENABLE,saml_enabled=SAML_ENABLED)
else:
return render_template('register.html', error=result)
except Exception, e:
@ -293,6 +384,7 @@ def logout():
session.pop('user_id', None)
session.pop('github_token', None)
session.pop('google_token', None)
session.clear()
logout_user()
return redirect(url_for('login'))
@ -326,36 +418,39 @@ def dashboard():
def domain(domain_name):
r = Record()
domain = Domain.query.filter(Domain.name == domain_name).first()
if domain:
# query domain info from PowerDNS API
zone_info = r.get_record_data(domain.name)
if zone_info:
jrecords = zone_info['records']
else:
# can not get any record, API server might be down
return redirect(url_for('error', code=500))
records = []
#TODO: This should be done in the "model" instead of "view"
if NEW_SCHEMA:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
for subrecord in jr['records']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if subrecord['disabled'] else 'Active', ttl=jr['ttl'], data=subrecord['content'])
records.append(record)
else:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if jr['disabled'] else 'Active', ttl=jr['ttl'], data=jr['content'])
records.append(record)
if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name):
editable_records = app.config['RECORDS_ALLOW_EDIT']
else:
editable_records = ['PTR']
return render_template('domain.html', domain=domain, records=records, editable_records=editable_records)
else:
if not domain:
return redirect(url_for('error', code=404))
if not current_user.can_access_domain(domain_name):
abort(403)
# query domain info from PowerDNS API
zone_info = r.get_record_data(domain.name)
if zone_info:
jrecords = zone_info['records']
else:
# can not get any record, API server might be down
return redirect(url_for('error', code=500))
records = []
#TODO: This should be done in the "model" instead of "view"
if NEW_SCHEMA:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
for subrecord in jr['records']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if subrecord['disabled'] else 'Active', ttl=jr['ttl'], data=subrecord['content'])
records.append(record)
else:
for jr in jrecords:
if jr['type'] in app.config['RECORDS_ALLOW_EDIT']:
record = Record(name=jr['name'], type=jr['type'], status='Disabled' if jr['disabled'] else 'Active', ttl=jr['ttl'], data=jr['content'])
records.append(record)
if not re.search('ip6\.arpa|in-addr\.arpa$', domain_name):
editable_records = app.config['RECORDS_ALLOW_EDIT']
else:
editable_records = ['PTR']
return render_template('domain.html', domain=domain, records=records, editable_records=editable_records)
@app.route('/admin/domain/add', methods=['GET', 'POST'])
@login_required
@ -446,6 +541,10 @@ def record_apply(domain_name):
example jdata: {u'record_ttl': u'1800', u'record_type': u'CNAME', u'record_name': u'test4', u'record_status': u'Active', u'record_data': u'duykhanh.me'}
"""
#TODO: filter removed records / name modified records.
if not current_user.can_access_domain(domain_name):
return make_response(jsonify({'status': 'error', 'msg': 'You do not have access to that domain'}), 403)
try:
pdata = request.data
jdata = json.loads(pdata)
@ -470,6 +569,10 @@ def record_update(domain_name):
This route is used for domain work as Slave Zone only
Pulling the records update from its Master
"""
if not current_user.can_access_domain(domain_name):
return make_response(jsonify({'status': 'error', 'msg': 'You do not have access to that domain'}), 403)
try:
pdata = request.data
jdata = json.loads(pdata)
@ -504,6 +607,9 @@ def record_delete(domain_name, record_name, record_type):
@app.route('/domain/<string:domain_name>/dnssec', methods=['GET'])
@login_required
def domain_dnssec(domain_name):
if not current_user.can_access_domain(domain_name):
return make_response(jsonify({'status': 'error', 'msg': 'You do not have access to that domain'}), 403)
domain = Domain()
dnssec = domain.get_domain_dnssec(domain_name)
return make_response(jsonify(dnssec), 200)
@ -701,8 +807,11 @@ def admin_settings_edit(setting):
@app.route('/user/profile', methods=['GET', 'POST'])
@login_required
def user_profile():
if request.method == 'GET':
return render_template('user_profile.html')
external_account = False
if session.has_key('external_auth'):
external_account = session['external_auth']
if request.method == 'GET' or external_account:
return render_template('user_profile.html', external_account=external_account)
if request.method == 'POST':
# get new profile info
firstname = request.form['firstname'] if 'firstname' in request.form else ''
@ -737,7 +846,7 @@ def user_profile():
user = User(username=current_user.username, plain_text_password=new_password, firstname=firstname, lastname=lastname, email=email, avatar=save_file_name, reload_info=False)
user.update_profile()
return render_template('user_profile.html')
return render_template('user_profile.html', external_account=external_account)
@app.route('/user/avatar/<string:filename>')