initial commit with basic user/token management

master
Simon Pirkelmann 2021-03-07 23:19:44 +01:00
commit 62321d8b3c
13 changed files with 14698 additions and 0 deletions

238
app.py Normal file
View File

@ -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/<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/<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/<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()

78
door.py Normal file
View File

@ -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<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) Invalid token: (?P<token>[[:xdigit:]]{14})",
nfc_log, re.REVERSE)
return match

7
door_tokens Normal file
View File

@ -0,0 +1,7 @@
# token | name | organization | email | valid_thru
04387cfa186280|David|test_org|d@d.de|
#043a81fa186280|Nico|imaginaerraum||
04538cfa186280|Simon|test_org|simon.pirkelmann@gmail.com|2021-03-07
045e77fa186280|Valentin|test_org|v@v.de|
#047378fa186280|Stephan|test_org|s@s.de|2021-03-07
#047a76fa186280||||

3314
nfc.log Normal file

File diff suppressed because it is too large Load Diff

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask
Flask-Security-Too

BIN
static/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
static/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

10881
static/jquery-3.6.0.js vendored Normal file

File diff suppressed because it is too large Load Diff

BIN
static/stop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

46
templates/edit.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Token editieren</title>
<script src="../static/jquery-3.6.0.js"></script>
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<div>
<p>
Token {{ token }} editieren:
</p>
<form method="POST">
<p>{{ form.csrf_token }}
<p>{{ form.name.label }} {{ form.name(size=20) }}</p>
<p>{{ form.email.label }} {{ form.email(size=20) }}</p>
<p>{{ form.limit_validity.label }} {{ form.limit_validity() }}</p>
<div id="valid_thru_dialog" {% if not form.limit_validity.data %}hidden{% endif %}>
<p>{{ form.valid_thru.label }} {{ form.valid_thru() }}</p>
</div>
<p>{{ form.active.label }} {{ form.active() }}</p>
<input type="submit" value="Speichern">
</form>
</div>
</body>
<script>
$(function () {
$("#limit_validity").on("click", function () {
$("#valid_thru_dialog").toggle(this.checked);
});
});
</script>
</html>

29
templates/index.html Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Space Zugangsverwaltung</title>
</head>
<body>
<h1>Space Zugangsverwaltung</h1>
Zustand der Tür:
{% if door_state == 'closed' %}
<div style="color: red">
Abgeschlossen
</div>
{% elif door_state == 'open' %}
<div style="color: limegreen">
Geöffnet
</div>
{% endif %}
<br>
Position Drehgeber: {{ encoder_position }}
<p>
<a href="{{ url_for('list_tokens') }}">Übersicht Tokens</a>
</p>
<p>
<a href="{{ url_for('register') }}">Token registrieren</a>
</p>
</body>
</html>

51
templates/register.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tokens registrieren</title>
<script src="../static/jquery-3.6.0.js"></script>
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% if token is not none %}
Letzter unregistrierter Token: {{ token['token'] }} <br>
Gelesen am {{ token['date'] }} um {{ token['time'] }}
<div>
<p>
RaumnutzerIn registrieren:
</p>
<form method="POST">
<p>{{ form.csrf_token }}
<p>{{ form.name.label }} {{ form.name(size=20) }}</p>
<p>{{ form.email.label }} {{ form.email(size=20) }}</p>
<p>{{ form.limit_validity.label }} {{ form.limit_validity() }}</p>
<div id="valid_thru_dialog" {% if not form.limit_validity.data %}hidden{% endif %}>
<p>{{ form.valid_thru.label }} {{ form.valid_thru() }}</p>
</div>
<p>{{ form.dsgvo.label }} {{ form.dsgvo() }}</p>
<input type="submit" value="Abschicken">
</form>
</div>
{% else %}
Keine unregistrierten Tokens in Logfile gefunden. Bitte Token scannen und die Seite neu laden.
{% endif %}
</body>
<script>
$(function () {
$("#limit_validity").on("click", function () {
$("#valid_thru_dialog").toggle(this.checked);
});
});
</script>
</html>

52
templates/tokens.html Normal file
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tokens</title>
</head>
<body>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<table border="1">
<td>Token</td>
<td>NutzerIn</td>
<td>Organisation</td>
<td>E-Mail</td>
<td>Gültig bis</td>
<td>Aktionen</td>
{% for t, data in assigned_tokens.items() %}
<tr>
<td>{{ t }}</td>
{% for field in ['name', 'organization', 'email', 'valid_thru'] %}
<td>{{ data[field] if data[field] }}</td>
{% endfor %}
<td>
<a href="{{ url_for('edit_token', token=t) }}"><img src="static/edit.png" title="Editieren" alt="Edit"></a>
<a href="{{ url_for('deactivate_token', token=t) }}"><img src="static/stop.png" title="Deaktivieren" alt="Deactivate"></a>
<a href="{{ url_for('delete_token', token=t) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
</td>
</tr>
{% endfor %}
{% for t, data in inactive_tokens.items() %}
<tr style="background-color: lightgrey">
<td>{{ t }}</td>
{% for field in ['name', 'organization', 'email', 'valid_thru'] %}
<td>{{ data[field] if data[field] }}</td>
{% endfor %}
<td>
<a href="{{ url_for('edit_token', token=t) }}"><img src="static/edit.png" title="Editieren" alt="Edit"></a>
<a href="{{ url_for('delete_token', token=t) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
</td>
</tr>
{% endfor %}
</table>
</body>
</html>