Merge branch 'master' into historyfix

This commit is contained in:
Rauno
2023-02-20 10:32:52 +02:00
committed by GitHub
78 changed files with 6267 additions and 4945 deletions

View File

@ -1,5 +1,5 @@
from .base import (
csrf, login_manager, handle_bad_request, handle_unauthorized_access,
captcha, csrf, login_manager, handle_bad_request, handle_unauthorized_access,
handle_access_forbidden, handle_page_not_found, handle_internal_server_error
)
@ -14,6 +14,7 @@ from .api import api_bp, apilist_bp
def init_app(app):
login_manager.init_app(app)
csrf.init_app(app)
captcha.init_app(app)
app.register_blueprint(index_bp)
app.register_blueprint(user_bp)

View File

@ -610,14 +610,21 @@ def manage_user():
@operator_role_required
def edit_account(account_name=None):
users = User.query.all()
account = Account.query.filter(
Account.name == account_name).first()
all_accounts = Account.query.all()
accounts = {acc.id: acc for acc in all_accounts}
domains = Domain.query.all()
if request.method == 'GET':
if account_name is None:
if account_name is None or not account:
return render_template('admin_edit_account.html',
account=None,
account_user_ids=[],
users=users,
domains=domains,
accounts=accounts,
create=1)
else:
account = Account.query.filter(
Account.name == account_name).first()
@ -626,11 +633,14 @@ def edit_account(account_name=None):
account=account,
account_user_ids=account_user_ids,
users=users,
domains=domains,
accounts=accounts,
create=0)
if request.method == 'POST':
fdata = request.form
new_user_list = request.form.getlist('account_multi_user')
new_domain_list = request.form.getlist('account_domains')
# on POST, synthesize account and account_user_ids from form data
if not account_name:
@ -654,6 +664,8 @@ def edit_account(account_name=None):
account=account,
account_user_ids=account_user_ids,
users=users,
domains=domains,
accounts=accounts,
create=create,
invalid_accountname=True)
@ -662,19 +674,33 @@ def edit_account(account_name=None):
account=account,
account_user_ids=account_user_ids,
users=users,
domains=domains,
accounts=accounts,
create=create,
duplicate_accountname=True)
result = account.create_account()
history = History(msg='Create account {0}'.format(account.name),
created_by=current_user.username)
else:
result = account.update_account()
history = History(msg='Update account {0}'.format(account.name),
created_by=current_user.username)
if result['status']:
account = Account.query.filter(
Account.name == account_name).first()
old_domains = Domain.query.filter(Domain.account_id == account.id).all()
for domain_name in new_domain_list:
domain = Domain.query.filter(
Domain.name == domain_name).first()
if account.id != domain.account_id:
Domain(name=domain_name).assoc_account(account.id)
for domain in old_domains:
if domain.name not in new_domain_list:
Domain(name=domain.name).assoc_account(None)
history = History(msg='{0} account {1}'.format('Create' if create else 'Update', account.name),
created_by=current_user.username)
account.grant_privileges(new_user_list)
history.add()
return redirect(url_for('admin.manage_account'))
@ -769,7 +795,7 @@ class DetailedHistory():
if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped">
<tr><td>Domain type:</td><td>{{ domaintype }}</td></tr>
<tr><td>Domain Type:</td><td>{{ domaintype }}</td></tr>
<tr><td>Account:</td><td>{{ account }}</td></tr>
</table>
""",
@ -778,22 +804,23 @@ class DetailedHistory():
elif 'authenticator' in detail_dict: # this is a user authentication
self.detailed_msg = render_template_string("""
<table class="table table-bordered table-striped" style="width:565px;">
<thead>
<tr>
<th colspan="3" style="background: rgba({{ background_rgba }});">
<p style="color:white;">User {{ username }} authentication {{ auth_result }}</p>
</th>
</tr>
</thead>
<table class="table table-bordered table-striped"">
<tbody>
<tr>
<td>Authenticator Type:</td>
<td colspan="2">{{ authenticator }}</td>
<td>Username:</td>
<td>{{ username }}</td>
</tr>
<tr>
<td>IP Address</td>
<td colspan="2">{{ ip_address }}</td>
<td>Authentication Result:</td>
<td>{{ auth_result }}</td>
</tr>
<tr>
<td>Authenticator Type:</td>
<td>{{ authenticator }}</td>
</tr>
<tr>
<td>IP Address:</td>
<td>{{ ip_address }}</td>
</tr>
</tbody>
</table>
@ -890,6 +917,16 @@ class DetailedHistory():
''',
history_status=DetailedHistory.get_key_val(detail_dict, 'status'),
history_msg=DetailedHistory.get_key_val(detail_dict, 'msg'))
elif 'Update domain' in history.msg and 'associate account' in history.msg: # When an account gets associated or dissociate with domains
self.detailed_msg = render_template_string('''
<table class="table table-bordered table-striped">
<tr><td>Associate: </td><td>{{ history_assoc_account }}</td></tr>
<tr><td>Dissociate:</td><td>{{ history_dissoc_account }}</td></tr>
</table>
''',
history_assoc_account=DetailedHistory.get_key_val(detail_dict, 'assoc_account'),
history_dissoc_account=DetailedHistory.get_key_val(detail_dict, 'dissoc_account'))
# check for lower key as well for old databases
@staticmethod
@ -927,6 +964,13 @@ def history():
'msg': 'You do not have permission to remove history.'
}), 401)
if Setting().get('preserve_history'):
return make_response(
jsonify({
'status': 'error',
'msg': 'History removal is not allowed (toggle preserve_history in settings).'
}), 401)
h = History()
result = h.remove_all()
if result:
@ -1282,6 +1326,7 @@ def setting_basic():
'otp_field_enabled',
'otp_force',
'pdns_api_timeout',
'preserve_history',
'pretty_ipv6_ptr',
'record_helper',
'record_quick_edit',

View File

@ -1,22 +1,21 @@
import json
from urllib.parse import urljoin
import secrets
import string
from base64 import b64encode
from flask import (
Blueprint, g, request, abort, current_app, make_response, jsonify,
)
from urllib.parse import urljoin
from flask import (Blueprint, g, request, abort, current_app, make_response, jsonify)
from flask_login import current_user
from .base import csrf
from ..models.base import db
from ..models import (
User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey,
Role,
from ..decorators import (
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
apikey_can_create_domain, apikey_can_remove_domain,
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
api_role_can, apikey_or_basic_auth,
callback_if_request_body_contains_key, allowed_record_types, allowed_record_ttl
)
from ..lib import utils, helper
from ..lib.schema import (
ApiKeySchema, DomainSchema, ApiPlainKeySchema, UserSchema, AccountSchema,
UserDetailedSchema,
)
from ..lib.errors import (
StructuredException,
DomainNotExists, DomainAlreadyExists, DomainAccessForbidden,
@ -26,15 +25,15 @@ from ..lib.errors import (
UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail,
UserUpdateFailEmail, InvalidAccountNameException
)
from ..decorators import (
api_basic_auth, api_can_create_domain, is_json, apikey_auth,
apikey_can_create_domain, apikey_can_remove_domain,
apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec,
api_role_can, apikey_or_basic_auth,
callback_if_request_body_contains_key, allowed_record_types, allowed_record_ttl
from ..lib.schema import (
ApiKeySchema, DomainSchema, ApiPlainKeySchema, UserSchema, AccountSchema,
UserDetailedSchema,
)
import secrets
import string
from ..models import (
User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey,
Role,
)
from ..models.base import db
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
apilist_bp = Blueprint('apilist', __name__, url_prefix='/')
@ -56,10 +55,10 @@ def get_user_domains():
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).all()
db.or_(
DomainUser.user_id == current_user.id,
AccountUser.user_id == current_user.id
)).all()
return domains
@ -71,10 +70,10 @@ def get_user_apikeys(domain_name=None):
.outerjoin(Account, Domain.account_id == Account.id) \
.outerjoin(AccountUser, Account.id == AccountUser.account_id) \
.filter(
db.or_(
DomainUser.user_id == User.id,
AccountUser.user_id == User.id
)
db.or_(
DomainUser.user_id == User.id,
AccountUser.user_id == User.id
)
) \
.filter(User.id == current_user.id)
@ -167,12 +166,7 @@ def handle_request_is_not_json(err):
def before_request():
# Check site is in maintenance mode
maintenance = Setting().get('maintenance')
if (
maintenance and current_user.is_authenticated and
current_user.role.name not in [
'Administrator', 'Operator'
]
):
if (maintenance and current_user.is_authenticated and current_user.role.name not in ['Administrator', 'Operator']):
return make_response(
jsonify({
"status": False,
@ -224,14 +218,13 @@ def api_login_create_zone():
history = History(msg='Add domain {0}'.format(
data['name'].rstrip('.')),
detail=json.dumps(data),
created_by=current_user.username,
domain_id=domain_id)
detail=json.dumps(data),
created_by=current_user.username,
domain_id=domain_id)
history.add()
if current_user.role.name not in ['Administrator', 'Operator']:
current_app.logger.debug(
"User is ordinary user, assigning created domain")
current_app.logger.debug("User is ordinary user, assigning created domain")
domain = Domain(name=data['name'].rstrip('.'))
domain.update()
domain.grant_privileges([current_user.id])
@ -299,9 +292,9 @@ def api_login_delete_zone(domain_name):
history = History(msg='Delete domain {0}'.format(
utils.pretty_domain_name(domain_name)),
detail='',
created_by=current_user.username,
domain_id=domain_id)
detail='',
created_by=current_user.username,
domain_id=domain_id)
history.add()
except Exception as e:
@ -326,14 +319,14 @@ def api_generate_apikey():
if 'domains' not in data:
domains = []
elif not isinstance(data['domains'], (list, )):
elif not isinstance(data['domains'], (list,)):
abort(400)
else:
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, )):
elif not isinstance(data['accounts'], (list,)):
abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
@ -385,8 +378,7 @@ def api_generate_apikey():
user_domain_list = [item.name for item in user_domain_obj_list]
current_app.logger.debug("Input domain list: {0}".format(domain_list))
current_app.logger.debug(
"User domain list: {0}".format(user_domain_list))
current_app.logger.debug("User domain list: {0}".format(user_domain_list))
inter = set(domain_list).intersection(set(user_domain_list))
@ -539,14 +531,14 @@ def api_update_apikey(apikey_id):
if 'domains' not in data:
domains = None
elif not isinstance(data['domains'], (list, )):
elif not isinstance(data['domains'], (list,)):
abort(400)
else:
domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']]
if 'accounts' not in data:
accounts = None
elif not isinstance(data['accounts'], (list, )):
elif not isinstance(data['accounts'], (list,)):
abort(400)
else:
accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']]
@ -963,9 +955,7 @@ def api_delete_account(account_id):
account = account_list[0]
else:
abort(404)
current_app.logger.debug(
f'Deleting Account {account.name}'
)
current_app.logger.debug(f'Deleting Account {account.name}')
# Remove account association from domains first
if len(account.domains) > 0:
@ -1047,7 +1037,7 @@ def api_remove_account_user(account_id, user_id):
user_list = User.query.join(AccountUser).filter(
AccountUser.account_id == account_id,
AccountUser.user_id == user_id,
).all()
).all()
if not user_list:
abort(404)
if not account.remove_user(user):
@ -1194,17 +1184,13 @@ def api_get_zones(server_id):
return jsonify(domain_schema.dump(domain_obj_list)), 200
else:
resp = helper.forward_request()
if (
g.apikey.role.name not in ['Administrator', 'Operator']
and resp.status_code == 200
):
if (g.apikey.role.name not in ['Administrator', 'Operator'] and resp.status_code == 200):
domain_list = [d['name']
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)))
current_app.logger.debug("Account domains: {}".format('/'.join(accounts_domains)))
content = json.dumps([i for i in json.loads(resp.content)
if i['name'].rstrip('.') in allowed_domains])
return content, resp.status_code, resp.headers.items()
@ -1225,6 +1211,7 @@ def api_server_config_forward(server_id):
resp = helper.forward_request()
return resp.content, resp.status_code, resp.headers.items()
# The endpoint to synchronize Domains in background
@api_bp.route('/sync_domains', methods=['GET'])
@apikey_or_basic_auth
@ -1233,6 +1220,7 @@ def sync_domains():
domain.update()
return 'Finished synchronization in background', 200
@api_bp.route('/health', methods=['GET'])
@apikey_auth
def health():
@ -1246,7 +1234,8 @@ def health():
try:
domain.get_domain_info(domain_to_query.name)
except Exception as e:
current_app.logger.error("Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name))
current_app.logger.error(
"Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name))
return make_response("Down", 503)
return make_response("Up", 200)

View File

@ -3,10 +3,12 @@ import base64
from flask import render_template, url_for, redirect, session, request, current_app
from flask_login import LoginManager
from flask_seasurf import SeaSurf
from flask_session_captcha import FlaskSessionCaptcha
from ..models.user import User
captcha = FlaskSessionCaptcha()
csrf = SeaSurf()
login_manager = LoginManager()

View File

@ -89,14 +89,14 @@ def domain(domain_name):
# - Find a way to make it consistent, or
# - Only allow one comment for that case
if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'):
pretty_v6 = Setting().get('pretty_ipv6_ptr')
for r in rrsets:
if r['type'] in records_allow_to_edit:
r_name = r['name'].rstrip('.')
# If it is reverse zone and pretty_ipv6_ptr setting
# is enabled, we reformat the name for ipv6 records.
if Setting().get('pretty_ipv6_ptr') and r[
'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name:
if pretty_v6 and r['type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name:
r_name = dns.reversename.to_address(
dns.name.from_text(r_name))

View File

@ -10,7 +10,7 @@ from yaml import Loader, load
from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort
from flask_login import login_user, logout_user, login_required, current_user
from .base import csrf, login_manager
from .base import captcha, csrf, login_manager
from ..lib import utils
from ..decorators import dyndns_login_required
from ..models.base import db
@ -400,7 +400,7 @@ def login():
desc_prop = Setting().get('oidc_oauth_account_description_property')
account_to_add = []
#If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP).
#If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP).
if name_prop in me and desc_prop in me:
accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop]
accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop]
@ -415,7 +415,7 @@ def login():
account_to_add.append(account)
user_accounts = user.get_accounts()
# Add accounts
# Add accounts
for account in account_to_add:
if account not in user_accounts:
account.add_user(user)
@ -581,7 +581,7 @@ def get_azure_groups(uri):
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:
if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret and session['authentication_type'] not in ['OAuth']:
user.update_profile(enable_otp=True)
user_id = current_user.id
prepare_welcome_user(user_id)
@ -651,50 +651,73 @@ def logout():
@index_bp.route('/register', methods=['GET', 'POST'])
def register():
if Setting().get('signup_enabled'):
if request.method == 'GET':
return render_template('register.html')
elif request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
firstname = request.form.get('firstname', '').strip()
lastname = request.form.get('lastname', '').strip()
email = request.form.get('email', '').strip()
rpassword = request.form.get('rpassword', '')
CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE')
if Setting().get('signup_enabled'):
if current_user.is_authenticated:
return redirect(url_for('index.index'))
if request.method == 'GET':
return render_template('register.html', captcha_enable=CAPTCHA_ENABLE)
elif request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
firstname = request.form.get('firstname', '').strip()
lastname = request.form.get('lastname', '').strip()
email = request.form.get('email', '').strip()
rpassword = request.form.get('rpassword', '')
if not username or not password or not email:
return render_template(
'register.html', error='Please input required information')
is_valid_email = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$')
if password != rpassword:
return render_template(
'register.html',
error="Password confirmation does not match")
error_messages = {}
if not firstname:
error_messages['firstname'] = 'First Name is required'
if not lastname:
error_messages['lastname'] = 'Last Name is required'
if not username:
error_messages['username'] = 'Username is required'
if not password:
error_messages['password'] = 'Password is required'
if not rpassword:
error_messages['rpassword'] = 'Password confirmation is required'
if not email:
error_messages['email'] = 'Email is required'
if not is_valid_email.match(email):
error_messages['email'] = 'Invalid email address'
if password != rpassword:
error_messages['password'] = 'Password confirmation does not match'
error_messages['rpassword'] = 'Password confirmation does not match'
user = User(username=username,
plain_text_password=password,
firstname=firstname,
lastname=lastname,
email=email)
if not captcha.validate():
return render_template(
'register.html', error='Invalid CAPTCHA answer', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE)
try:
result = user.create_local_user()
if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
if Setting().get('otp_force') and Setting().get('otp_field_enabled'):
user.update_profile(enable_otp=True)
prepare_welcome_user(user.id)
return redirect(url_for('index.welcome'))
else:
return redirect(url_for('index.login'))
else:
return render_template('register.html',
error=result['msg'])
except Exception as e:
return render_template('register.html', error=e)
if error_messages:
return render_template('register.html', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE)
user = User(username=username,
plain_text_password=password,
firstname=firstname,
lastname=lastname,
email=email
)
try:
result = user.create_local_user()
if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
if Setting().get('otp_force') and Setting().get('otp_field_enabled'):
user.update_profile(enable_otp=True)
prepare_welcome_user(user.id)
return redirect(url_for('index.welcome'))
else:
return redirect(url_for('index.login'))
else:
return render_template('register.html',
error=result['msg'], captcha_enable=CAPTCHA_ENABLE)
except Exception as e:
return render_template('register.html', error=e, captcha_enable=CAPTCHA_ENABLE)
else:
return render_template('errors/404.html'), 404
return render_template('errors/404.html'), 404
# Show welcome page on first login if otp_force is enabled