From 62321d8b3c023df0103995e1b5f0542aa13dc407 Mon Sep 17 00:00:00 2001 From: Simon Pirkelmann Date: Sun, 7 Mar 2021 23:19:44 +0100 Subject: [PATCH] initial commit with basic user/token management --- app.py | 238 + door.py | 78 + door_tokens | 7 + nfc.log | 3314 ++++++++++++ requirements.txt | 2 + static/delete.png | Bin 0 -> 5478 bytes static/edit.png | Bin 0 -> 7220 bytes static/jquery-3.6.0.js | 10881 ++++++++++++++++++++++++++++++++++++++ static/stop.png | Bin 0 -> 6681 bytes templates/edit.html | 46 + templates/index.html | 29 + templates/register.html | 51 + templates/tokens.html | 52 + 13 files changed, 14698 insertions(+) create mode 100644 app.py create mode 100644 door.py create mode 100644 door_tokens create mode 100644 nfc.log create mode 100644 requirements.txt create mode 100644 static/delete.png create mode 100644 static/edit.png create mode 100644 static/jquery-3.6.0.js create mode 100644 static/stop.png create mode 100644 templates/edit.html create mode 100644 templates/index.html create mode 100644 templates/register.html create mode 100644 templates/tokens.html diff --git a/app.py b/app.py new file mode 100644 index 0000000..85fa670 --- /dev/null +++ b/app.py @@ -0,0 +1,238 @@ +import os +from flask import Flask, render_template, request, flash, redirect, session +from flask_wtf import FlaskForm +from wtforms.fields.html5 import DateField, EmailField +from wtforms.fields import StringField, BooleanField +from wtforms.validators import DataRequired, ValidationError +from flask_sqlalchemy import SQLAlchemy +from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password +from flask_security.models import fsqla_v2 as fsqla +from flask_security.forms import LoginForm, Required, PasswordField + +from datetime import date + +from door import Door + +door = Door('10.10.21.2') + +app = Flask(__name__) + +# Generate a nice key using secrets.token_urlsafe() +app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'Q7PJu2fg2jabYwP-Psop6c6f2G4') +# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt +# Generate a good salt using: secrets.SystemRandom().getrandbits(128) +app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '10036796768252925167749545152988277953') +app.config['SECURITY_USER_IDENTITY_ATTRIBUTES'] = ('username', 'email') + + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' +# 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. +app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { + "pool_pre_ping": True, +} + +# 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): + username = db.Column(db.String(255)) + pass + +class ExtendedLoginForm(LoginForm): + email = StringField('Benutzername oder E-Mail', [Required()]) + password = PasswordField('Passwort', [Required()]) + + +# Setup Flask-Security +user_datastore = SQLAlchemyUserDatastore(db, User, Role) +security = Security(app, user_datastore, login_form=ExtendedLoginForm) + + +def validate_valid_thru_date(form, field): + if form.limit_validity.data: # only check date format if limited validity of token is set + try: + if not field.data >= date.today(): + raise ValueError + except ValueError as e: + flash("Ungültiges Datum") + raise ValidationError + return True + +class TokenForm(FlaskForm): + name = StringField('Name', validators=[DataRequired()]) + email = EmailField('E-Mail', validators=[DataRequired()]) + limit_validity = BooleanField('Gültigkeit begrenzen?') + valid_thru = DateField('Gültig bis', validators=[validate_valid_thru_date]) + active = BooleanField('Aktiv?') + dsgvo = BooleanField('Einwilligung DSGVO erfragt?', validators=[DataRequired()]) + + +# Create a user to test with +@app.before_first_request +def create_user(): + db.create_all() + if not user_datastore.find_user(email='admin@example.com', username="admin"): + user_datastore.create_user(email='admin@example.com', username="admin", password=hash_password("password")) + db.session.commit() + + +@app.route('/') +def door_lock(): + return render_template('index.html', door_state=door.state, encoder_position=door.encoder_position) + + +@app.route('/tokens') +#@auth_required() +def list_tokens(): + tokens = door.get_tokens() + assigned_tokens = {t: data for t, data in tokens.items() if not data['inactive']} + 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) + + +@app.route('/register-token', methods=['GET', 'POST']) +#@auth_required() +def register(): + """Register new token for locking and unlocking the door. + + This route displays the most recently scanned invalid token as reported in the logfile and provides a form for + entering user info (name, email, valid thru date (optional)) for the new token. + + If the route is called via POST the provided form data is checked and if the check succeeds the /store-token route + will be called which adds the new token to the database. + """ + form = TokenForm() + if request.method == 'GET': + # set default valid thru date to today to make sure form validity check passes + # (will not be used if limited validity is disabled) + form.valid_thru.data = date.today() + return render_template('register.html', token=door.get_most_recent_token(), form=form) + elif request.method == 'POST' and form.validate(): + # store data in session cookie + session['token'] = door.get_most_recent_token()['token'] + session['name'] = form.name.data + session['email'] = form.email.data + if form.limit_validity.data: + session['valid_thru'] = form.valid_thru.data.isoformat() + else: + session['valid_thru'] = '' + session['inactive'] = not form.active.data + return redirect(f'/store-token') + 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): + """Edit data in the token file (name, email, valid_thru date, active/inactive). + + If the route is accessed via GET it will provide a form for editing the currently stored data for the user. + If the route is accessed via POST it will check if the form data is good and then store the modified user data in + the database (by redirecting to the /store-token route) + + Parameters + ---------- + token : str + The token for which data should be edited. + """ + form = TokenForm() + form.dsgvo.validators = [] # we skip the validation of the DSGVO checkbox here because we assume the user agreed + # to it before + if request.method == 'GET': + tokens = door.get_tokens() + if token in tokens: + # set default for form according to values from the token file + et = tokens[token] + form.active.data = not et['inactive'] + form.name.data = et['name'] if et['name'] else '' + form.email.data = et['email'] if et['email'] else '' + # for the valid thru date we use today's date in case there is not valid date in the database + try: + form.valid_thru.data = date.fromisoformat(et['valid_thru']) + form.limit_validity.data = True + except Exception: + form.valid_thru.data = date.today() + + return render_template('edit.html',token=token, form=form) + else: + # flash an error message if the route is accessed with an invalid token + flash(f'Ausgewaehlter Token {token} in Tokenfile nicht gefunden.') + return redirect('/tokens') + elif request.method == 'POST' and form.validate(): + # store data in session cookie + session['token'] = token + session['name'] = form.name.data + session['email'] = form.email.data + if form.limit_validity.data: + session['valid_thru'] = form.valid_thru.data.isoformat() + else: + session['valid_thru'] = '' + session['inactive'] = not form.active.data + return redirect(f'/store-token') + + +@app.route('/store-token') +#@auth_required() +def store_token(): + """Store token to the token file on disk. + + This will use the token id and the associated data stored in the session cookie (filled by register_token() or + edit_token()) and create/modify a token and store the new token file to disk. + """ + token = session['token'] + tokens = door.get_tokens() + tokens[token] = {'name': session['name'], + 'email': session['email'], + 'valid_thru': session['valid_thru'], + 'inactive': session['inactive'], + 'organization': 'test_org'} + door.store_tokens(tokens) + return redirect('/tokens') + + +@app.route('/delete-token/') +#@auth_required() +def delete_token(token): + """Delete the given token from the token file and store the new token file to disk + + Parameters + ---------- + token : str + The token to delete from the database. + """ + tokens = door.get_tokens() + if token in tokens: # check if token exists + tokens.pop(token) + door.store_tokens(tokens) + return redirect('/tokens') + + +@app.route('/deactivate-token/') +#@auth_required() +def deactivate_token(token): + """Deactivate access for the given token. This updates the token file on disk. + + Parameters + ---------- + token : str + The token to deactivate. + """ + tokens = door.get_tokens() + if token in tokens: + tokens[token]['inactive'] = True + door.store_tokens(tokens) + return redirect('/tokens') + + +if __name__ == '__main__': + app.run() diff --git a/door.py b/door.py new file mode 100644 index 0000000..73dbba7 --- /dev/null +++ b/door.py @@ -0,0 +1,78 @@ +import paho.mqtt.client as mqtt +import regex as re + + +class Door: + def __init__(self, host, port=1883): + self.state = None + self.encoder_position = None + + self.token_file = 'door_tokens' + self.nfc_logfile = 'nfc.log' + + self.mqtt_client = mqtt.Client() + self.mqtt_client.on_connect = self.on_connect + self.mqtt_client.on_message = self.on_message + self.mqtt_client.connect(host=host, port=port) + self.mqtt_client.loop_start() + + self.data_fields = ['name', 'organization', 'email', 'valid_thru'] + + # The callback for when the client receives a CONNACK response from the server. + def on_connect(self, client, userdata, flags, rc): + print("Connected with result code " + str(rc)) + + # Subscribing in on_connect() means that if we lose the connection and + # reconnect then subscriptions will be renewed. + client.subscribe("#") + + # The callback for when a PUBLISH message is received from the server. + def on_message(self, client, userdata, msg): + print(msg.topic + " " + str(msg.payload)) + if msg.topic == 'door/state/value': + self.state = msg.payload.decode() + elif msg.topic == 'door/position/value': + self.encoder_position = int(msg.payload) + + def get_tokens(self): + tokens = {} + with open(self.token_file) as f: + token_lines = f.readlines() + for line in token_lines[1:]: + data = line.split('|') + inactive = '#' in data[0] + token = data[0].strip().strip('#') + name = data[1].strip() if len(data) > 1 else None + organization = data[2].strip() if len(data) > 2 else None + email = data[3].strip() if len(data) > 3 else None + valid_thru = data[4].strip() if len(data) > 4 else None + + tokens[token] = {'name': name, + 'organization': organization, + 'email': email, + 'valid_thru': valid_thru, + 'inactive': inactive} + return tokens + + def store_tokens(self, tokens): + output = '# token | ' + ' | '.join(self.data_fields) + '\n' + + for t, data in tokens.items(): + output += '#' if data['inactive'] else '' + output += t + for key in self.data_fields: + output += '|' + output += data[key] if data[key] else '' + output += '\n' + with open(self.token_file, 'w') as f: + f.write(output) + + + + def get_most_recent_token(self): + # read last invalid token from logfile + with open(self.nfc_logfile) as f: + nfc_log = f.read() + match = re.search(r"(?P\d{4}-\d{2}-\d{2}) (?P