From 8cdf549c4cf37dbd88f4a046be94f58abd28b531 Mon Sep 17 00:00:00 2001 From: Simon Pirkelmann Date: Sun, 28 Mar 2021 21:50:44 +0200 Subject: [PATCH] added ability to backup and restore admin users for super admins --- .../templates/admins.html | 7 ++ imaginaerraum_door_admin/webapp.py | 118 ++++++++++++++---- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/imaginaerraum_door_admin/templates/admins.html b/imaginaerraum_door_admin/templates/admins.html index 9d348e0..6770097 100644 --- a/imaginaerraum_door_admin/templates/admins.html +++ b/imaginaerraum_door_admin/templates/admins.html @@ -47,4 +47,11 @@ +
+ +
+
+ + +
{% endblock %} \ No newline at end of file diff --git a/imaginaerraum_door_admin/webapp.py b/imaginaerraum_door_admin/webapp.py index a5becec..c980b80 100644 --- a/imaginaerraum_door_admin/webapp.py +++ b/imaginaerraum_door_admin/webapp.py @@ -1,23 +1,27 @@ import os -from flask import Flask, render_template, request, flash, redirect, session, url_for +from flask import Flask, render_template, request, flash, redirect, session, send_file +from werkzeug.utils import secure_filename from flask_wtf import FlaskForm from wtforms.fields.html5 import DateField, EmailField from wtforms.fields import StringField, BooleanField from wtforms.validators import DataRequired, ValidationError, EqualTo from flask_sqlalchemy import SQLAlchemy -from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password, uia_email_mapper, current_user, roles_required +from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password, uia_email_mapper, \ + current_user, roles_required from flask_security.models import fsqla_v2 as fsqla from flask_security.forms import LoginForm, Required, PasswordField from flask_security.utils import find_user, verify_password from flask_mail import Mail from email_validator import validate_email +import json import secrets import bleach import ldap3 from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError from pathlib import Path import logging +import tempfile from datetime import date from .door_handle import DoorHandle @@ -43,18 +47,22 @@ class TokenForm(FlaskForm): active = BooleanField('Aktiv?') dsgvo = BooleanField('Einwilligung Nutzungsbedingungen erfragt?', validators=[DataRequired()]) -class TokenDeleteForm(FlaskForm): + +class ConfirmDeleteForm(FlaskForm): name = StringField('Name', validators=[DataRequired(), EqualTo('name_confirm', 'Name stimmt nicht überein')]) name_confirm = StringField('Name confirm') + class AdminCreationForm(FlaskForm): name = StringField('Name', validators=[DataRequired()]) email = EmailField('E-Mail', validators=[DataRequired()]) + def uia_username_mapper(identity): # we allow pretty much anything - but we bleach it. return bleach.clean(identity, strip=True) + def create_application(config): # set up logging for the web app logger = logging.getLogger('webapp') @@ -136,7 +144,7 @@ def create_application(config): # LDAP ldap_server = ldap3.Server(config.ldap_url) - local_ldap_cache = {} # dict for caching LDAP authorization locally (stores username + hashed password) + local_ldap_cache = {} # dict for caching LDAP authorization locally (stores username + hashed password) def validate_ldap(user, password): """Validate the user and password through an LDAP server. @@ -158,7 +166,7 @@ def create_application(config): try: con = ldap3.Connection(ldap_server, user="uid=%s,ou=Users,dc=imaginaerraum,dc=de" % (user.username,), - password=password, auto_bind=True) + password=password, auto_bind=True) except ldap3.core.exceptions.LDAPBindError: # server reachable but user unauthorized -> fail return False @@ -200,8 +208,7 @@ def create_application(config): user_datastore = SQLAlchemyUserDatastore(db, User, Role) security = Security(app, user_datastore, login_form=ExtendedLoginForm) - - + # admin user management @app.route('/manage_admins', methods=['GET', 'POST']) @roles_required('super_admin') def manage_admins(): @@ -212,15 +219,17 @@ def create_application(config): return render_template('admins.html', admin_data=admin_data, form=form) elif form.validate(): if user_datastore.find_user(username=form.name.data) is not None or \ - user_datastore.find_user(email=form.email.data) is not None: - flash("A user with the same name or email is already registered!") - return redirect('/manage_admins') + user_datastore.find_user(email=form.email.data) is not None: + flash("A user with the same name or email is already registered!") + return redirect('/manage_admins') else: pw = secrets.token_urlsafe(16) new_user = user_datastore.create_user(username=form.name.data, email=form.email.data, roles=['admin'], password=hash_password(pw)) - logger.info(f"Super admin {current_user.username} created new admin account for {new_user.username} <{new_user.email}>") - flash(f"An account for the new admin user {new_user.username} has been created. Use the randomly generated password {pw} to log in.") + logger.info( + f"Super admin {current_user.username} created new admin account for {new_user.username} <{new_user.email}>") + flash( + f"An account for the new admin user {new_user.username} has been created. Use the randomly generated password {pw} to log in.") db.session.commit() return redirect('/manage_admins') @@ -239,7 +248,7 @@ def create_application(config): return redirect('/manage_admins') # set up form for confirming deletion - form = TokenDeleteForm() + form = ConfirmDeleteForm() form.name_confirm.data = username if request.method == 'GET': @@ -247,9 +256,13 @@ def create_application(config): return render_template('delete_admin.html', username=username, form=form) elif form.validate(): user_datastore.delete_user(user) + flash(f"Username {username} was deleted.") logger.info(f"Super admin {current_user.username} deleted admin user {username}") db.session.commit() return redirect('/manage_admins') + else: + flash("Username does not match. User was not deleted!") + return redirect('/manage_admins') @app.route('/admin_toggle_active/') @roles_required('super_admin') @@ -269,11 +282,74 @@ def create_application(config): db.session.commit() return redirect('/manage_admins') + @app.route('/backup_user_datastore') + @roles_required('super_admin') + def backup_user_datastore(): + # get list of defined admin users for backup + users = user_datastore.user_model.query.all() + user_data = [{'username': u.username, 'email': u.email, 'active': u.is_active, 'password_hash': u.password} + for u in users if not u.has_role('super_admin')] + try: + with tempfile.TemporaryDirectory() as tmpdir: + file = Path(tmpdir, 'user_data.txt') + file.write_text(json.dumps(user_data)) + return send_file(file, as_attachment=True, cache_timeout=-1) + except Exception as e: + return str(e) + + @app.route('/restore_user_datastore', methods=['POST']) + @roles_required('super_admin') + def restore_user_datastore(): + # check if the post request has the file part + if 'file' not in request.files: + flash('Keine Datei ausgewählt!') + return redirect(request.url) + file = request.files['file'] + # if user does not select file, browser also + # submit an empty part without filename + if file.filename == '': + flash('Keine Datei ausgewählt!') + return redirect('/manage_admins') + filename = secure_filename(file.filename) + if file and filename.endswith('.txt'): + data = file.stream.read() + try: + # check validity of user data + user_data = json.loads(data) + valid = type(user_data) == list + valid &= all(type(d) == dict for d in user_data) + if valid: + for d in user_data: + entry_valid = set(d.keys()) == { 'active', 'email', 'password_hash', 'username'} + entry_valid &= all(len(d[key]) > 0 for key in ['email', 'password_hash', 'username']) + entry_valid &= type(d['active']) == bool + validate_email(d['email']) + if entry_valid: + existing_user = user_datastore.find_user(username=d['username'], email=d['email']) + if existing_user is None: + user_datastore.create_user(username=d['username'], email=d['email'], password=d['password_hash'], + roles=['admin'], active=d['active']) + flash(f"Admin Account für Benutzer '{d['username']} wurde wiederhergestellt.") + else: + flash(f"Admin '{d['username']} existiert bereits. Eintrag wird übersprungen.") + else: + raise ValueError(f"Ungültige Daten für User Entry {d}") + else: + raise ValueError("Admin User Datei hat ungültiges Format.") + except Exception as e: + flash(f"Die Datei konnte nicht gelesen werden. Exception: {e}") + return redirect('/manage_admins') + flash("Admin Benutzer aus Datei gelesen.") + db.session.commit() + else: + flash("Ungültige Dateiendung") + return redirect('/manage_admins') + # main page @app.route('/') def door_lock(): return render_template('index.html', door_state=door.state, encoder_position=door.encoder_position) - + # token overview @app.route('/tokens') @auth_required() def list_tokens(): @@ -282,7 +358,7 @@ def create_application(config): inactive_tokens = {t: data for t, data in tokens.items() if data['inactive']} return render_template('tokens.html', assigned_tokens=assigned_tokens, inactive_tokens=inactive_tokens) - + # routes for registering, editing and deleting tokens @app.route('/register-token', methods=['GET', 'POST']) @auth_required() def register(): @@ -315,7 +391,6 @@ def create_application(config): else: return render_template('register.html', token=door.get_most_recent_token(), form=form) - @app.route('/edit-token/', methods=['GET', 'POST']) @auth_required() def edit_token(token): @@ -332,7 +407,7 @@ def create_application(config): """ form = TokenForm(request.form) form.dsgvo.validators = [] # we skip the validation of the DSGVO checkbox here because we assume the user agreed - # to it before + # to it before if request.method == 'GET': tokens = door.get_tokens() if token in tokens: @@ -371,7 +446,6 @@ def create_application(config): else: return render_template('edit.html', token=token, form=form) - @app.route('/store-token') @auth_required() def store_token(): @@ -394,7 +468,6 @@ def create_application(config): flash(f"Error during store_tokens. Exception: {e}") return redirect('/tokens') - @app.route('/delete-token/', methods=['GET', 'POST']) @auth_required() def delete_token(token): @@ -411,7 +484,7 @@ def create_application(config): token_to_delete = tokens[token] # set up form for confirming deletion - form = TokenDeleteForm() + form = ConfirmDeleteForm() form.name_confirm.data = token_to_delete['name'] if request.method == 'GET': @@ -429,13 +502,13 @@ def create_application(config): return redirect('/tokens') else: # form validation failed -> return to token overview and flash message - flash(f"Der eingegebene Name stimmt nicht überein. Der Token {token} von {token_to_delete['name']} wurde nicht gelöscht.") + flash( + f"Der eingegebene Name stimmt nicht überein. Der Token {token} von {token_to_delete['name']} wurde nicht gelöscht.") return redirect('/tokens') else: flash(f'Ungültiger Token {token} für Löschung.') return redirect('/tokens') - @app.route('/deactivate-token/') @auth_required() def deactivate_token(token): @@ -466,6 +539,7 @@ def create_application(config): flash(f'Could not open door. Exception: {e}') return redirect('/') + # routes for opening and closing the door via the web interface @app.route('/close') @auth_required() def close_door():