mirror of
https://github.com/cwinfo/powerdns-admin.git
synced 2024-11-08 22:50:26 +00:00
Merge branch 'master' into feature-google-oauth
This commit is contained in:
commit
83a0396350
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,6 +23,9 @@ nosetests.xml
|
|||||||
flask
|
flask
|
||||||
config.py
|
config.py
|
||||||
logfile.log
|
logfile.log
|
||||||
|
settings.json
|
||||||
|
advanced_settings.json
|
||||||
|
idp.crt
|
||||||
|
|
||||||
db_repository/*
|
db_repository/*
|
||||||
upload/avatar/*
|
upload/avatar/*
|
||||||
|
42
README.md
42
README.md
@ -5,6 +5,7 @@ PowerDNS Web-GUI - Built by Flask
|
|||||||
- Multiple domain management
|
- Multiple domain management
|
||||||
- Local / LDAP user authentication
|
- Local / LDAP user authentication
|
||||||
- Support Two-factor authentication (TOTP)
|
- Support Two-factor authentication (TOTP)
|
||||||
|
- Support SAML authentication
|
||||||
- User management
|
- User management
|
||||||
- User access management based on domain
|
- User access management based on domain
|
||||||
- User activity logging
|
- User activity logging
|
||||||
@ -84,6 +85,47 @@ Run the application and enjoy!
|
|||||||
(flask)$ ./run.py
|
(flask)$ ./run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SAML Authentication
|
||||||
|
SAML authentication is supported. In order to use it you have to create your own settings.json and advanced_settings.json based on the templates.
|
||||||
|
Following Assertions are supported and used by this application:
|
||||||
|
- nameidentifier in form of email address as user login
|
||||||
|
- email used as user email address
|
||||||
|
- givenname used as firstname
|
||||||
|
- surname used as lastname
|
||||||
|
|
||||||
|
### ADFS claim rules as example
|
||||||
|
Microsoft Active Directory Federation Services can be used as Identity Provider for SAML login.
|
||||||
|
The Following rules should be configured to send all attribute information to PowerDNS-Admin.
|
||||||
|
The nameidentifier should be something stable from the idp side. All other attributes are update when singing in.
|
||||||
|
|
||||||
|
#### sending the nameidentifier
|
||||||
|
Name-Identifiers Type is "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"
|
||||||
|
```
|
||||||
|
c:[Type == "<here goes your source claim>"]
|
||||||
|
=> issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### sending the firstname
|
||||||
|
Name-Identifiers Type is "givenname"
|
||||||
|
```
|
||||||
|
c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"]
|
||||||
|
=> issue(Type = "givenname", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:1.1:nameid-format:transient");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### sending the lastname
|
||||||
|
Name-Identifiers Type is "surname"
|
||||||
|
```
|
||||||
|
c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"]
|
||||||
|
=> issue(Type = "surname", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:1.1:nameid-format:transient");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### sending the email
|
||||||
|
Name-Identifiers Type is "email"
|
||||||
|
```
|
||||||
|
c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]
|
||||||
|
=> issue(Type = "email", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
|
||||||
|
```
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
![login page](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/images/readme_screenshots/fullscreen-login.png?raw=true)
|
![login page](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/images/readme_screenshots/fullscreen-login.png?raw=true)
|
||||||
![dashboard](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/images/readme_screenshots/fullscreen-dashboard.png?raw=true)
|
![dashboard](https://github.com/ngoduykhanh/PowerDNS-Admin/wiki/images/readme_screenshots/fullscreen-dashboard.png?raw=true)
|
||||||
|
104
app/lib/utils.py
104
app/lib/utils.py
@ -7,6 +7,39 @@ import hashlib
|
|||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from distutils.version import StrictVersion
|
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():
|
if 'TIMEOUT' in app.config.keys():
|
||||||
TIMEOUT = app.config['TIMEOUT']
|
TIMEOUT = app.config['TIMEOUT']
|
||||||
@ -159,3 +192,74 @@ def email_to_gravatar_url(email, size=100):
|
|||||||
|
|
||||||
hash_string = hashlib.md5(email).hexdigest()
|
hash_string = hashlib.md5(email).hexdigest()
|
||||||
return "https://s.gravatar.com/avatar/%s?s=%s" % (hash_string, size)
|
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
|
@ -314,6 +314,13 @@ class User(db.Model):
|
|||||||
user_domains.append(q[2])
|
user_domains.append(q[2])
|
||||||
return user_domains
|
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):
|
def delete(self):
|
||||||
"""
|
"""
|
||||||
Delete a user
|
Delete a user
|
||||||
|
@ -167,7 +167,7 @@ json_library = {
|
|||||||
return r + (pEnd || '');
|
return r + (pEnd || '');
|
||||||
},
|
},
|
||||||
prettyPrint: function(obj) {
|
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 jsonData = JSON.parse(obj);
|
||||||
var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg;
|
var jsonLine = /^( *)("[\w]+": )?("[^"]*"|[\w.+-]*)?([,[{])?$/mg;
|
||||||
return JSON.stringify(jsonData, null, 3)
|
return JSON.stringify(jsonData, null, 3)
|
||||||
@ -175,4 +175,4 @@ json_library = {
|
|||||||
.replace(/</g, '<').replace(/>/g, '>')
|
.replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(jsonLine, json_library.replacer);
|
.replace(jsonLine, json_library.replacer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -324,6 +324,7 @@
|
|||||||
record_data.val(data);
|
record_data.val(data);
|
||||||
modal.modal('hide');
|
modal.modal('hide');
|
||||||
})
|
})
|
||||||
|
modal.modal('show');
|
||||||
} else if (record_type == "SOA") {
|
} else if (record_type == "SOA") {
|
||||||
var modal = $("#modal_custom_record");
|
var modal = $("#modal_custom_record");
|
||||||
if (record_data.val() == "") {
|
if (record_data.val() == "") {
|
||||||
|
@ -101,11 +101,16 @@
|
|||||||
{% if google_enabled %}
|
{% if google_enabled %}
|
||||||
<a href="{{ url_for('google_login') }}">Google oauth login</a>
|
<a href="{{ url_for('google_login') }}">Google oauth login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if saml_enabled %}
|
||||||
|
<br>
|
||||||
|
<a href="{{ url_for('saml_login') }}">SAML login</a>
|
||||||
|
{% endif %}
|
||||||
{% if github_enabled %}
|
{% if github_enabled %}
|
||||||
|
<br>
|
||||||
<a href="{{ url_for('github_login') }}">Github oauth login</a>
|
<a href="{{ url_for('github_login') }}">Github oauth login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br>
|
|
||||||
{% if signup_enabled %}
|
{% if signup_enabled %}
|
||||||
|
<br>
|
||||||
<a href="{{ url_for('register') }}" class="text-center">Create an account </a>
|
<a href="{{ url_for('register') }}" class="text-center">Create an account </a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
<div class="box box-primary">
|
<div class="box box-primary">
|
||||||
<div class="box-header with-border">
|
<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>
|
||||||
<div class="box-body">
|
<div class="box-body">
|
||||||
<!-- Custom Tabs -->
|
<!-- Custom Tabs -->
|
||||||
@ -29,10 +29,10 @@
|
|||||||
Info</a></li>
|
Info</a></li>
|
||||||
<li><a href="#tabs-avatar" data-toggle="tab">Change
|
<li><a href="#tabs-avatar" data-toggle="tab">Change
|
||||||
Avatar</a></li>
|
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>
|
Password</a></li>
|
||||||
<li><a href="#tabs-authentication" data-toggle="tab">Authentication
|
<li><a href="#tabs-authentication" data-toggle="tab">Authentication
|
||||||
</a></li>
|
</a></li>{% endif %}>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="tab-pane active" id="tabs-personal">
|
<div class="tab-pane active" id="tabs-personal">
|
||||||
@ -40,21 +40,21 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="firstname">First Name</label> <input type="text"
|
<label for="firstname">First Name</label> <input type="text"
|
||||||
class="form-control" name="firstname" id="firstname"
|
class="form-control" name="firstname" id="firstname"
|
||||||
placeholder="{{ current_user.firstname }}">
|
placeholder="{{ current_user.firstname }}" {% if external_account %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="lastname">Last Name</label> <input type="text"
|
<label for="lastname">Last Name</label> <input type="text"
|
||||||
class="form-control" name="lastname" id="lastname"
|
class="form-control" name="lastname" id="lastname"
|
||||||
placeholder="{{ current_user.lastname }}">
|
placeholder="{{ current_user.lastname }}" {% if external_account %}disabled{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">E-mail</label> <input type="text"
|
<label for="email">E-mail</label> <input type="text"
|
||||||
class="form-control" name="email" id="email"
|
class="form-control" name="email" id="email"
|
||||||
placeholder="{{ current_user.email }}">
|
placeholder="{{ current_user.email }}" {% if external_account %}disabled{% endif %}>
|
||||||
</div>
|
</div>{% if not external_account %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
|
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
|
||||||
</div>
|
</div>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="tabs-avatar">
|
<div class="tab-pane" id="tabs-avatar">
|
||||||
@ -69,25 +69,25 @@
|
|||||||
else %} <img
|
else %} <img
|
||||||
src="{{ current_user.email|email_to_gravatar_url(size=200) }}"
|
src="{{ current_user.email|email_to_gravatar_url(size=200) }}"
|
||||||
alt="" /> {% endif %}
|
alt="" /> {% endif %}
|
||||||
</div>
|
</div>{% if not external_account %}
|
||||||
<div>
|
<div>
|
||||||
<label for="file">Select image</label> <input type="file"
|
<label for="file">Select image</label> <input type="file"
|
||||||
id="file" name="file">
|
id="file" name="file">
|
||||||
</div>
|
</div>{% endif %}
|
||||||
</div>
|
</div>{% if not external_account %}
|
||||||
<div>
|
<div>
|
||||||
<span class="label label-danger">NOTE! </span> <span> Only
|
<span class="label label-danger">NOTE! </span> <span> Only
|
||||||
supports <strong>.PNG, .JPG, .JPEG</strong>. The best size
|
supports <strong>.PNG, .JPG, .JPEG</strong>. The best size
|
||||||
to use is <strong>200x200</strong>.
|
to use is <strong>200x200</strong>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>{% endif %}
|
||||||
</div>
|
</div>{% if not external_account %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
|
<button type="submit" class="btn btn-flat btn-primary">Submit</button>
|
||||||
</div>
|
</div>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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
|
{% if not current_user.password %} Your account password is
|
||||||
managed via LDAP which isn't supported to change here. {% else
|
managed via LDAP which isn't supported to change here. {% else
|
||||||
%}
|
%}
|
||||||
@ -95,15 +95,15 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">New Password</label> <input
|
<label for="password">New Password</label> <input
|
||||||
type="password" class="form-control" name="password"
|
type="password" class="form-control" name="password"
|
||||||
id="newpassword" />
|
id="newpassword" {% if external_account %}disabled{% endif %} />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rpassword">Re-type New Password</label> <input
|
<label for="rpassword">Re-type New Password</label> <input
|
||||||
type="password" class="form-control" name="rpassword"
|
type="password" class="form-control" name="rpassword"
|
||||||
id="rpassword" />
|
id="rpassword" {% if external_account %}disabled{% endif %} />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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>
|
password</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -112,7 +112,7 @@
|
|||||||
<div class="tab-pane" id="tabs-authentication">
|
<div class="tab-pane" id="tabs-authentication">
|
||||||
<form action="{{ user_profile }}" method="post">
|
<form action="{{ user_profile }}" method="post">
|
||||||
<div class="form-group">
|
<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>
|
<label for="otp_toggle">Enable Two Factor Authentication</label>
|
||||||
{% if current_user.otp_secret %}
|
{% if current_user.otp_secret %}
|
||||||
<div id="token_information">
|
<div id="token_information">
|
||||||
@ -124,7 +124,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
183
app/views.py
183
app/views.py
@ -20,6 +20,8 @@ from .models import User, Domain, Record, Server, History, Anonymous, Setting, D
|
|||||||
from app import app, login_manager, github, google
|
from app import app, login_manager, github, google
|
||||||
from lib import utils
|
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_record_name'] = utils.display_record_name
|
||||||
jinja2.filters.FILTERS['display_master_name'] = utils.display_master_name
|
jinja2.filters.FILTERS['display_master_name'] = utils.display_master_name
|
||||||
@ -174,6 +176,71 @@ def github_login():
|
|||||||
return abort(400)
|
return abort(400)
|
||||||
return github.authorize(callback=url_for('authorized', _external=True))
|
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'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
@login_manager.unauthorized_handler
|
@login_manager.unauthorized_handler
|
||||||
def login():
|
def login():
|
||||||
@ -184,6 +251,7 @@ def login():
|
|||||||
SIGNUP_ENABLED = app.config['SIGNUP_ENABLED']
|
SIGNUP_ENABLED = app.config['SIGNUP_ENABLED']
|
||||||
GITHUB_ENABLE = app.config.get('GITHUB_OAUTH_ENABLE')
|
GITHUB_ENABLE = app.config.get('GITHUB_OAUTH_ENABLE')
|
||||||
GOOGLE_ENABLE = app.config.get('GOOGLE_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:
|
if g.user is not None and current_user.is_authenticated:
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
@ -214,11 +282,12 @@ def login():
|
|||||||
if not user:
|
if not user:
|
||||||
# create user
|
# create user
|
||||||
user = User(username=user_info['name'],
|
user = User(username=user_info['name'],
|
||||||
plain_text_password=gen_salt(7),
|
plain_text_password=gen_salt(30),
|
||||||
email=user_info['email'])
|
email=user_info['email'])
|
||||||
user.create_local_user()
|
user.create_local_user()
|
||||||
|
|
||||||
session['user_id'] = user.id
|
session['user_id'] = user.id
|
||||||
|
session['external_auth'] = True
|
||||||
login_user(user, remember = False)
|
login_user(user, remember = False)
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
@ -226,6 +295,7 @@ def login():
|
|||||||
return render_template('login.html',
|
return render_template('login.html',
|
||||||
github_enabled=GITHUB_ENABLE,
|
github_enabled=GITHUB_ENABLE,
|
||||||
google_enabled=GOOGLE_ENABLE,
|
google_enabled=GOOGLE_ENABLE,
|
||||||
|
saml_enabled=SAML_ENABLED,
|
||||||
ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
|
ldap_enabled=LDAP_ENABLED, login_title=LOGIN_TITLE,
|
||||||
basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
|
basic_enabled=BASIC_ENABLED, signup_enabled=SIGNUP_ENABLED)
|
||||||
|
|
||||||
@ -252,19 +322,38 @@ def login():
|
|||||||
try:
|
try:
|
||||||
auth = user.is_validate(method=auth_method)
|
auth = user.is_validate(method=auth_method)
|
||||||
if auth == False:
|
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:
|
except Exception, e:
|
||||||
error = e.message['desc'] if 'desc' in e.message else 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
|
# check if user enabled OPT authentication
|
||||||
if user.otp_secret:
|
if user.otp_secret:
|
||||||
if otp_token:
|
if otp_token:
|
||||||
good_token = user.verify_totp(otp_token)
|
good_token = user.verify_totp(otp_token)
|
||||||
if not good_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:
|
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)
|
login_user(user, remember = remember_me)
|
||||||
return redirect(request.args.get('next') or url_for('index'))
|
return redirect(request.args.get('next') or url_for('index'))
|
||||||
@ -281,7 +370,9 @@ def login():
|
|||||||
try:
|
try:
|
||||||
result = user.create_local_user()
|
result = user.create_local_user()
|
||||||
if result == True:
|
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:
|
else:
|
||||||
return render_template('register.html', error=result)
|
return render_template('register.html', error=result)
|
||||||
except Exception, e:
|
except Exception, e:
|
||||||
@ -293,6 +384,7 @@ def logout():
|
|||||||
session.pop('user_id', None)
|
session.pop('user_id', None)
|
||||||
session.pop('github_token', None)
|
session.pop('github_token', None)
|
||||||
session.pop('google_token', None)
|
session.pop('google_token', None)
|
||||||
|
session.clear()
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
@ -326,36 +418,39 @@ def dashboard():
|
|||||||
def domain(domain_name):
|
def domain(domain_name):
|
||||||
r = Record()
|
r = Record()
|
||||||
domain = Domain.query.filter(Domain.name == domain_name).first()
|
domain = Domain.query.filter(Domain.name == domain_name).first()
|
||||||
if domain:
|
if not 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:
|
|
||||||
return redirect(url_for('error', code=404))
|
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'])
|
@app.route('/admin/domain/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@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'}
|
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.
|
#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:
|
try:
|
||||||
pdata = request.data
|
pdata = request.data
|
||||||
jdata = json.loads(pdata)
|
jdata = json.loads(pdata)
|
||||||
@ -470,6 +569,10 @@ def record_update(domain_name):
|
|||||||
This route is used for domain work as Slave Zone only
|
This route is used for domain work as Slave Zone only
|
||||||
Pulling the records update from its Master
|
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:
|
try:
|
||||||
pdata = request.data
|
pdata = request.data
|
||||||
jdata = json.loads(pdata)
|
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'])
|
@app.route('/domain/<string:domain_name>/dnssec', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def domain_dnssec(domain_name):
|
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()
|
domain = Domain()
|
||||||
dnssec = domain.get_domain_dnssec(domain_name)
|
dnssec = domain.get_domain_dnssec(domain_name)
|
||||||
return make_response(jsonify(dnssec), 200)
|
return make_response(jsonify(dnssec), 200)
|
||||||
@ -701,8 +807,11 @@ def admin_settings_edit(setting):
|
|||||||
@app.route('/user/profile', methods=['GET', 'POST'])
|
@app.route('/user/profile', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def user_profile():
|
def user_profile():
|
||||||
if request.method == 'GET':
|
external_account = False
|
||||||
return render_template('user_profile.html')
|
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':
|
if request.method == 'POST':
|
||||||
# get new profile info
|
# get new profile info
|
||||||
firstname = request.form['firstname'] if 'firstname' in request.form else ''
|
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 = 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()
|
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>')
|
@app.route('/user/avatar/<string:filename>')
|
||||||
|
@ -65,6 +65,7 @@ GITHUB_OAUTH_URL = 'http://127.0.0.1:5000/api/v3/'
|
|||||||
GITHUB_OAUTH_TOKEN = 'http://127.0.0.1:5000/oauth/token'
|
GITHUB_OAUTH_TOKEN = 'http://127.0.0.1:5000/oauth/token'
|
||||||
GITHUB_OAUTH_AUTHORIZE = 'http://127.0.0.1:5000/oauth/authorize'
|
GITHUB_OAUTH_AUTHORIZE = 'http://127.0.0.1:5000/oauth/authorize'
|
||||||
|
|
||||||
|
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
GOOGLE_OAUTH_ENABLE = False
|
GOOGLE_OAUTH_ENABLE = False
|
||||||
GOOGLE_OAUTH_CLIENT_ID = ' '
|
GOOGLE_OAUTH_CLIENT_ID = ' '
|
||||||
@ -77,6 +78,18 @@ GOOGLE_TOKEN_PARAMS = {
|
|||||||
GOOGLE_AUTHORIZE_URL='https://accounts.google.com/o/oauth2/auth'
|
GOOGLE_AUTHORIZE_URL='https://accounts.google.com/o/oauth2/auth'
|
||||||
GOOGLE_BASE_URL='https://www.googleapis.com/oauth2/v1/'
|
GOOGLE_BASE_URL='https://www.googleapis.com/oauth2/v1/'
|
||||||
|
|
||||||
|
# SAML Authnetication
|
||||||
|
SAML_ENABLED = False
|
||||||
|
SAML_DEBUG = True
|
||||||
|
SAML_PATH = os.path.join(os.path.dirname(__file__), 'saml')
|
||||||
|
##Example for ADFS Metadata-URL
|
||||||
|
SAML_METADATA_URL = 'https://<hostname>/FederationMetadata/2007-06/FederationMetadata.xml'
|
||||||
|
#Cache Lifetime in Seconds
|
||||||
|
SAML_METADATA_CACHE_LIFETIME = 1
|
||||||
|
SAML_SP_ENTITY_ID = 'http://<SAML SP Entity ID>'
|
||||||
|
SAML_SP_CONTACT_NAME = '<contact name>'
|
||||||
|
SAML_SP_CONTACT_MAIL = '<contact mail>'
|
||||||
|
|
||||||
#Default Auth
|
#Default Auth
|
||||||
BASIC_ENABLED = True
|
BASIC_ENABLED = True
|
||||||
SIGNUP_ENABLED = True
|
SIGNUP_ENABLED = True
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
Flask>=0.10
|
Flask==0.12.2
|
||||||
Flask-WTF>=0.11
|
Flask-WTF==0.14.2
|
||||||
Flask-Login>=0.2.11
|
Flask-Login==0.4.0
|
||||||
configobj==5.0.5
|
configobj==5.0.5
|
||||||
bcrypt==3.1.0
|
bcrypt==3.1.0
|
||||||
requests==2.7.0
|
requests==2.7.0
|
||||||
@ -12,3 +12,4 @@ pyotp==2.2.1
|
|||||||
qrcode==5.3
|
qrcode==5.3
|
||||||
Flask-OAuthlib==0.9.3
|
Flask-OAuthlib==0.9.3
|
||||||
dnspython>=1.12.0
|
dnspython>=1.12.0
|
||||||
|
python-saml==2.3.0
|
Loading…
Reference in New Issue
Block a user