From 38164aca4b4762517b1c9af8a1646dc130f0d30f Mon Sep 17 00:00:00 2001 From: Simon Pirkelmann Date: Tue, 25 Jan 2022 21:42:35 +0100 Subject: [PATCH] started refactoring: - use blueprint - read configuration from file (default_app_config.py) and additional file specified by APPLICATION_SETTINGS environment variable --- imaginaerraum_door_admin/__init__.py | 170 +++ imaginaerraum_door_admin/auth.py | 111 ++ .../default_app_config.py | 55 + imaginaerraum_door_admin/templates/base.html | 10 +- imaginaerraum_door_admin/templates/index.html | 4 +- imaginaerraum_door_admin/webapp.py | 1056 ++++++----------- 6 files changed, 737 insertions(+), 669 deletions(-) create mode 100644 imaginaerraum_door_admin/auth.py create mode 100644 imaginaerraum_door_admin/default_app_config.py diff --git a/imaginaerraum_door_admin/__init__.py b/imaginaerraum_door_admin/__init__.py index e69de29..8d9d10a 100644 --- a/imaginaerraum_door_admin/__init__.py +++ b/imaginaerraum_door_admin/__init__.py @@ -0,0 +1,170 @@ +import logging +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_security.models import fsqla_v2 as fsqla +from flask_security import Security, SQLAlchemyUserDatastore + +import ldap3 + +from pathlib import Path + +from .webapp import door_app +from .door_handle import DoorHandle +from .auth import ExtendedLoginForm + +security = Security() + + +def setup_logging(app): + # set up logging for the web app + logger = logging.getLogger('webapp') + logger.setLevel(logging.INFO) + + if app.config['LOG_FILE'] is not None: + ch = logging.FileHandler(app.config['LOG_FILE']) + ch.setLevel(logging.INFO) + else: + # create console handler and set level to debug + ch = logging.StreamHandler() + ch.setLevel(logging.DEBUG) + + # create formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + # add formatter to ch + ch.setFormatter(formatter) + # add ch to logger + logger.addHandler(ch) + + return logger + +def create_app(): + app = Flask(__name__) + app.config.from_object('imaginaerraum_door_admin.default_app_config.DefaultConfig') + app.config.from_envvar('APPLICATION_SETTINGS') + + logger = setup_logging(app) + + # do some checks for file existence etc. + try: + with open(app.config['KEY_FILE']) as f: + data = f.readlines() + if 'SECRET_KEY' in data[0]: + secret_key = data[0].split()[-1] + else: + raise Exception("Could not read SECURITY_PASSWORD_SALT") + if 'SECURITY_PASSWORD_SALT' in data[1]: + security_password_salt = data[1].split()[-1] + else: + raise Exception("Could not read SECURITY_PASSWORD_SALT") + except Exception as e: + logger.warning( + f"Flask keys could not be read from file at {Path(app.config['KEY_FILE']).absolute()}. Exception: {e}. Using default values instead.") + secret_key = 'Q7PJu2fg2jabYwP-Psop6c6f2G4' + security_password_salt = '10036796768252925167749545152988277953' + + if Path(app.config['TEMPLATE_FOLDER']).is_absolute(): + if not Path(app.config['TEMPLATE_FOLDER']).exists(): + logger.error( + f"Flask template folder not found at {Path(app.config['TEMPLATE_FOLDER']).absolute()}") + else: + if not (Path(__file__).parent / app.config['TEMPLATE_FOLDER']).exists(): + logger.error( + f"Flask template folder not found at {(Path(__file__).parent / app.config['TEMPLATE_FOLDER']).absolute()}") + if Path(app.config['STATIC_FOLDER']).is_absolute(): + if not Path(app.config['STATIC_FOLDER']).exists(): + logger.error( + f"Flask static folder not found at {Path(app.config['STATIC_FOLDER']).absolute()}") + else: + if not (Path(__file__).parent / app.config['STATIC_FOLDER']).exists(): + logger.error( + f"Flask static folder not found at {(Path(__file__).parent / app.config['STATIC_FOLDER']).absolute()}") + if not Path(app.config['TOKEN_FILE']).exists(): + logger.warning( + f"Token file not found at {Path(app.config['TOKEN_FILE']).absolute()}") + + # create door objects which provides access to the token file and current door state via MQTT + app.door = DoorHandle(token_file=app.config['TOKEN_FILE'], mqtt_host=app.config['MQTT_HOST'], + nfc_socket=app.config['NFC_SOCKET'], + logger=logger) + + # Mail Config + #mail = Mail(app) + + from . import webapp + #app.register_blueprint + + # Create database connection object + db = SQLAlchemy(app) + + # Define models + fsqla.FsModels.set_db_info(db) + + class Role(db.Model, fsqla.FsRoleMixin): + pass + + class User(db.Model, fsqla.FsUserMixin): + pass + + app.register_blueprint(door_app) + + # setup user database when starting the app + # with app.app_context(): + # new_admin_data = [] + # if config.admin_file is not None: + # if not Path(config.admin_file).exists(): + # logger.warning( + # f"Admin user creation file not found at {config.admin_file}") + # else: + # # store data for new admins in memory s.t. the file can be deleted afterwards + # with open(config.admin_file) as f: + # for i, line in enumerate(f.readlines()): + # if not line.strip().startswith('#'): + # try: + # user, email, pw = line.split() + # validate_email(email) + # new_admin_data.append( + # {'username': user, 'email': email, + # 'password': pw}) + # except Exception as e: + # print( + # f"Error while parsing line {i} in admin config file. Config file should contain lines of " + # f"' \\n'\n Exception: {e}\nAdmin account could not be created.") + # + # # create admin users (only if they don't exists already) + # def create_super_admins(new_admin_data): + # db.create_all() + # super_admin_role = user_datastore.find_or_create_role( + # 'super_admin') # root admin = can create other admins + # admin_role = user_datastore.find_or_create_role( + # 'admin') # 'normal' admin + # local_role = user_datastore.find_or_create_role( + # 'local') # LDAP user or local user + # + # for d in new_admin_data: + # if user_datastore.find_user(email=d['email'], + # username=d['username']) is None: + # roles = [super_admin_role, admin_role] + # if not d['password'] == 'LDAP': + # roles.append(local_role) + # logger.info( + # f"New super admin user created with username '{d['username']}' and email '{d['email']}', roles = {[r.name for r in roles]}") + # + # # create new admin (only if admin does not already exist) + # new_admin = user_datastore.create_user(email=d['email'], + # username=d[ + # 'username'], + # password=hash_password( + # d['password']), + # roles=roles) + # db.session.commit() + # + # create_super_admins(new_admin_data) + + ldap_server = ldap3.Server(app.config['LDAP_URL']) + + # Setup Flask-Security + user_datastore = SQLAlchemyUserDatastore(db, User, Role) + security.init_app(app, user_datastore, login_form=ExtendedLoginForm) + + return app \ No newline at end of file diff --git a/imaginaerraum_door_admin/auth.py b/imaginaerraum_door_admin/auth.py new file mode 100644 index 0000000..aa75bfd --- /dev/null +++ b/imaginaerraum_door_admin/auth.py @@ -0,0 +1,111 @@ +from wtforms.fields import StringField, BooleanField +from flask_security import hash_password +from flask_security.forms import LoginForm, Required, PasswordField +from flask_security.utils import find_user +import ldap3 +from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError + + +class ExtendedLoginForm(LoginForm): + email = StringField('Benutzername oder E-Mail', [Required()]) + password = PasswordField('Passwort', [Required()]) + remember = BooleanField('Login merken?') + + def validate(self): + # search for user in the current database + user = find_user(self.email.data) + if user is not None: + # if a user is found we check if it is associated with LDAP or with the local database + if user.has_role('local'): + # try authorizing locally using Flask security user datastore + authorized = super(ExtendedLoginForm, self).validate() + + if authorized: + logger.info(f"User with credentials '{self.email.data}' authorized through local database") + else: + # run LDAP authorization + # if the authorization succeeds we also get the new_user_data dict which contains information about + # the user's permissions etc. + authorized, new_user_data = validate_ldap(user.username, self.password.data) + + if authorized: + logger.info(f"User with credentials '{self.email.data}' authorized through LDAP") + # update permissions and password/email to stay up to date for login with no network connection + user.email = new_user_data['email'] + user.password = new_user_data['password'] + for role in new_user_data['roles']: + user_datastore.add_role_to_user(user, role) + user_datastore.commit() + self.user = user + else: + self.password.errors = ['Invalid password'] + else: + # this means there is no user with that email in the database + # we assume that the username was entered instead of an email and use that for authentication with LDAP + username = self.email.data + # try LDAP authorization and create a new user if it succeeds + authorized, new_user_data = validate_ldap(username, self.password.data) + + if authorized: + # if there was no user in the database before we create a new user + self.user = user_datastore.create_user(username=new_user_data['username'], email=new_user_data['email'], + password=new_user_data['password'], roles=new_user_data['roles']) + user_datastore.commit() + logger.info(f"New admin user '{new_user_data['username']} <{new_user_data['email']}>' created after" + " successful LDAP authorization") + + # if any of the authorization methods is successful we authorize the user + return authorized + +def validate_ldap(username, password): + """Validate the user and password through an LDAP server. + + If the connection completes successfully the given user and password is authorized. + Then the permissions and additional information of the user are obtained through an LDAP search. + The data is stored in a dict which will be used later to create/update the entry for the user in the local + database. + + Parameters + ---------- + username : username for the LDAP server + password : password for the LDAP server + + Returns + ------- + bool : result of the authorization process (True = success, False = failure) + dict : dictionary with information about an authorized user (contains username, email, hashed password, + roles) + """ + + try: + con = ldap3.Connection(ldap_server, user=f"uid={username},ou=Users,dc=imaginaerraum,dc=de", + password=password, auto_bind=True) + except ldap3.core.exceptions.LDAPBindError: + # server reachable but user unauthorized -> fail + return False, None + except LDAPSocketOpenError: + # server not reachable -> fail (but will try authorization from local database later) + return False, None + except Exception as e: + # for other Exceptions we just fail + return False, None + + # get user data and permissions from LDAP server + new_user_data = {} + new_user_data['username'] = username + new_user_data['password'] = hash_password(password) + new_user_data['roles'] = [] + lock_permission = con.search('ou=Users,dc=imaginaerraum,dc=de', + f'(&(uid={username})(memberof=cn=Keyholders,ou=Groups,dc=imaginaerraum,dc=de))', + attributes=ldap3.ALL_ATTRIBUTES) + authorized = True + if lock_permission: + new_user_data['email'] = con.entries[0].mail.value + else: + authorized = False + token_granting_permission = con.search('ou=Users,dc=imaginaerraum,dc=de', + f'(&(uid={username})(memberof=cn=Vorstand,ou=Groups,dc=imaginaerraum,dc=de))') + if token_granting_permission: + new_user_data['roles'].append('admin') + + return authorized, new_user_data diff --git a/imaginaerraum_door_admin/default_app_config.py b/imaginaerraum_door_admin/default_app_config.py new file mode 100644 index 0000000..dc40572 --- /dev/null +++ b/imaginaerraum_door_admin/default_app_config.py @@ -0,0 +1,55 @@ +print("loading default config") +import bleach +from flask_security import uia_email_mapper + +def uia_username_mapper(identity): + # we allow pretty much anything - but we bleach it. + return bleach.clean(identity, strip=True) + +class DefaultConfig(object): + DEBUG = False + + SECRET_KEY = 'supersecret' + + TEMPLATE_FOLDER = 'templates' + STATIC_FOLDER = 'static' + + SECURITY_REGISTERABLE = False + SECURITY_CHANGEABLE = True + SECURITY_RECOVERABLE = True + SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False + + SECURITY_POST_LOGIN_VIEW = '/' + SECURITY_EMAIL_SUBJECT_PASSWORD_RESET = 'Passwort zurücksetzen' + SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE = 'Passwort wurde zurückgesetzt' + SECURITY_PASSWORD_LENGTH_MIN = 10 + + SECURITY_USER_IDENTITY_ATTRIBUTES = [ + {"email": {"mapper": uia_email_mapper, "case_insensitive": True}}, + {"username": {"mapper": uia_username_mapper}} + ] + + # mail configuration + MAIL_SERVER = '' + MAIL_PORT = 465 + MAIL_USE_SSL = True + MAIL_USERNAME = '' + MAIL_PASSWORD = '' + MAIL_DEFAULT_SENDER = '' + + SQLALCHEMY_DATABASE_URI = 'sqlite:///admin.db' + # As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the + # underlying engine. This option makes sure that DB connections from the + # pool are still valid. Important for entire application since + # many DBaaS options automatically close idle connections. + SQLALCHEMY_ENGINE_OPTIONS = { + "pool_pre_ping": True, + } + + KEY_FILE = '/root/flask_keys' + TOKEN_FILE = "/etc/door_tokens" + LDAP_URL = "ldaps://ldap.imaginaerraum.de" + NFC_SOCKET = "/tmp/nfc.sock" + LOG_FILE = "/var/log/webinterface.log" + NFC_LOG = "/var/log/nfc.log" + MQTT_HOST = '10.10.21.2' \ No newline at end of file diff --git a/imaginaerraum_door_admin/templates/base.html b/imaginaerraum_door_admin/templates/base.html index 6df702a..a8d6990 100644 --- a/imaginaerraum_door_admin/templates/base.html +++ b/imaginaerraum_door_admin/templates/base.html @@ -13,7 +13,7 @@