initial commit with basic user/token management
This commit is contained in:
commit
62321d8b3c
238
app.py
Normal file
238
app.py
Normal 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
78
door.py
Normal 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
7
door_tokens
Normal 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||||
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
flask
|
||||
Flask-Security-Too
|
BIN
static/delete.png
Normal file
BIN
static/delete.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
BIN
static/edit.png
Normal file
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
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
BIN
static/stop.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
46
templates/edit.html
Normal file
46
templates/edit.html
Normal 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
29
templates/index.html
Normal 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
51
templates/register.html
Normal 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
52
templates/tokens.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user