Merge branch 'master' into password-policy

This commit is contained in:
ManosKoukoularis 2021-12-17 22:37:35 +02:00 committed by GitHub
commit 8badb3e578
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1197 additions and 2290 deletions

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
github: [ngoduykhanh]

View file

@ -58,7 +58,3 @@ You can then access PowerDNS-Admin by pointing your browser to http://localhost:
## LICENSE ## LICENSE
MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE) MIT. See [LICENSE](https://github.com/ngoduykhanh/PowerDNS-Admin/blob/master/LICENSE)
## Support
If you like the project and want to support it, you can *buy me a coffee*
<a href="https://www.buymeacoffee.com/khanhngo" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>

View file

@ -1,105 +1,134 @@
### API Usage ### API Usage
#### Getting started with docker
1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification 1. Run docker image docker-compose up, go to UI http://localhost:9191, at http://localhost:9191/swagger is swagger API specification
2. Click to register user, type e.g. user: admin and password: admin 2. Click to register user, type e.g. user: admin and password: admin
3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users 3. Login to UI in settings enable allow domain creation for users, now you can create and manage domains with admin account and also ordinary users
4. Encode your user and password to base64, in our example we have user admin and password admin so in linux cmd line we type: 4. Click on the API Keys menu then click on teh "Add Key" button to add a new Administrator Key
5. Keep the base64 encoded apikey somewhere safe as it won't be available in clear anymore
```
#### Accessing the API
The PDA API consists of two distinct parts:
- The /powerdnsadmin endpoints manages PDA content (accounts, users, apikeys) and also allow domain creation/deletion
- The /server endpoints are proxying queries to the backend PowerDNS instance's API. PDA acts as a proxy managing several API Keys and permissions to the PowerDNS content.
The requests to the API needs two headers:
- The classic 'Content-Type: application/json' is required to all POST and PUT requests, though it's armless to use it on each call
- The authentication header to provide either the login:password basic authentication or the Api Key authentication.
When you access the `/powerdnsadmin` endpoint, you must use the Basic Auth:
```bash
# Encode your user and password to base64
$ echo -n 'admin:admin'|base64 $ echo -n 'admin:admin'|base64
YWRtaW46YWRtaW4= YWRtaW46YWRtaW4=
# Use the ouput as your basic auth header
curl -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X <method> <url>
``` ```
we use generated output in basic authentication, we authenticate as user, When you access the `/server` endpoint, you must use the ApiKey
with basic authentication, we can create/delete/get zone and create/delete/get/update apikeys
creating domain:
```bash
# Use the already base64 encoded key in your header
curl -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' -X <method> <url>
``` ```
Finally, the `/sync_domains` endpoint accepts both basic and apikey authentication
#### Examples
Creating domain via `/powerdnsadmin`:
```bash
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}' curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/zones --data '{"name": "yourdomain.com.", "kind": "NATIVE", "nameservers": ["ns1.mydomain.com."]}'
``` ```
creating apikey which has Administrator role, apikey can have also User role, when creating such apikey you have to specify also domain for which apikey is valid: Creating an apikey which has the Administrator role:
``` ```bash
# Create the key
curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}' curl -L -vvv -H 'Content-Type: application/json' -H 'Authorization: Basic YWRtaW46YWRtaW4=' -X POST http://localhost:9191/api/v1/pdnsadmin/apikeys --data '{"description": "masterkey","domains":[], "role": "Administrator"}'
``` ```
Example response (don't forget to save the plain key from the output)
call above will return response like this: ```json
[
``` {
[{"description": "samekey", "domains": [], "role": {"name": "Administrator", "id": 1}, "id": 2, "plain_key": "aGCthP3KLAeyjZI"}] "accounts": [],
"description": "masterkey",
"domains": [],
"role": {
"name": "Administrator",
"id": 1
},
"id": 2,
"plain_key": "aGCthP3KLAeyjZI"
}
]
``` ```
we take plain_key and base64 encode it, this is the only time we can get API key in plain text and save it somewhere: We can use the apikey for all calls to PowerDNS (don't forget to specify Content-Type):
``` Getting powerdns configuration (Administrator Key is needed):
$ echo -n 'aGCthP3KLAeyjZI'|base64
YUdDdGhQM0tMQWV5alpJ
```
We can use apikey for all calls specified in our API specification (it tries to follow powerdns API 1:1, only tsigkeys endpoints are not yet implemented), don't forget to specify Content-Type! ```bash
getting powerdns configuration:
```
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/config
``` ```
creating and updating records: Creating and updating records:
``` ```bash
curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com. curl -X PATCH -H 'Content-Type: application/json' --data '{"rrsets": [{"name": "test1.yourdomain.com.","type": "A","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "192.0.2.5", "disabled": false} ]},{"name": "test2.yourdomain.com.","type": "AAAA","ttl": 86400,"changetype": "REPLACE","records": [ {"content": "2001:db8::6", "disabled": false} ]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://127.0.0.1:9191/api/v1/servers/localhost/zones/yourdomain.com.
``` ```
getting domain: Getting a domain:
``` ```bash
curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com curl -L -vvv -H 'Content-Type: application/json' -H 'X-API-KEY: YUdDdGhQM0tMQWV5alpJ' -X GET http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
``` ```
list zone records: List a zone's records:
``` ```bash
curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com curl -H 'Content-Type: application/json' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com
``` ```
add new record: Add a new record:
``` ```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.5.4", "disabled": false } ] } ] }' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
``` ```
update record: Update a record:
``` ```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq . curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "REPLACE", "records": [ {"content": "192.0.2.5", "disabled": false, "name": "test.yourdomain.com.", "ttl": 86400, "type": "A"}]}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq .
``` ```
delete record: Delete a record:
``` ```bash
curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq curl -H 'Content-Type: application/json' -X PATCH --data '{"rrsets": [ {"name": "test.yourdomain.com.", "type": "A", "ttl": 86400, "changetype": "DELETE"}]}' -H 'X-API-Key: YUdDdGhQM0tMQWV5alpJ' http://localhost:9191/api/v1/servers/localhost/zones/yourdomain.com | jq
``` ```
### Generate ER diagram ### Generate ER diagram
``` With docker
```bash
# Install build packages
apt-get install python-dev graphviz libgraphviz-dev pkg-config apt-get install python-dev graphviz libgraphviz-dev pkg-config
``` # Get the required python libraries
```
pip install graphviz mysqlclient ERAlchemy pip install graphviz mysqlclient ERAlchemy
``` # Start the docker container
```
docker-compose up -d docker-compose up -d
``` # Set environment variables
```
source .env source .env
``` # Generate the diagrams
```
eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf eralchemy -i 'mysql://${PDA_DB_USER}:${PDA_DB_PASSWORD}@'$(docker inspect powerdns-admin-mysql|jq -jr '.[0].NetworkSettings.Networks.powerdnsadmin_default.IPAddress')':3306/powerdns_admin' -o /tmp/output.pdf
``` ```

View file

@ -0,0 +1,41 @@
"""add apikey account mapping table
Revision ID: 0967658d9c0d
Revises: 0d3d93f1c2e0
Create Date: 2021-11-13 22:28:46.133474
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0967658d9c0d'
down_revision = '0d3d93f1c2e0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('apikey_account',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('apikey_id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['account.id'], ),
sa.ForeignKeyConstraint(['apikey_id'], ['apikey.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('history', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_history_created_on'), ['created_on'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('history', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_history_created_on'))
op.drop_table('apikey_account')
# ### end Alembic commands ###

View file

@ -55,6 +55,8 @@ def create_app(config=None):
csrf.exempt(routes.api.api_list_account_users) csrf.exempt(routes.api.api_list_account_users)
csrf.exempt(routes.api.api_add_account_user) csrf.exempt(routes.api.api_add_account_user)
csrf.exempt(routes.api.api_remove_account_user) csrf.exempt(routes.api.api_remove_account_user)
csrf.exempt(routes.api.api_zone_cryptokeys)
csrf.exempt(routes.api.api_zone_cryptokey)
# Load config from env variables if using docker # Load config from env variables if using docker
if os.path.exists(os.path.join(app.root_path, 'docker_config.py')): if os.path.exists(os.path.join(app.root_path, 'docker_config.py')):

View file

@ -23,6 +23,7 @@ css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css',
js_login = Bundle('node_modules/jquery/dist/jquery.js', js_login = Bundle('node_modules/jquery/dist/jquery.js',
'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/bootstrap/dist/js/bootstrap.js',
'node_modules/icheck/icheck.js', 'node_modules/icheck/icheck.js',
'custom/js/custom.js',
filters=(ConcatFilter, 'jsmin'), filters=(ConcatFilter, 'jsmin'),
output='generated/login.js') output='generated/login.js')

View file

@ -192,6 +192,24 @@ def is_json(f):
return decorated_function return decorated_function
def callback_if_request_body_contains_key(callback, http_methods=[], keys=[]):
"""
If request body contains one or more of specified keys, call
:param callback
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
check_current_http_method = not http_methods or request.method in http_methods
if (check_current_http_method and
set(request.get_json(force=True).keys()).intersection(set(keys))
):
callback(*args, **kwargs)
return f(*args, **kwargs)
return decorated_function
return decorator
def api_role_can(action, roles=None, allow_self=False): def api_role_can(action, roles=None, allow_self=False):
""" """
Grant access if: Grant access if:
@ -246,6 +264,48 @@ def api_can_create_domain(f):
return decorated_function return decorated_function
def apikey_can_create_domain(f):
"""
Grant access if:
- user is in Operator role or higher, or
- allow_user_create_domain is on
"""
@wraps(f)
def decorated_function(*args, **kwargs):
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"
current_app.logger.error(msg.format(g.apikey.id))
raise NotEnoughPrivileges()
return f(*args, **kwargs)
return decorated_function
def apikey_can_remove_domain(http_methods=[]):
"""
Grant access if:
- user is in Operator role or higher, or
- allow_user_remove_domain is on
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
check_current_http_method = not http_methods or request.method in http_methods
if (check_current_http_method and
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"
current_app.logger.error(msg.format(g.apikey.id))
raise NotEnoughPrivileges()
return f(*args, **kwargs)
return decorated_function
return decorator
def apikey_is_admin(f): def apikey_is_admin(f):
""" """
Grant access if user is in Administrator role Grant access if user is in Administrator role
@ -262,21 +322,52 @@ def apikey_is_admin(f):
def apikey_can_access_domain(f): def apikey_can_access_domain(f):
"""
Grant access if:
- user has Operator role or higher, or
- user has explicitly been granted access to domain
"""
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
apikey = g.apikey
if g.apikey.role.name not in ['Administrator', 'Operator']: if g.apikey.role.name not in ['Administrator', 'Operator']:
domains = apikey.domains
zone_id = kwargs.get('zone_id').rstrip(".") zone_id = kwargs.get('zone_id').rstrip(".")
domain_names = [item.name for item in domains] domain_names = [item.name for item in g.apikey.domains]
if zone_id not in domain_names: accounts = g.apikey.accounts
accounts_domains = [domain.name for a in accounts for domain in a.domains]
allowed_domains = set(domain_names + accounts_domains)
if zone_id not in allowed_domains:
raise DomainAccessForbidden() raise DomainAccessForbidden()
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def apikey_can_configure_dnssec(http_methods=[]):
"""
Grant access if:
- user is in Operator role or higher, or
- dnssec_admins_only is off
"""
def decorator(f=None):
@wraps(f)
def decorated_function(*args, **kwargs):
check_current_http_method = not http_methods or request.method in http_methods
if (check_current_http_method and
g.apikey.role.name not in ['Administrator', 'Operator'] and
Setting().get('dnssec_admins_only')
):
msg = "ApiKey #{0} does not have enough privileges to configure dnssec"
current_app.logger.error(msg.format(g.apikey.id))
raise DomainAccessForbidden(message=msg)
return f(*args, **kwargs) if f else None
return decorated_function
return decorator
def apikey_auth(f): def apikey_auth(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):

View file

@ -60,7 +60,8 @@ class ApiKeyNotUsable(StructuredException):
def __init__( def __init__(
self, self,
name=None, name=None,
message="Api key must have domains or have administrative role"): message=("Api key must have domains or accounts"
" or an administrative role")):
StructuredException.__init__(self) StructuredException.__init__(self)
self.message = message self.message = message
self.name = name self.name = name
@ -120,6 +121,15 @@ class AccountDeleteFail(StructuredException):
self.name = name self.name = name
class AccountNotExists(StructuredException):
status_code = 404
def __init__(self, name=None, message="Account does not exist"):
StructuredException.__init__(self)
self.message = message
self.name = name
class UserCreateFail(StructuredException): class UserCreateFail(StructuredException):
status_code = 500 status_code = 500

View file

@ -11,10 +11,21 @@ class RoleSchema(Schema):
name = fields.String() name = fields.String()
class AccountSummarySchema(Schema):
id = fields.Integer()
name = fields.String()
domains = fields.Embed(schema=DomainSchema, many=True)
class ApiKeySummarySchema(Schema):
id = fields.Integer()
description = fields.String()
class ApiKeySchema(Schema): class ApiKeySchema(Schema):
id = fields.Integer() id = fields.Integer()
role = fields.Embed(schema=RoleSchema) role = fields.Embed(schema=RoleSchema)
domains = fields.Embed(schema=DomainSchema, many=True) domains = fields.Embed(schema=DomainSchema, many=True)
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
description = fields.String() description = fields.String()
key = fields.String() key = fields.String()
@ -23,15 +34,11 @@ class ApiPlainKeySchema(Schema):
id = fields.Integer() id = fields.Integer()
role = fields.Embed(schema=RoleSchema) role = fields.Embed(schema=RoleSchema)
domains = fields.Embed(schema=DomainSchema, many=True) domains = fields.Embed(schema=DomainSchema, many=True)
accounts = fields.Embed(schema=AccountSummarySchema, many=True)
description = fields.String() description = fields.String()
plain_key = fields.String() plain_key = fields.String()
class AccountSummarySchema(Schema):
id = fields.Integer()
name = fields.String()
class UserSchema(Schema): class UserSchema(Schema):
id = fields.Integer() id = fields.Integer()
username = fields.String() username = fields.String()
@ -56,3 +63,4 @@ class AccountSchema(Schema):
contact = fields.String() contact = fields.String()
mail = fields.String() mail = fields.String()
domains = fields.Embed(schema=DomainSchema, many=True) domains = fields.Embed(schema=DomainSchema, many=True)
apikeys = fields.Embed(schema=ApiKeySummarySchema, many=True)

View file

@ -8,6 +8,7 @@ from .account_user import AccountUser
from .server import Server from .server import Server
from .history import History from .history import History
from .api_key import ApiKey from .api_key import ApiKey
from .api_key_account import ApiKeyAccount
from .setting import Setting from .setting import Setting
from .domain import Domain from .domain import Domain
from .domain_setting import DomainSetting from .domain_setting import DomainSetting

View file

@ -17,6 +17,9 @@ class Account(db.Model):
contact = db.Column(db.String(128)) contact = db.Column(db.String(128))
mail = db.Column(db.String(128)) mail = db.Column(db.String(128))
domains = db.relationship("Domain", back_populates="account") domains = db.relationship("Domain", back_populates="account")
apikeys = db.relationship("ApiKey",
secondary="apikey_account",
back_populates="accounts")
def __init__(self, name=None, description=None, contact=None, mail=None): def __init__(self, name=None, description=None, contact=None, mail=None):
self.name = name self.name = name

View file

@ -1,12 +1,12 @@
import random import secrets
import string import string
import bcrypt import bcrypt
from flask import current_app from flask import current_app
from .base import db, domain_apikey from .base import db
from ..models.role import Role from ..models.role import Role
from ..models.domain import Domain from ..models.domain import Domain
from ..models.account import Account
class ApiKey(db.Model): class ApiKey(db.Model):
__tablename__ = "apikey" __tablename__ = "apikey"
@ -16,17 +16,21 @@ class ApiKey(db.Model):
role_id = db.Column(db.Integer, db.ForeignKey('role.id')) role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
role = db.relationship('Role', back_populates="apikeys", lazy=True) role = db.relationship('Role', back_populates="apikeys", lazy=True)
domains = db.relationship("Domain", domains = db.relationship("Domain",
secondary=domain_apikey, secondary="domain_apikey",
back_populates="apikeys") back_populates="apikeys")
accounts = db.relationship("Account",
secondary="apikey_account",
back_populates="apikeys")
def __init__(self, key=None, desc=None, role_name=None, domains=[]): def __init__(self, key=None, desc=None, role_name=None, domains=[], accounts=[]):
self.id = None self.id = None
self.description = desc self.description = desc
self.role_name = role_name self.role_name = role_name
self.domains[:] = domains self.domains[:] = domains
self.accounts[:] = accounts
if not key: if not key:
rand_key = ''.join( rand_key = ''.join(
random.choice(string.ascii_letters + string.digits) secrets.choice(string.ascii_letters + string.digits)
for _ in range(15)) for _ in range(15))
self.plain_key = rand_key self.plain_key = rand_key
self.key = self.get_hashed_password(rand_key).decode('utf-8') self.key = self.get_hashed_password(rand_key).decode('utf-8')
@ -54,7 +58,7 @@ class ApiKey(db.Model):
db.session.rollback() db.session.rollback()
raise e raise e
def update(self, role_name=None, description=None, domains=None): def update(self, role_name=None, description=None, domains=None, accounts=None):
try: try:
if role_name: if role_name:
role = Role.query.filter(Role.name == role_name).first() role = Role.query.filter(Role.name == role_name).first()
@ -63,12 +67,18 @@ class ApiKey(db.Model):
if description: if description:
self.description = description self.description = description
if domains: if domains is not None:
domain_object_list = Domain.query \ domain_object_list = Domain.query \
.filter(Domain.name.in_(domains)) \ .filter(Domain.name.in_(domains)) \
.all() .all()
self.domains[:] = domain_object_list self.domains[:] = domain_object_list
if accounts is not None:
account_object_list = Account.query \
.filter(Account.name.in_(accounts)) \
.all()
self.accounts[:] = account_object_list
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
msg_str = 'Update of apikey failed. Error: {0}' msg_str = 'Update of apikey failed. Error: {0}'
@ -121,3 +131,12 @@ class ApiKey(db.Model):
raise Exception("Unauthorized") raise Exception("Unauthorized")
return apikey return apikey
def associate_account(self, account):
return True
def dissociate_account(self, account):
return True
def get_accounts(self):
return True

View file

@ -0,0 +1,20 @@
from .base import db
class ApiKeyAccount(db.Model):
__tablename__ = 'apikey_account'
id = db.Column(db.Integer, primary_key=True)
apikey_id = db.Column(db.Integer,
db.ForeignKey('apikey.id'),
nullable=False)
account_id = db.Column(db.Integer,
db.ForeignKey('account.id'),
nullable=False)
db.UniqueConstraint('apikey_id', 'account_id', name='uniq_apikey_account')
def __init__(self, apikey_id, account_id):
self.apikey_id = apikey_id
self.account_id = account_id
def __repr__(self):
return '<ApiKey_Account {0} {1}>'.format(self.apikey_id, self.account_id)

View file

@ -198,6 +198,8 @@ class Setting(db.Model):
'max_history_records': 1000, 'max_history_records': 1000,
'zxcvbn_enabled': False, 'zxcvbn_enabled': False,
'zxcvbn_guesses_log' : 11 'zxcvbn_guesses_log' : 11
'otp_force': False,
'max_history_records': 1000
} }
def __init__(self, id=None, name=None, value=None): def __init__(self, id=None, name=None, value=None):

View file

@ -8,6 +8,9 @@ import ldap.filter
from flask import current_app from flask import current_app
from flask_login import AnonymousUserMixin from flask_login import AnonymousUserMixin
from sqlalchemy import orm from sqlalchemy import orm
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from .base import db from .base import db
from .role import Role from .role import Role
@ -628,10 +631,18 @@ class User(db.Model):
Account)\ Account)\
.filter(self.id == AccountUser.user_id)\ .filter(self.id == AccountUser.user_id)\
.filter(Account.id == AccountUser.account_id)\ .filter(Account.id == AccountUser.account_id)\
.order_by(Account.name)\
.all() .all()
for q in query: for q in query:
accounts.append(q[1]) accounts.append(q[1])
return accounts return accounts
def get_qrcode_value(self):
img = qrc.make(self.get_totp_uri(),
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue()
def read_entitlements(self, key): def read_entitlements(self, key):
@ -793,7 +804,4 @@ def getUserInfo(DomainsOrAccounts):
current=[] current=[]
for DomainOrAccount in DomainsOrAccounts: for DomainOrAccount in DomainsOrAccounts:
current.append(DomainOrAccount.name) current.append(DomainOrAccount.name)
return current return current

View file

@ -4,7 +4,7 @@ import traceback
import re import re
from base64 import b64encode from base64 import b64encode
from ast import literal_eval from ast import literal_eval
from flask import Blueprint, render_template, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session from flask import Blueprint, render_template, render_template_string, make_response, url_for, current_app, request, redirect, jsonify, abort, flash, session
from flask_login import login_required, current_user from flask_login import login_required, current_user
from ..decorators import operator_role_required, admin_role_required, history_access_required from ..decorators import operator_role_required, admin_role_required, history_access_required
@ -40,7 +40,7 @@ old_state: dictionary with "disabled" and "content" keys. {"disabled" : False, "
new_state: similarly new_state: similarly
change_type: "addition" or "deletion" or "status" for status change or "unchanged" for no change change_type: "addition" or "deletion" or "status" for status change or "unchanged" for no change
Note: A change in "content", is considered a deletion and recreation of the same record, Note: A change in "content", is considered a deletion and recreation of the same record,
holding the new content value. holding the new content value.
""" """
def get_record_changes(del_rrest, add_rrest): def get_record_changes(del_rrest, add_rrest):
@ -57,12 +57,12 @@ def get_record_changes(del_rrest, add_rrest):
{"disabled":a['disabled'],"content":a['content']}, {"disabled":a['disabled'],"content":a['content']},
"status") ) "status") )
break break
if not exists: # deletion if not exists: # deletion
changeSet.append( ({"disabled":d['disabled'],"content":d['content']}, changeSet.append( ({"disabled":d['disabled'],"content":d['content']},
None, None,
"deletion") ) "deletion") )
for a in addSet: # get the additions for a in addSet: # get the additions
exists = False exists = False
for d in delSet: for d in delSet:
@ -78,7 +78,7 @@ def get_record_changes(del_rrest, add_rrest):
exists = False exists = False
for c in changeSet: for c in changeSet:
if c[1] != None and c[1]["content"] == a['content']: if c[1] != None and c[1]["content"] == a['content']:
exists = True exists = True
break break
if not exists: if not exists:
changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") ) changeSet.append( ( {"disabled":a['disabled'], "content":a['content']}, {"disabled":a['disabled'], "content":a['content']}, "unchanged") )
@ -92,8 +92,9 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n
if history_entry.detail is None: if history_entry.detail is None:
return return
detail_dict = json.loads(history_entry.detail.replace("'", '"')) if "add_rrests" in history_entry.detail:
if "add_rrests" not in detail_dict: detail_dict = json.loads(history_entry.detail.replace("\'", ''))
else: # not a record entry
return return
add_rrests = detail_dict['add_rrests'] add_rrests = detail_dict['add_rrests']
@ -123,7 +124,7 @@ def extract_changelogs_from_a_history_entry(out_changes, history_entry, change_n
if change_num not in out_changes: if change_num not in out_changes:
out_changes[change_num] = [] out_changes[change_num] = []
out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-")) out_changes[change_num].append(HistoryRecordEntry(history_entry, del_rrest, [], "-"))
# only used for changelog per record # 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 if record_name != None and record_type != None: # then get only the records with the specific (record_name, record_type) tuple
@ -172,7 +173,7 @@ class HistoryRecordEntry:
if add_rrest['ttl'] != del_rrest['ttl']: if add_rrest['ttl'] != del_rrest['ttl']:
self.changed_fields.append("ttl") self.changed_fields.append("ttl")
self.changeSet = get_record_changes(del_rrest, add_rrest) self.changeSet = get_record_changes(del_rrest, add_rrest)
def toDict(self): def toDict(self):
@ -300,6 +301,7 @@ def edit_user(user_username=None):
@operator_role_required @operator_role_required
def edit_key(key_id=None): def edit_key(key_id=None):
domains = Domain.query.all() domains = Domain.query.all()
accounts = Account.query.all()
roles = Role.query.all() roles = Role.query.all()
apikey = None apikey = None
create = True create = True
@ -316,6 +318,7 @@ def edit_key(key_id=None):
return render_template('admin_edit_key.html', return render_template('admin_edit_key.html',
key=apikey, key=apikey,
domains=domains, domains=domains,
accounts=accounts,
roles=roles, roles=roles,
create=create) create=create)
@ -323,14 +326,21 @@ def edit_key(key_id=None):
fdata = request.form fdata = request.form
description = fdata['description'] description = fdata['description']
role = fdata.getlist('key_role')[0] role = fdata.getlist('key_role')[0]
doamin_list = fdata.getlist('key_multi_domain') domain_list = fdata.getlist('key_multi_domain')
account_list = fdata.getlist('key_multi_account')
# Create new apikey # Create new apikey
if create: if create:
domain_obj_list = Domain.query.filter(Domain.name.in_(doamin_list)).all() if role == "User":
domain_obj_list = Domain.query.filter(Domain.name.in_(domain_list)).all()
account_obj_list = Account.query.filter(Account.name.in_(account_list)).all()
else:
account_obj_list, domain_obj_list = [], []
apikey = ApiKey(desc=description, apikey = ApiKey(desc=description,
role_name=role, role_name=role,
domains=domain_obj_list) domains=domain_obj_list,
accounts=account_obj_list)
try: try:
apikey.create() apikey.create()
except Exception as e: except Exception as e:
@ -344,7 +354,9 @@ def edit_key(key_id=None):
# Update existing apikey # Update existing apikey
else: else:
try: try:
apikey.update(role,description,doamin_list) if role != "User":
domain_list, account_list = [], []
apikey.update(role,description,domain_list, account_list)
history_message = "Updated API key {0}".format(apikey.id) history_message = "Updated API key {0}".format(apikey.id)
except Exception as e: except Exception as e:
current_app.logger.error('Error: {0}'.format(e)) current_app.logger.error('Error: {0}'.format(e))
@ -354,14 +366,16 @@ def edit_key(key_id=None):
'key': apikey.id, 'key': apikey.id,
'role': apikey.role.name, 'role': apikey.role.name,
'description': apikey.description, 'description': apikey.description,
'domain_acl': [domain.name for domain in apikey.domains] 'domains': [domain.name for domain in apikey.domains],
'accounts': [a.name for a in apikey.accounts]
}), }),
created_by=current_user.username) created_by=current_user.username)
history.add() history.add()
return render_template('admin_edit_key.html', return render_template('admin_edit_key.html',
key=apikey, key=apikey,
domains=domains, domains=domains,
accounts=accounts,
roles=roles, roles=roles,
create=create, create=create,
plain_key=plain_key) plain_key=plain_key)
@ -390,7 +404,7 @@ def manage_keys():
history_apikey_role = apikey.role.name history_apikey_role = apikey.role.name
history_apikey_description = apikey.description history_apikey_description = apikey.description
history_apikey_domains = [ domain.name for domain in apikey.domains] history_apikey_domains = [ domain.name for domain in apikey.domains]
apikey.delete() apikey.delete()
except Exception as e: except Exception as e:
current_app.logger.error('Error: {0}'.format(e)) current_app.logger.error('Error: {0}'.format(e))
@ -740,110 +754,140 @@ def manage_account():
class DetailedHistory(): class DetailedHistory():
def __init__(self, history, change_set): def __init__(self, history, change_set):
self.history = history self.history = history
self.detailed_msg = "" self.detailed_msg = ""
self.change_set = change_set self.change_set = change_set
if history.detail is None:
self.detailed_msg = ""
# if 'Create account' in history.msg:
# account = Account.query.filter(
# Account.name == history.msg.split(' ')[2]).first()
# self.detailed_msg = str(account.get_user())
# # WRONG, cannot do query afterwards, db may have changed
return
detail_dict = json.loads(history.detail.replace("'", '"')) if not history.detail:
if 'domain_type' in detail_dict.keys() and 'account_id' in detail_dict.keys(): # this is a domain creation self.detailed_msg = ""
self.detailed_msg = """ return
<table class="table table-bordered table-striped"><tr><td>Domain type:</td><td>{0}</td></tr> <tr><td>Account:</td><td>{1}</td></tr></table>
""".format(detail_dict['domain_type'],
Account.get_name_by_id(self=None, account_id=detail_dict['account_id']) if detail_dict['account_id'] != "0" else "None")
elif 'authenticator' in detail_dict.keys(): # this is a user authentication
self.detailed_msg = """
<table class="table table-bordered table-striped" style="width:565px;">
<thead>
<tr>
<th colspan="3" style="background:
"""
# Change table header background colour depending on auth success or failure
if detail_dict['success'] == 1:
self.detailed_msg+= """
rgba(68,157,68);"> <p style="color:white;">
User {0} authentication success
</p></th>
""".format(detail_dict['username'])
else: if 'add_rrest' in history.detail:
self.detailed_msg+= """ detail_dict = json.loads(history.detail.replace("\'", ''))
rgba(201,48,44);"> <p style="color:white;"> else:
User {0} authentication failure detail_dict = json.loads(history.detail.replace("'", '"'))
</th>
""".format(detail_dict['username'])
self.detailed_msg+= """ if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
</tr> self.detailed_msg = render_template_string("""
</thead> <table class="table table-bordered table-striped">
<tbody> <tr><td>Domain type:</td><td>{{ domaintype }}</td></tr>
<tr> <tr><td>Account:</td><td>{{ account }}</td></tr>
<td>Authenticator Type:</td> </table>
<td colspan="2">{0}</td> """,
</tr> domaintype=detail_dict['domain_type'],
<tr> account=Account.get_name_by_id(self=None, account_id=detail_dict['account_id']) if detail_dict['account_id'] != "0" else "None")
<td>IP Address</td>
<td colspan="2">{1}</td> elif 'authenticator' in detail_dict: # this is a user authentication
</tr> self.detailed_msg = render_template_string("""
</tbody> <table class="table table-bordered table-striped" style="width:565px;">
</table> <thead>
""".format(detail_dict['authenticator'], detail_dict['ip_address']) <tr>
<th colspan="3" style="background: rgba({{ background_rgba }});">
<p style="color:white;">User {{ username }} authentication {{ auth_result }}</p>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Authenticator Type:</td>
<td colspan="2">{{ authenticator }}</td>
</tr>
<tr>
<td>IP Address</td>
<td colspan="2">{{ ip_address }}</td>
</tr>
</tbody>
</table>
""",
background_rgba="68,157,68" if detail_dict['success'] == 1 else "201,48,44",
username=detail_dict['username'],
auth_result="success" if detail_dict['success'] == 1 else "failure",
authenticator=detail_dict['authenticator'],
ip_address=detail_dict['ip_address'])
elif 'add_rrests' in detail_dict: # this is a domain record change
# changes_set = []
self.detailed_msg = ""
# extract_changelogs_from_a_history_entry(changes_set, history, 0)
elif 'name' in detail_dict and 'template' in history.msg: # template creation / deletion
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Template name:</td><td>{{ template_name }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
</table>
""",
template_name=DetailedHistory.get_key_val(detail_dict, "name"),
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
users_with_access = DetailedHistory.get_key_val(detail_dict, "user_has_access")
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Users with access to this domain</td><td>{{ users_with_access }}</td></tr>
<tr><td>Number of users:</td><td>{{ users_with_access | length }}</td><tr>
</table>
""",
users_with_access=users_with_access)
elif 'Created API key' in history.msg or 'Updated API key' in history.msg:
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{{ keyname }}</td></tr>
<tr><td>Role:</td><td>{{ rolename }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{{ linked_domains }}</td></tr>
<tr><td>Accessible accounts with this API key:</td><td>{{ linked_accounts }}</td></tr>
</table>
""",
keyname=DetailedHistory.get_key_val(detail_dict, "key"),
rolename=DetailedHistory.get_key_val(detail_dict, "role"),
description=DetailedHistory.get_key_val(detail_dict, "description"),
linked_domains=DetailedHistory.get_key_val(detail_dict, "domains" if "domains" in detail_dict else "domain_acl"),
linked_accounts=DetailedHistory.get_key_val(detail_dict, "accounts"))
elif 'Delete API key' in history.msg:
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{{ keyname }}</td></tr>
<tr><td>Role:</td><td>{{ rolename }}</td></tr>
<tr><td>Description:</td><td>{{ description }}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{{ linked_domains }}</td></tr>
</table>
""",
keyname=DetailedHistory.get_key_val(detail_dict, "key"),
rolename=DetailedHistory.get_key_val(detail_dict, "role"),
description=DetailedHistory.get_key_val(detail_dict, "description"),
linked_domains=DetailedHistory.get_key_val(detail_dict, "domains"))
elif 'Update type for domain' in history.msg:
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Domain: </td><td>{{ domain }}</td></tr>
<tr><td>Domain type:</td><td>{{ domain_type }}</td></tr>
<tr><td>Masters:</td><td>{{ masters }}</td></tr>
</table>
""",
domain=DetailedHistory.get_key_val(detail_dict, "domain"),
domain_type=DetailedHistory.get_key_val(detail_dict, "type"),
masters=DetailedHistory.get_key_val(detail_dict, "masters"))
elif 'reverse' in history.msg:
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Domain Type: </td><td>{{ domain_type }}</td></tr>
<tr><td>Domain Master IPs:</td><td>{{ domain_master_ips }}</td></tr>
</table>
""",
domain_type=DetailedHistory.get_key_val(detail_dict, "domain_type"),
domain_master_ips=DetailedHistory.get_key_val(detail_dict, "domain_master_ips"))
# check for lower key as well for old databases
@staticmethod
def get_key_val(_dict, key):
return str(_dict.get(key, _dict.get(key.title(), '')))
elif 'add_rrests' in detail_dict.keys(): # this is a domain record change
# changes_set = []
self.detailed_msg = ""
# extract_changelogs_from_a_history_entry(changes_set, history, 0)
elif 'name' in detail_dict.keys() and 'template' in history.msg: # template creation
self.detailed_msg = """
<table class="table table-bordered table-striped"><tr><td>Template name:</td><td>{0}</td></tr> <tr><td>Description:</td><td>{1}</td></tr></table>
""".format(detail_dict['name'], detail_dict['description'])
elif 'Change domain' in history.msg and 'access control' in history.msg: # added or removed a user from a domain
self.detailed_msg = """
<table class="table table-bordered table-striped"><tr><td>Users with access to this domain</td><td>{0}</td></tr><tr><td>Number of users:</td><td>{1}</td><tr></table>
""".format(str(detail_dict['user_has_access']).replace("]","").replace("[", ""), len((detail_dict['user_has_access'])))
elif 'Created API key' in history.msg or 'Updated API key' in history.msg:
self.detailed_msg = """
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{0}</td></tr>
<tr><td>Role:</td><td>{1}</td></tr>
<tr><td>Description:</td><td>{2}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{3}</td></tr>
</table>
""".format(detail_dict['key'], detail_dict['role'], detail_dict['description'], str(detail_dict['domain_acl']).replace("]","").replace("[", ""))
elif 'Update type for domain' in history.msg:
self.detailed_msg = """
<table class="table table-bordered table-striped">
<tr><td>Domain: </td><td>{0}</td></tr>
<tr><td>Domain type:</td><td>{1}</td></tr>
<tr><td>Masters:</td><td>{2}</td></tr>
</table>
""".format(detail_dict['domain'], detail_dict['type'], str(detail_dict['masters']).replace("]","").replace("[", ""))
elif 'Delete API key' in history.msg:
self.detailed_msg = """
<table class="table table-bordered table-striped">
<tr><td>Key: </td><td>{0}</td></tr>
<tr><td>Role:</td><td>{1}</td></tr>
<tr><td>Description:</td><td>{2}</td></tr>
<tr><td>Accessible domains with this API key:</td><td>{3}</td></tr>
</table>
""".format(detail_dict['key'], detail_dict['role'], detail_dict['description'], str(detail_dict['domains']).replace("]","").replace("[", ""))
elif 'reverse' in history.msg:
self.detailed_msg = """
<table class="table table-bordered table-striped">
<tr><td>Domain Type: </td><td>{0}</td></tr>
<tr><td>Domain Master IPs:</td><td>{1}</td></tr>
</table>
""".format(detail_dict['domain_type'], detail_dict['domain_master_ips'])
# convert a list of History objects into DetailedHistory objects # convert a list of History objects into DetailedHistory objects
def convert_histories(histories): def convert_histories(histories):
@ -851,8 +895,7 @@ def convert_histories(histories):
detailedHistories = [] detailedHistories = []
j = 0 j = 0
for i in range(len(histories)): for i in range(len(histories)):
# if histories[i].detail != None and 'add_rrests' in json.loads(histories[i].detail.replace("'", '"')): if histories[i].detail and ('add_rrests' in histories[i].detail or 'del_rrests' in histories[i].detail):
if histories[i].detail != None and ('add_rrests' in json.loads(histories[i].detail.replace("'", '"')) or 'del_rrests' in json.loads(histories[i].detail.replace("'", '"'))):
extract_changelogs_from_a_history_entry(changes_set, histories[i], j) extract_changelogs_from_a_history_entry(changes_set, histories[i], j)
if j in changes_set: if j in changes_set:
detailedHistories.append(DetailedHistory(histories[i], changes_set[j])) detailedHistories.append(DetailedHistory(histories[i], changes_set[j]))
@ -895,7 +938,7 @@ def history():
}), 500) }), 500)
if request.method == 'GET': if request.method == 'GET':
doms = accounts = users = "" doms = accounts = users = ""
if current_user.role.name in [ 'Administrator', 'Operator']: if current_user.role.name in [ 'Administrator', 'Operator']:
all_domain_names = Domain.query.all() all_domain_names = Domain.query.all()
@ -903,7 +946,7 @@ def history():
all_user_names = User.query.all() all_user_names = User.query.all()
for d in all_domain_names: for d in all_domain_names:
doms += d.name + " " doms += d.name + " "
for acc in all_account_names: for acc in all_account_names:
@ -931,9 +974,9 @@ def history():
AccountUser.user_id == current_user.id AccountUser.user_id == current_user.id
)).all() )).all()
all_user_names = [] all_user_names = []
for a in all_account_names: for a in all_account_names:
temp = db.session.query(User) \ temp = db.session.query(User) \
.join(AccountUser, AccountUser.user_id == User.id) \ .join(AccountUser, AccountUser.user_id == User.id) \
.outerjoin(Account, Account.id == AccountUser.account_id) \ .outerjoin(Account, Account.id == AccountUser.account_id) \
@ -951,11 +994,11 @@ def history():
for d in all_domain_names: for d in all_domain_names:
doms += d.name + " " doms += d.name + " "
for a in all_account_names: for a in all_account_names:
accounts += a.name + " " accounts += a.name + " "
for u in all_user_names: for u in all_user_names:
users += u.username + " " users += u.username + " "
return render_template('admin_history.html', all_domain_names=doms, all_account_names=accounts, all_usernames=users) return render_template('admin_history.html', all_domain_names=doms, all_account_names=accounts, all_usernames=users)
# local_offset is the offset of the utc to the local time # local_offset is the offset of the utc to the local time
@ -1005,7 +1048,7 @@ def history_table(): # ajax call data
if current_user.role.name in [ 'Administrator', 'Operator' ]: if current_user.role.name in [ 'Administrator', 'Operator' ]:
base_query = History.query base_query = History.query
else: else:
# if the user isn't an administrator or operator, # if the user isn't an administrator or operator,
# allow_user_view_history must be enabled to get here, # allow_user_view_history must be enabled to get here,
# so include history for the domains for the user # so include history for the domains for the user
base_query = db.session.query(History) \ base_query = db.session.query(History) \
@ -1020,7 +1063,7 @@ def history_table(): # ajax call data
)) ))
domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \ domain_name = request.args.get('domain_name_filter') if request.args.get('domain_name_filter') != None \
and len(request.args.get('domain_name_filter')) != 0 else None and len(request.args.get('domain_name_filter')) != 0 else None
account_name = request.args.get('account_name_filter') if request.args.get('account_name_filter') != None \ account_name = request.args.get('account_name_filter') if request.args.get('account_name_filter') != None \
and len(request.args.get('account_name_filter')) != 0 else None and len(request.args.get('account_name_filter')) != 0 else None
user_name = request.args.get('auth_name_filter') if request.args.get('auth_name_filter') != None \ user_name = request.args.get('auth_name_filter') if request.args.get('auth_name_filter') != None \
@ -1217,8 +1260,7 @@ def setting_basic():
'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name', 'allow_user_create_domain', 'allow_user_remove_domain', 'allow_user_view_history', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options', 'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email', 'pdns_api_timeout', 'verify_ssl_connections', 'verify_user_email',
'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records' 'delete_sso_accounts', 'otp_field_enabled', 'custom_css', 'enable_api_rr_history', 'max_history_records', 'otp_force'
] ]
return render_template('admin_setting_basic.html', settings=settings) return render_template('admin_setting_basic.html', settings=settings)

View file

@ -21,16 +21,18 @@ from ..lib.errors import (
DomainNotExists, DomainAlreadyExists, DomainAccessForbidden, DomainNotExists, DomainAlreadyExists, DomainAccessForbidden,
RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges, RequestIsNotJSON, ApiKeyCreateFail, ApiKeyNotUsable, NotEnoughPrivileges,
AccountCreateFail, AccountUpdateFail, AccountDeleteFail, AccountCreateFail, AccountUpdateFail, AccountDeleteFail,
AccountCreateDuplicate, AccountCreateDuplicate, AccountNotExists,
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail, UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
UserUpdateFailEmail, UserUpdateFailEmail,
) )
from ..decorators import ( from ..decorators import (
api_basic_auth, api_can_create_domain, is_json, apikey_auth, api_basic_auth, api_can_create_domain, is_json, apikey_auth,
apikey_is_admin, apikey_can_access_domain, api_role_can, apikey_can_create_domain, apikey_can_remove_domain,
apikey_or_basic_auth, apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
api_role_can, apikey_or_basic_auth,
callback_if_request_body_contains_key,
) )
import random import secrets
import string import string
api_bp = Blueprint('api', __name__, url_prefix='/api/v1') api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
@ -307,6 +309,7 @@ def api_generate_apikey():
role_name = None role_name = None
apikey = None apikey = None
domain_obj_list = [] domain_obj_list = []
account_obj_list = []
abort(400) if 'role' not in data else None abort(400) if 'role' not in data else None
@ -317,6 +320,13 @@ def api_generate_apikey():
else: else:
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
if 'accounts' not in data:
accounts = []
elif not isinstance(data['accounts'], (list, )):
abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
description = data['description'] if 'description' in data else None description = data['description'] if 'description' in data else None
if isinstance(data['role'], str): if isinstance(data['role'], str):
@ -326,16 +336,24 @@ def api_generate_apikey():
else: else:
abort(400) abort(400)
if role_name == 'User' and len(domains) == 0: if role_name == 'User' and len(domains) == 0 and len(accounts) == 0:
current_app.logger.error("Apikey with User role must have domains") current_app.logger.error("Apikey with User role must have domains or accounts")
raise ApiKeyNotUsable() raise ApiKeyNotUsable()
elif role_name == 'User':
if role_name == 'User' and len(domains) > 0:
domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all() domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all()
if len(domain_obj_list) == 0: if len(domain_obj_list) == 0:
msg = "One of supplied domains does not exist" msg = "One of supplied domains does not exist"
current_app.logger.error(msg) current_app.logger.error(msg)
raise DomainNotExists(message=msg) raise DomainNotExists(message=msg)
if role_name == 'User' and len(accounts) > 0:
account_obj_list = Account.query.filter(Account.name.in_(accounts)).all()
if len(account_obj_list) == 0:
msg = "One of supplied accounts does not exist"
current_app.logger.error(msg)
raise AccountNotExists(message=msg)
if current_user.role.name not in ['Administrator', 'Operator']: if current_user.role.name not in ['Administrator', 'Operator']:
# domain list of domain api key should be valid for # domain list of domain api key should be valid for
# if not any domain error # if not any domain error
@ -345,6 +363,11 @@ def api_generate_apikey():
current_app.logger.error(msg) current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg) raise NotEnoughPrivileges(message=msg)
if len(accounts) > 0:
msg = "User cannot assign accounts"
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
user_domain_obj_list = get_user_domains() user_domain_obj_list = get_user_domains()
domain_list = [item.name for item in domain_obj_list] domain_list = [item.name for item in domain_obj_list]
@ -363,7 +386,8 @@ def api_generate_apikey():
apikey = ApiKey(desc=description, apikey = ApiKey(desc=description,
role_name=role_name, role_name=role_name,
domains=domain_obj_list) domains=domain_obj_list,
accounts=account_obj_list)
try: try:
apikey.create() apikey.create()
@ -476,9 +500,16 @@ def api_update_apikey(apikey_id):
# if role different and user is allowed to change it, update # if role different and user is allowed to change it, update
# if apikey domains are different and user is allowed to handle # if apikey domains are different and user is allowed to handle
# that domains update domains # that domains update domains
domain_obj_list = None
account_obj_list = None
apikey = ApiKey.query.get(apikey_id)
if not apikey:
abort(404)
data = request.get_json() data = request.get_json()
description = data['description'] if 'description' in data else None description = data['description'] if 'description' in data else None
domain_obj_list = None
if 'role' in data: if 'role' in data:
if isinstance(data['role'], str): if isinstance(data['role'], str):
@ -487,8 +518,11 @@ def api_update_apikey(apikey_id):
role_name = data['role']['name'] role_name = data['role']['name']
else: else:
abort(400) abort(400)
target_role = role_name
else: else:
role_name = None role_name = None
target_role = apikey.role.name
if 'domains' not in data: if 'domains' not in data:
domains = None domains = None
@ -497,22 +531,54 @@ def api_update_apikey(apikey_id):
else: else:
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
apikey = ApiKey.query.get(apikey_id) if 'accounts' not in data:
accounts = None
if not apikey: elif not isinstance(data['accounts'], (list, )):
abort(404) abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
current_app.logger.debug('Updating apikey with id {0}'.format(apikey_id)) current_app.logger.debug('Updating apikey with id {0}'.format(apikey_id))
if role_name == 'User' and len(domains) == 0: if target_role == 'User':
current_app.logger.error("Apikey with User role must have domains") current_domains = [item.name for item in apikey.domains]
raise ApiKeyNotUsable() current_accounts = [item.name for item in apikey.accounts]
elif role_name == 'User':
domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all() if domains is not None:
if len(domain_obj_list) == 0: domain_obj_list = Domain.query.filter(Domain.name.in_(domains)).all()
msg = "One of supplied domains does not exist" if len(domain_obj_list) != len(domains):
current_app.logger.error(msg) msg = "One of supplied domains does not exist"
raise DomainNotExists(message=msg) current_app.logger.error(msg)
raise DomainNotExists(message=msg)
target_domains = domains
else:
target_domains = current_domains
if accounts is not None:
account_obj_list = Account.query.filter(Account.name.in_(accounts)).all()
if len(account_obj_list) != len(accounts):
msg = "One of supplied accounts does not exist"
current_app.logger.error(msg)
raise AccountNotExists(message=msg)
target_accounts = accounts
else:
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")
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")
domains = None
if accounts is not None and set(accounts) == set(current_accounts):
current_app.logger.debug(
"Accounts are the same, apikey accounts won't be updated")
accounts = None
if current_user.role.name not in ['Administrator', 'Operator']: if current_user.role.name not in ['Administrator', 'Operator']:
if role_name != 'User': if role_name != 'User':
@ -520,8 +586,12 @@ def api_update_apikey(apikey_id):
current_app.logger.error(msg) current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg) raise NotEnoughPrivileges(message=msg)
if len(accounts) > 0:
msg = "User cannot assign accounts"
current_app.logger.error(msg)
raise NotEnoughPrivileges(message=msg)
apikeys = get_user_apikeys() apikeys = get_user_apikeys()
apikey_domains = [item.name for item in apikey.domains]
apikeys_ids = [apikey_item.id for apikey_item in apikeys] apikeys_ids = [apikey_item.id for apikey_item in apikeys]
user_domain_obj_list = current_user.get_domain().all() user_domain_obj_list = current_user.get_domain().all()
@ -545,12 +615,7 @@ def api_update_apikey(apikey_id):
current_app.logger.error(msg) current_app.logger.error(msg)
raise DomainAccessForbidden() raise DomainAccessForbidden()
if set(domains) == set(apikey_domains): if role_name == apikey.role.name:
current_app.logger.debug(
"Domains are same, apikey domains won't be updated")
domains = None
if role_name == apikey.role:
current_app.logger.debug("Role is same, apikey role won't be updated") current_app.logger.debug("Role is same, apikey role won't be updated")
role_name = None role_name = None
@ -559,10 +624,13 @@ def api_update_apikey(apikey_id):
current_app.logger.debug(msg) current_app.logger.debug(msg)
description = None description = None
if target_role != "User":
domains, accounts = [], []
try: try:
apikey = ApiKey.query.get(apikey_id)
apikey.update(role_name=role_name, apikey.update(role_name=role_name,
domains=domains, domains=domains,
accounts=accounts,
description=description) description=description)
except Exception as e: except Exception as e:
current_app.logger.error('Error: {0}'.format(e)) current_app.logger.error('Error: {0}'.format(e))
@ -621,7 +689,7 @@ def api_create_user():
if not plain_text_password and not password: if not plain_text_password and not password:
plain_text_password = ''.join( plain_text_password = ''.join(
random.choice(string.ascii_letters + string.digits) secrets.choice(string.ascii_letters + string.digits)
for _ in range(15)) for _ in range(15))
if not role_name and not role_id: if not role_name and not role_id:
role_name = 'User' role_name = 'User'
@ -856,7 +924,7 @@ def api_update_account(account_id):
"Updating account {} ({})".format(account_id, account.name)) "Updating account {} ({})".format(account_id, account.name))
result = account.update_account() result = account.update_account()
if not result['status']: if not result['status']:
raise AccountDeleteFail(message=result['msg']) raise AccountUpdateFail(message=result['msg'])
history = History(msg='Update account {0}'.format(account.name), history = History(msg='Update account {0}'.format(account.name),
created_by=current_user.username) created_by=current_user.username)
history.add() history.add()
@ -876,7 +944,7 @@ def api_delete_account(account_id):
"Deleting account {} ({})".format(account_id, account.name)) "Deleting account {} ({})".format(account_id, account.name))
result = account.delete_account() result = account.delete_account()
if not result: if not result:
raise AccountUpdateFail(message=result['msg']) raise AccountDeleteFail(message=result['msg'])
history = History(msg='Delete account {0}'.format(account.name), history = History(msg='Delete account {0}'.format(account.name),
created_by=current_user.username) created_by=current_user.username)
@ -957,6 +1025,28 @@ def api_remove_account_user(account_id, user_id):
return '', 204 return '', 204
@api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/cryptokeys',
methods=['GET', 'POST'])
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec(http_methods=['POST'])
def api_zone_cryptokeys(server_id, zone_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/cryptokeys/<string:cryptokey_id>',
methods=['GET', 'PUT', 'DELETE'])
@apikey_auth
@apikey_can_access_domain
@apikey_can_configure_dnssec()
def api_zone_cryptokey(server_id, zone_id, cryptokey_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
@api_bp.route( @api_bp.route(
'/servers/<string:server_id>/zones/<string:zone_id>/<path:subpath>', '/servers/<string:server_id>/zones/<string:zone_id>/<path:subpath>',
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
@ -971,6 +1061,10 @@ def api_zone_subpath_forward(server_id, zone_id, subpath):
methods=['GET', 'PUT', 'PATCH', 'DELETE']) methods=['GET', 'PUT', 'PATCH', 'DELETE'])
@apikey_auth @apikey_auth
@apikey_can_access_domain @apikey_can_access_domain
@apikey_can_remove_domain(http_methods=['DELETE'])
@callback_if_request_body_contains_key(apikey_can_configure_dnssec()(),
http_methods=['PUT'],
keys=['dnssec', 'nsec3param'])
def api_zone_forward(server_id, zone_id): def api_zone_forward(server_id, zone_id):
resp = helper.forward_request() resp = helper.forward_request()
if not Setting().get('bg_domain_updates'): if not Setting().get('bg_domain_updates'):
@ -1004,6 +1098,7 @@ def api_zone_forward(server_id, zone_id):
history.add() history.add()
return resp.content, resp.status_code, resp.headers.items() return resp.content, resp.status_code, resp.headers.items()
@api_bp.route('/servers/<path:subpath>', methods=['GET', 'PUT']) @api_bp.route('/servers/<path:subpath>', methods=['GET', 'PUT'])
@apikey_auth @apikey_auth
@apikey_is_admin @apikey_is_admin
@ -1014,6 +1109,7 @@ def api_server_sub_forward(subpath):
@api_bp.route('/servers/<string:server_id>/zones', methods=['POST']) @api_bp.route('/servers/<string:server_id>/zones', methods=['POST'])
@apikey_auth @apikey_auth
@apikey_can_create_domain
def api_create_zone(server_id): def api_create_zone(server_id):
resp = helper.forward_request() resp = helper.forward_request()
@ -1055,8 +1151,13 @@ def api_get_zones(server_id):
and resp.status_code == 200): and resp.status_code == 200):
domain_list = [d['name'] domain_list = [d['name']
for d in domain_schema.dump(g.apikey.domains)] for d in domain_schema.dump(g.apikey.domains)]
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)))
content = json.dumps([i for i in json.loads(resp.content) content = json.dumps([i for i in json.loads(resp.content)
if i['name'].rstrip('.') in domain_list]) if i['name'].rstrip('.') in allowed_domains])
return content, resp.status_code, resp.headers.items() return content, resp.status_code, resp.headers.items()
else: else:
return resp.content, resp.status_code, resp.headers.items() return resp.content, resp.status_code, resp.headers.items()

View file

@ -340,7 +340,8 @@ def record_changelog(domain_name, record_name, record_type):
for i in indexes_to_pop: for i in indexes_to_pop:
changes_set_of_record.pop(i) changes_set_of_record.pop(i)
return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record) return render_template('domain_changelog.html', domain=domain, allHistoryChanges=changes_set_of_record,
record_name = record_name, record_type = record_type)

View file

@ -4,6 +4,7 @@ import json
import traceback import traceback
import datetime import datetime
import ipaddress import ipaddress
import base64
from distutils.util import strtobool from distutils.util import strtobool
from yaml import Loader, load from yaml import Loader, load
from onelogin.saml2.utils import OneLogin_Saml2_Utils from onelogin.saml2.utils import OneLogin_Saml2_Utils
@ -168,10 +169,8 @@ def login():
return redirect(url_for('index.login')) return redirect(url_for('index.login'))
session['user_id'] = user.id session['user_id'] = user.id
login_user(user, remember=False)
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
signin_history(user.username, 'Google OAuth', True) return authenticate_user(user, 'Google OAuth')
return redirect(url_for('index.index'))
if 'github_token' in session: if 'github_token' in session:
me = json.loads(github.get('user').text) me = json.loads(github.get('user').text)
@ -196,9 +195,7 @@ def login():
session['user_id'] = user.id session['user_id'] = user.id
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
login_user(user, remember=False) return authenticate_user(user, 'Github OAuth')
signin_history(user.username, 'Github OAuth', True)
return redirect(url_for('index.index'))
if 'azure_token' in session: if 'azure_token' in session:
azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text azure_info = azure.get('me?$select=displayName,givenName,id,mail,surname,userPrincipalName').text
@ -367,10 +364,7 @@ def login():
history.add() history.add()
current_app.logger.warning('group info: {} '.format(account_id)) current_app.logger.warning('group info: {} '.format(account_id))
return authenticate_user(user, 'Azure OAuth')
login_user(user, remember=False)
signin_history(user.username, 'Azure OAuth', True)
return redirect(url_for('index.index'))
if 'oidc_token' in session: if 'oidc_token' in session:
me = json.loads(oidc.get('userinfo').text) me = json.loads(oidc.get('userinfo').text)
@ -434,9 +428,7 @@ def login():
session['user_id'] = user.id session['user_id'] = user.id
session['authentication_type'] = 'OAuth' session['authentication_type'] = 'OAuth'
login_user(user, remember=False) return authenticate_user(user, 'OIDC OAuth')
signin_history(user.username, 'OIDC OAuth', True)
return redirect(url_for('index.index'))
if request.method == 'GET': if request.method == 'GET':
return render_template('login.html', saml_enabled=SAML_ENABLED) return render_template('login.html', saml_enabled=SAML_ENABLED)
@ -513,9 +505,7 @@ def login():
user.revoke_privilege(True) 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 + '.' )
login_user(user, remember=remember_me) return authenticate_user(user, 'LOCAL', remember_me)
signin_history(user.username, 'LOCAL', True)
return redirect(session.get('next', url_for('index.index')))
def checkForPDAEntries(Entitlements, urn_value): def checkForPDAEntries(Entitlements, urn_value):
""" """
@ -585,6 +575,23 @@ def get_azure_groups(uri):
mygroups = [] mygroups = []
return 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:
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') @index_bp.route('/logout')
def logout(): def logout():
@ -778,7 +785,12 @@ def register():
if result and result['status']: if result and result['status']:
if Setting().get('verify_user_email'): if Setting().get('verify_user_email'):
send_account_verification(email) send_account_verification(email)
return redirect(url_for('index.login')) 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: else:
return render_template('register.html', return render_template('register.html',
error=result['msg']) error=result['msg'])
@ -788,6 +800,28 @@ def register():
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
@index_bp.route('/welcome', methods=['GET', 'POST'])
def welcome():
if 'welcome_user_id' not in session:
return redirect(url_for('index.index'))
user = User(id=session['welcome_user_id'])
encoded_img_data = base64.b64encode(user.get_qrcode_value())
if request.method == 'GET':
return render_template('register_otp.html', qrcode_image=encoded_img_data.decode(), user=user)
elif request.method == 'POST':
otp_token = request.form.get('otptoken', '')
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")
else:
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/<token>', methods=['GET']) @index_bp.route('/confirm/<token>', methods=['GET'])
def confirm_email(token): def confirm_email(token):
email = confirm_token(token) email = confirm_token(token)
@ -1141,9 +1175,7 @@ def saml_authorized():
user.plain_text_password = None user.plain_text_password = None
user.update_profile() user.update_profile()
session['authentication_type'] = 'SAML' session['authentication_type'] = 'SAML'
login_user(user, remember=False) return authenticate_user(user, 'SAML')
signin_history(user.username, 'SAML', True)
return redirect(url_for('index.login'))
else: else:
return render_template('errors/SAML.html', errors=errors) return render_template('errors/SAML.html', errors=errors)

View file

@ -1,7 +1,4 @@
import datetime import datetime
import qrcode as qrc
import qrcode.image.svg as qrc_svg
from io import BytesIO
from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app from flask import Blueprint, request, render_template, make_response, jsonify, redirect, url_for, g, session, current_app
from flask_login import current_user, login_required, login_manager from flask_login import current_user, login_required, login_manager
@ -97,13 +94,9 @@ def qrcode():
if not current_user: if not current_user:
return redirect(url_for('index')) return redirect(url_for('index'))
img = qrc.make(current_user.get_totp_uri(), return current_user.get_qrcode_value(), 200, {
image_factory=qrc_svg.SvgPathImage)
stream = BytesIO()
img.save(stream)
return stream.getvalue(), 200, {
'Content-Type': 'image/svg+xml', 'Content-Type': 'image/svg+xml',
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }

View file

@ -285,4 +285,14 @@ function timer(elToUpdate, maxTime) {
}, 1000); }, 1000);
return interval; return interval;
} }
// copy otp secret code to clipboard
function copy_otp_secret_to_clipboard() {
var copyBox = document.getElementById("otp_secret");
copyBox.select();
copyBox.setSelectionRange(0, 99999); /* For mobile devices */
navigator.clipboard.writeText(copyBox.value);
$("#copy_tooltip").css("visibility", "visible");
setTimeout(function(){ $("#copy_tooltip").css("visibility", "collapse"); }, 2000);
}

View file

@ -797,6 +797,11 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/PDNSAdminZones' $ref: '#/definitions/PDNSAdminZones'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
post: post:
security: security:
- basicAuth: [] - basicAuth: []
@ -816,6 +821,23 @@ paths:
description: A zone description: A zone
schema: schema:
$ref: '#/definitions/Zone' $ref: '#/definitions/Zone'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'409':
description: 'Domain already exists (conflict)'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/zones/{zone_id}': '/pdnsadmin/zones/{zone_id}':
parameters: parameters:
- name: zone_id - name: zone_id
@ -839,6 +861,23 @@ paths:
responses: responses:
'204': '204':
description: 'Returns 204 No Content on success.' description: 'Returns 204 No Content on success.'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Forbidden'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found'
schema:
$ref: '#/definitions/Error'
'500':
description: 'Internal Server Error'
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/apikeys': '/pdnsadmin/apikeys':
get: get:
security: security:
@ -854,15 +893,23 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/ApiKey' $ref: '#/definitions/ApiKey'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'500': '500':
description: 'Internal Server Error, keys could not be retrieved. Contains error message' description: 'Internal Server Error. There was a problem creating the key'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
post: post:
security: security:
- basicAuth: [] - basicAuth: []
summary: 'Add a ApiKey key' summary: 'Add a ApiKey key'
description: 'This methods add a new ApiKey. The actual key can be generated by the server or be provided by the client' description: 'This methods add a new ApiKey. The actual key is generated by the server'
operationId: api_generate_apikey operationId: api_generate_apikey
tags: tags:
- apikey - apikey
@ -878,14 +925,27 @@ paths:
description: Created description: Created
schema: schema:
$ref: '#/definitions/ApiKey' $ref: '#/definitions/ApiKey'
'422': '400':
description: 'Unprocessable Entry, the ApiKey provided has issues.' description: 'Request is not JSON or does not respect required format'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Domain or Account Not found'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'500': '500':
description: 'Internal Server Error. There was a problem creating the key' description: 'Internal Server Error. There was a problem creating the key'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/apikeys/{apikey_id}': '/pdnsadmin/apikeys/{apikey_id}':
parameters: parameters:
- name: apikey_id - name: apikey_id
@ -905,14 +965,16 @@ paths:
description: OK. description: OK.
schema: schema:
$ref: '#/definitions/ApiKey' $ref: '#/definitions/ApiKey'
'403': '401':
description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key' description: 'Unauthorized'
'404':
description: 'Not found. The ApiKey with the specified apikey_id does not exist'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'500': '403':
description: 'Internal Server Error, keys could not be retrieved. Contains error message' description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key'
schema:
$ref: '#/definitions/Error'
'404':
description: 'Not found. The ApiKey with the specified apikey_id does not exist'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
delete: delete:
@ -925,6 +987,14 @@ paths:
responses: responses:
'204': '204':
description: 'OK, key was deleted' description: 'OK, key was deleted'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'The authenticated user has User role and is not allowed on any of the domains assigned to the key'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: 'Not found. The ApiKey with the specified apikey_id does not exist' description: 'Not found. The ApiKey with the specified apikey_id does not exist'
schema: schema:
@ -938,9 +1008,11 @@ paths:
- basicAuth: [] - basicAuth: []
description: | description: |
The ApiKey at apikey_id can be changed in multiple ways: The ApiKey at apikey_id can be changed in multiple ways:
* Role, description, domains can be updated * Role, description, accounts and domains can be updated
* Role can be changed to Administrator only if user has Operator or Administrator privileges * Role can be changed to Administrator only if user has Operator or Administrator privileges
* Domains will be updated only if user has access to them * Domains will be updated only if user has access to them
* Accounts can be updated only by a privileged user
* With a User role, an ApiKey needs at least one account or one domain
Only the relevant fields have to be provided in the request body. Only the relevant fields have to be provided in the request body.
operationId: api_update_apikey operationId: api_update_apikey
tags: tags:
@ -957,14 +1029,27 @@ paths:
description: OK. ApiKey is changed. description: OK. ApiKey is changed.
schema: schema:
$ref: '#/definitions/ApiKey' $ref: '#/definitions/ApiKey'
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'403':
description: 'Domain Access Forbidden'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: 'Not found. The TSIGKey with the specified tsigkey_id does not exist' description: 'Not found (ApiKey, Domain or Account)'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'500': '500':
description: 'Internal Server Error. Contains error message' description: 'Internal Server Error. Contains error message'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/users': '/pdnsadmin/users':
get: get:
security: security:
@ -980,6 +1065,10 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/User' $ref: '#/definitions/User'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'500': '500':
description: Internal Server Error, users could not be retrieved. Contains error message description: Internal Server Error, users could not be retrieved. Contains error message
schema: schema:
@ -1038,7 +1127,11 @@ paths:
schema: schema:
$ref: '#/definitions/User' $ref: '#/definitions/User'
'400': '400':
description: Unprocessable Entry, the User data provided has issues description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'409': '409':
@ -1049,6 +1142,7 @@ paths:
description: Internal Server Error. There was a problem creating the user description: Internal Server Error. There was a problem creating the user
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/users/{username}': '/pdnsadmin/users/{username}':
parameters: parameters:
- name: username - name: username
@ -1068,6 +1162,10 @@ paths:
description: Retrieve a specific User description: Retrieve a specific User
schema: schema:
$ref: '#/definitions/UserDetailed' $ref: '#/definitions/UserDetailed'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The User with the specified username does not exist description: Not found. The User with the specified username does not exist
schema: schema:
@ -1076,6 +1174,7 @@ paths:
description: Internal Server Error, user could not be retrieved. Contains error message description: Internal Server Error, user could not be retrieved. Contains error message
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/users/{user_id}': '/pdnsadmin/users/{user_id}':
parameters: parameters:
- name: user_id - name: user_id
@ -1129,10 +1228,22 @@ paths:
responses: responses:
'204': '204':
description: OK. User is modified (empty response body) description: OK. User is modified (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The User with the specified user_id does not exist description: Not found. The User with the specified user_id does not exist
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'409':
description: Duplicate (Email already assigned to another user)
schema:
$ref: '#/definitions/Error'
'500': '500':
description: Internal Server Error. Contains error message description: Internal Server Error. Contains error message
schema: schema:
@ -1147,6 +1258,10 @@ paths:
responses: responses:
'204': '204':
description: OK. User is deleted (empty response body) description: OK. User is deleted (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The User with the specified user_id does not exist description: Not found. The User with the specified user_id does not exist
schema: schema:
@ -1155,6 +1270,7 @@ paths:
description: Internal Server Error. Contains error message description: Internal Server Error. Contains error message
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/accounts': '/pdnsadmin/accounts':
get: get:
security: security:
@ -1170,8 +1286,8 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/Account' $ref: '#/definitions/Account'
'500': '401':
description: Internal Server Error, accounts could not be retrieved. Contains error message description: 'Unauthorized'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
post: post:
@ -1207,7 +1323,11 @@ paths:
schema: schema:
$ref: '#/definitions/Account' $ref: '#/definitions/Account'
'400': '400':
description: Unprocessable Entry, the Account data provided has issues. description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'409': '409':
@ -1218,6 +1338,7 @@ paths:
description: Internal Server Error. There was a problem creating the account description: Internal Server Error. There was a problem creating the account
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_name}': '/pdnsadmin/accounts/{account_name}':
parameters: parameters:
- name: account_name - name: account_name
@ -1237,14 +1358,15 @@ paths:
description: Retrieve a specific account description: Retrieve a specific account
schema: schema:
$ref: '#/definitions/Account' $ref: '#/definitions/Account'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account with the specified name does not exist description: Not found. The Account with the specified name does not exist
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'500':
description: Internal Server Error, account could not be retrieved. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}': '/pdnsadmin/accounts/{account_id}':
parameters: parameters:
- name: account_id - name: account_id
@ -1281,6 +1403,14 @@ paths:
responses: responses:
'204': '204':
description: OK. Account is modified (empty response body) description: OK. Account is modified (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account with the specified account_id does not exist description: Not found. The Account with the specified account_id does not exist
schema: schema:
@ -1299,6 +1429,10 @@ paths:
responses: responses:
'204': '204':
description: OK. Account is deleted (empty response body) description: OK. Account is deleted (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account with the specified account_id does not exist description: Not found. The Account with the specified account_id does not exist
schema: schema:
@ -1307,6 +1441,7 @@ paths:
description: Internal Server Error. Contains error message description: Internal Server Error. Contains error message
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}/users': '/pdnsadmin/accounts/{account_id}/users':
parameters: parameters:
- name: account_id - name: account_id
@ -1329,14 +1464,46 @@ paths:
type: array type: array
items: items:
$ref: '#/definitions/User' $ref: '#/definitions/User'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account with the specified account_id does not exist description: Not found. The Account with the specified account_id does not exist
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'500':
description: Internal Server Error, accounts could not be retrieved. Contains error message '/pdnsadmin/accounts/users/{account_id}':
parameters:
- name: account_id
type: integer
in: path
required: true
description: The id of the account to list users linked to account
get:
security:
- basicAuth: []
summary: List users linked to a specific account
operationId: api_list_users_account
tags:
- account
- user
responses:
'200':
description: List of Summarized User objects
schema:
type: array
items:
$ref: '#/definitions/User'
'401':
description: 'Unauthorized'
schema: schema:
$ref: '#/definitions/Error' $ref: '#/definitions/Error'
'404':
description: Not found. The Account with the specified account_id does not exist
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/{account_id}/users/{user_id}': '/pdnsadmin/accounts/{account_id}/users/{user_id}':
parameters: parameters:
- name: account_id - name: account_id
@ -1360,6 +1527,14 @@ paths:
responses: responses:
'204': '204':
description: OK. User is linked (empty response body) description: OK. User is linked (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account or User with the specified id does not exist description: Not found. The Account or User with the specified id does not exist
schema: schema:
@ -1379,6 +1554,73 @@ paths:
responses: responses:
'204': '204':
description: OK. User is unlinked (empty response body) description: OK. User is unlinked (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist or user was not linked to account
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
'/pdnsadmin/accounts/users/{account_id}/{user_id}':
parameters:
- name: account_id
type: integer
in: path
required: true
description: The id of the account to link/unlink users to account
- name: user_id
type: integer
in: path
required: true
description: The id of the user to (un)link to/from account
put:
security:
- basicAuth: []
summary: Link user to account
operationId: api_add_user_account
tags:
- account
- user
responses:
'204':
description: OK. User is linked (empty response body)
'400':
description: 'Request is not JSON'
schema:
$ref: '#/definitions/Error'
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404':
description: Not found. The Account or User with the specified id does not exist
schema:
$ref: '#/definitions/Error'
'500':
description: Internal Server Error. Contains error message
schema:
$ref: '#/definitions/Error'
delete:
security:
- basicAuth: []
summary: Unlink user from account
operationId: api_remove_user_account
tags:
- account
- user
responses:
'204':
description: OK. User is unlinked (empty response body)
'401':
description: 'Unauthorized'
schema:
$ref: '#/definitions/Error'
'404': '404':
description: Not found. The Account or User with the specified id does not exist or user was not linked to account description: Not found. The Account or User with the specified id does not exist or user was not linked to account
schema: schema:
@ -1598,8 +1840,9 @@ definitions:
PDNSAdminZones: PDNSAdminZones:
title: PDNSAdminZones title: PDNSAdminZones
description: A ApiKey that can be used to manage domains through API description: 'A list of domains'
type: array type: array
x-omitempty: false
items: items:
properties: properties:
id: id:
@ -1624,7 +1867,7 @@ definitions:
ApiKey: ApiKey:
title: ApiKey title: ApiKey
description: A ApiKey that can be used to manage domains through API description: 'An ApiKey that can be used to manage domains through API'
properties: properties:
id: id:
type: integer type: integer
@ -1644,6 +1887,23 @@ definitions:
description: description:
type: string type: string
description: 'Some user defined description' description: 'Some user defined description'
accounts:
type: array
description: 'A list of accounts bound to this ApiKey'
items:
$ref: '#/definitions/AccountSummary'
ApiKeySummary:
title: ApiKeySummary
description: Summary of an ApiKey
properties:
id:
type: integer
description: 'The ID for this key, used in the ApiKey URL endpoint.'
readOnly: true
description:
type: string
description: 'Some user defined description'
User: User:
title: User title: User
@ -1751,6 +2011,12 @@ definitions:
type: string type: string
description: The email address of the contact for this account description: The email address of the contact for this account
readOnly: false readOnly: false
apikeys:
type: array
description: A list of API Keys bound to this account
readOnly: true
items:
$ref: '#/definitions/ApiKeySummary'
AccountSummary: AccountSummary:
title: AccountSummry title: AccountSummry
@ -1764,6 +2030,9 @@ definitions:
type: string type: string
description: The name for this account (unique, immutable) description: The name for this account (unique, immutable)
readOnly: false readOnly: false
domains:
description: The list of domains owned by this account
$ref: '#/definitions/PDNSAdminZones'
ConfigSetting: ConfigSetting:
title: ConfigSetting title: ConfigSetting

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "admin_keys" %} {% set active_page = "admin_keys" %}
{% if create or (key is not none and key.role.name != "User") %}{% set hide_opts = True %}{%else %}{% set hide_opts = False %}{% endif %}
{% block title %} {% block title %}
<title>Edit Key - {{ SITE_NAME }}</title> <title>Edit Key - {{ SITE_NAME }}</title>
{% endblock %} {% endblock %}
@ -49,10 +50,26 @@
class="glyphicon glyphicon-pencil form-control-feedback"></span> class="glyphicon glyphicon-pencil form-control-feedback"></span>
</div> </div>
</div> </div>
<div class="box-header with-border"> <div class="box-header with-border key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<h3 class="box-title">Access Control</h3> <h3 class="box-title">Accounts Access Control</h3>
</div> </div>
<div class="box-body"> <div class="box-body key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<p>This key will be linked to the accounts on the right,</p>
<p>thus granting access to domains owned by the selected accounts.</p>
<p>Click on accounts to move between the columns.</p>
<div class="form-group col-xs-2">
<select multiple="multiple" class="form-control" id="key_multi_account"
name="key_multi_account">
{% for account in accounts %}
<option {% if key and account in key.accounts %}selected{% endif %} value="{{ account.name }}">{{ account.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="box-header with-border key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<h3 class="box-title">Domain Access Control</h3>
</div>
<div class="box-body key-opts"{% if hide_opts %} style="display: none;"{% endif %}>
<p>This key will have acess to the domains on the right.</p> <p>This key will have acess to the domains on the right.</p>
<p>Click on domains to move between the columns.</p> <p>Click on domains to move between the columns.</p>
<div class="form-group col-xs-2"> <div class="form-group col-xs-2">
@ -66,7 +83,7 @@
</div> </div>
<div class="box-footer"> <div class="box-footer">
<button type="submit" <button type="submit"
class="btn btn-flat btn-primary">{% if create %}Create{% else %}Update{% endif %} class="btn btn-flat btn-primary" id="key_submit">{% if create %}Create{% else %}Update{% endif %}
Key</button> Key</button>
</div> </div>
</form> </form>
@ -82,7 +99,7 @@
<p>Fill in all the fields in the form to the left.</p> <p>Fill in all the fields in the form to the left.</p>
<p><strong>Role</strong> The role of the key.</p> <p><strong>Role</strong> The role of the key.</p>
<p><strong>Description</strong> The key description.</p> <p><strong>Description</strong> The key description.</p>
<p><strong>Access Control</strong> The domains which the key has access to.</p> <p><strong>Access Control</strong> The domains or accounts which the key has access to.</p>
</div> </div>
</div> </div>
</div> </div>
@ -91,6 +108,48 @@
{% endblock %} {% endblock %}
{% block extrascripts %} {% block extrascripts %}
<script> <script>
$('form').submit(function (e) {
var selectedRole = $("#key_role").val();
var selectedDomains = $("#key_multi_domain option:selected").length;
var selectedAccounts = $("#key_multi_account option:selected").length;
var warn_modal = $("#modal_warning");
if (selectedRole != "User" && selectedDomains > 0 && selectedAccounts > 0){
var warning = "Administrator and Operators have access to all domains. Your domain an account selection won't be saved.";
e.preventDefault(e);
warn_modal.modal('show');
}
if (selectedRole == "User" && selectedDomains == 0 && selectedAccounts == 0){
var warning = "User role must have at least one account or one domain bound. None selected.";
e.preventDefault(e);
warn_modal.modal('show');
}
warn_modal.find('.modal-body p').text(warning);
warn_modal.find('#button_key_confirm_warn').click(clearModal);
});
function clearModal(){
$("#modal_warning").modal('hide');
}
$('#key_role').on('change', function (e) {
var optionSelected = $("option:selected", this);
if (this.value != "User") {
// Clear the visible list
$('#ms-key_multi_domain .ms-selection li').each(function(){ $(this).css('display', 'none');})
$('#ms-key_multi_domain .ms-selectable li').each(function(){ $(this).css('display', '');})
$('#ms-key_multi_account .ms-selection li').each(function(){ $(this).css('display', 'none');})
$('#ms-key_multi_account .ms-selectable li').each(function(){ $(this).css('display', '');})
// Deselect invisible selectbox
$('#key_multi_domain option:selected').each(function(){ $(this).prop('selected', false);})
$('#key_multi_account option:selected').each(function(){ $(this).prop('selected', false);})
// Hide the lists
$(".key-opts").hide();
}
else {
$(".key-opts").show();
}
});
$("#key_multi_domain").multiSelect({ $("#key_multi_domain").multiSelect({
selectableHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>", selectableHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>",
selectionHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>", selectionHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Domain Name'>",
@ -126,6 +185,41 @@
this.qs2.cache(); this.qs2.cache();
} }
}); });
$("#key_multi_account").multiSelect({
selectableHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Account Name'>",
selectionHeader: "<input type='text' class='search-input' autocomplete='off' placeholder='Account Name'>",
afterInit: function (ms) {
var that = this,
$selectableSearch = that.$selectableUl.prev(),
$selectionSearch = that.$selectionUl.prev(),
selectableSearchString = '#' + that.$container.attr('id') + ' .ms-elem-selectable:not(.ms-selected)',
selectionSearchString = '#' + that.$container.attr('id') + ' .ms-elem-selection.ms-selected';
that.qs1 = $selectableSearch.quicksearch(selectableSearchString)
.on('keydown', function (e) {
if (e.which === 40) {
that.$selectableUl.focus();
return false;
}
});
that.qs2 = $selectionSearch.quicksearch(selectionSearchString)
.on('keydown', function (e) {
if (e.which == 40) {
that.$selectionUl.focus();
return false;
}
});
},
afterSelect: function () {
this.qs1.cache();
this.qs2.cache();
},
afterDeselect: function () {
this.qs1.cache();
this.qs2.cache();
}
});
{% if plain_key %} {% if plain_key %}
$(document.body).ready(function () { $(document.body).ready(function () {
var modal = $("#modal_show_key"); var modal = $("#modal_show_key");
@ -165,4 +259,25 @@
</div> </div>
<!-- /.modal-dialog --> <!-- /.modal-dialog -->
</div> </div>
<div class="modal fade" id="modal_warning">
<div class="modal-dialog">
<div class="modal-content modal-sm">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" id="button_close_warn_modal">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">WARNING</h4>
</div>
<div class="modal-body">
<p></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-flat btn-primary center-block" id="button_key_confirm_warn">
OK</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
{% endblock %} {% endblock %}

View file

@ -35,6 +35,7 @@
<th>Role</th> <th>Role</th>
<th>Description</th> <th>Description</th>
<th>Domains</th> <th>Domains</th>
<th>Accounts</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -45,6 +46,7 @@
<td>{{ key.role.name }}</td> <td>{{ key.role.name }}</td>
<td>{{ key.description }}</td> <td>{{ key.description }}</td>
<td>{% for domain in key.domains %}{{ domain.name }}{% if not loop.last %}, {% endif %}{% endfor %}</td> <td>{% for domain in key.domains %}{{ domain.name }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td>{% for account in key.accounts %}{{ account.name }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td width="15%"> <td width="15%">
<button type="button" class="btn btn-flat btn-success button_edit" <button type="button" class="btn btn-flat btn-success button_edit"
onclick="window.location.href='{{ url_for('admin.edit_key', key_id=key.id) }}'"> onclick="window.location.href='{{ url_for('admin.edit_key', key_id=key.id) }}'">

View file

@ -4,7 +4,11 @@
{% block dashboard_stat %} {% block dashboard_stat %}
<section class="content-header"> <section class="content-header">
<h1> <h1>
{% if record_name and record_type %}
Record changelog: <b>{{ record_name}} &nbsp {{ record_type }}</b>
{% else %}
Domain changelog: <b>{{ domain.name | pretty_domain_name }}</b> Domain changelog: <b>{{ domain.name | pretty_domain_name }}</b>
{% endif %}
</h1> </h1>
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{{ url_for('dashboard.dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li> <li><a href="{{ url_for('dashboard.dashboard') }}"><i class="fa fa-dashboard"></i> Home</a></li>

View file

@ -50,7 +50,7 @@
</div> </div>
{% if SETTING.get('otp_field_enabled') %} {% if SETTING.get('otp_field_enabled') %}
<div class="form-group"> <div class="form-group">
<input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken"> <input type="otptoken" class="form-control" placeholder="OTP Token" name="otptoken" autocomplete="off">
</div> </div>
{% endif %} {% endif %}
{% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %} {% if SETTING.get('ldap_enabled') and SETTING.get('local_db_enabled') %}

View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Welcome - {{ SITE_NAME }}</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.png') }}">
<!-- Tell the browser to be responsive to screen width -->
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
{% assets "css_login" -%}
<link rel="stylesheet" href="{{ ASSET_URL }}">
{%- endassets %}
{% if SETTING.get('custom_css') %}
<link rel="stylesheet" href="/static/custom/{{ SETTING.get('custom_css') }}">
{% endif %}
</head>
<body class="hold-transition register-page">
<div class="register-box">
<div class="register-logo">
<a><b>PowerDNS</b>-Admin</a>
</div>
<div class="register-box-body">
{% if error %}
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
{{ error }}
</div>
{% endif %}
Welcome, {{user.firstname}}! <br />
You will need a Token on login. <br />
Your QR code is:
<div id="token_information">
{% if qrcode_image == None %}
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
{% else %}
<p><img id="qrcode" src="data:image/svg+xml;utf8;base64, {{qrcode_image}}"></p>
{% endif %}
<p>
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</p>
You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank"
href="https://apps.apple.com/us/app/google-authenticator/id388497605">iOS</a>)
<br />
or FreeOTP (<a target="_blank"
href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp&hl=en">Android</a>
- <a target="_blank"
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone <br /> to scan the QR code or type the secret key.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code <br />
and secret key, and nobody can capture them.</i></strong></font>
</div>
</br>
Please input your OTP token to continue, to ensure the seed has been scanned correctly.
<form action="" method="post" data-toggle="validator">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<input type="text" class="form-control" placeholder="OTP Token" name="otptoken"
data-error="Please input your OTP token" required>
</div>
<div class="row">
<div class="col-xs-4">
<button type="submit" class="btn btn-flat btn-primary btn-block">Continue</button>
</div>
</div>
</form>
</div>
<div class="login-box-footer">
<center>
<p>Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin">PowerDNS-Admin</a></p>
</center>
</div>
</div>
</body>
{% assets "js_login" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
{% assets "js_validation" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{%- endassets %}
</html>

View file

@ -114,6 +114,14 @@
{% if current_user.otp_secret %} {% if current_user.otp_secret %}
<div id="token_information"> <div id="token_information">
<p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p> <p><img id="qrcode" src="{{ url_for('user.qrcode') }}"></p>
<div style="position: relative; left: 15px">
Your secret key is: <br />
<form>
<input type=text id="otp_secret" value={{current_user.otp_secret}} readonly>
<button type=button style="position:relative; right:28px" onclick="copy_otp_secret_to_clipboard()"> <i class="fa fa-clipboard"></i> </button>
<br /><font color="red" id="copy_tooltip" style="visibility:collapse">Copied.</font>
</form>
</div>
You can use Google Authenticator (<a target="_blank" You can use Google Authenticator (<a target="_blank"
href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a> href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Android</a>
- <a target="_blank" - <a target="_blank"
@ -124,8 +132,8 @@
href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>) href="https://itunes.apple.com/en/app/freeotp-authenticator/id872559395?mt=8">iOS</a>)
on your smartphone to scan the QR code. on your smartphone to scan the QR code.
<br /> <br />
<font color="red"><strong><i>Make sure only you can see this QR Code and <font color="red"><strong><i>Make sure only you can see this QR Code and secret key and
nobody can capture it.</i></strong></font> nobody can capture them.</i></strong></font>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -8,7 +8,7 @@ mysqlclient==2.0.1
configobj==5.0.6 configobj==5.0.6
bcrypt>=3.1.7 bcrypt>=3.1.7
requests==2.24.0 requests==2.24.0
python-ldap==3.3.1 python-ldap==3.4.0
pyotp==2.4.0 pyotp==2.4.0
qrcode==6.1 qrcode==6.1
dnspython>=1.16.0 dnspython>=1.16.0

File diff suppressed because it is too large Load diff