Compare commits

...

33 Commits

Author SHA1 Message Date
Simon Pirkelmann 8e868f1674 display time since unregistered token was read 2021-08-30 21:15:32 +02:00
Simon Pirkelmann 8a9a667429 adjusted layout 2021-08-30 21:03:30 +02:00
Simon Pirkelmann 8bc0a642a3 clarified time zone 2021-08-30 20:43:49 +02:00
Simon Pirkelmann 7cf4e3ce36 added guide for token registration 2021-08-24 18:48:54 +02:00
Simon Pirkelmann 79b9b69ef8 encode string 2021-08-23 21:10:43 +02:00
Simon Pirkelmann 4307d54505 check Keyholder status of users 2021-08-23 21:06:18 +02:00
Simon Pirkelmann 14c783b2c1 pass on username to nfc socket when opening and closing door 2021-08-23 21:05:42 +02:00
Simon Pirkelmann 2ccec6fe32 fixed typo causing door status not being displayed correctly 2021-08-23 20:31:18 +02:00
Simon Pirkelmann 5afcf1f10a added default argument for super admin file 2021-08-23 20:30:50 +02:00
Simon Pirkelmann 75ce8c46b8 nicer layout for token log 2021-04-17 15:50:46 +02:00
Simon Pirkelmann 9a0ae93a7b added missing * 2021-04-17 14:50:07 +02:00
Simon Pirkelmann 9d53f80cbf Makefile now longer necessary with setuptools 2021-04-17 14:44:03 +02:00
Simon Pirkelmann d406d254b4 added security templates 2021-04-17 14:42:00 +02:00
Simon Pirkelmann cffdb1f797 users created through the web interface are local users by default 2021-04-17 14:34:55 +02:00
Simon Pirkelmann 734bed2092 prevent password changing for ldap users (they get redirected to the ldap self service instead) 2021-04-17 14:26:04 +02:00
Simon Pirkelmann f48f78997c added option to view the NFC logfile as super admin 2021-04-10 17:25:27 +02:00
Simon Pirkelmann c02d5465ea update version 2021-04-10 14:18:44 +02:00
Simon Pirkelmann 717fe0d83b added bootstrap files to MANIFEST.in 2021-04-10 14:13:52 +02:00
Simon Pirkelmann 8339294277 added option to read flask SECRET_KEY and SECURITY_PASSWORD_SALT from file 2021-04-10 13:46:56 +02:00
Simon Pirkelmann db8ee556df added promote/demote buttons 2021-04-08 20:56:54 +02:00
Simon Pirkelmann be0ee36ba9 reformatting token registration 2021-04-08 20:56:31 +02:00
Simon Pirkelmann 7205928406 added iR logo, moved door open/close links to buttons on index.html 2021-04-08 20:55:27 +02:00
Simon Pirkelmann c215f367f5 clarified user vs admin terminology 2021-04-08 20:53:41 +02:00
Simon Pirkelmann 2cb93d1d3b Distinguish between normal users and admin users. Normal user can only lock and unlock the door. Admin users can register new tokens.
Also added option to grant and revoke admin permissions for super-Admins.
2021-04-07 16:15:39 +02:00
Simon Pirkelmann 7684268002 added bootstrap for nicer layout 2021-04-07 11:29:50 +02:00
Simon Pirkelmann 5facd44325 updated LDAP url 2021-04-06 22:40:02 +02:00
Simon Pirkelmann ccce39d1a0 restructured LDAP authorization procedure 2021-04-06 22:39:21 +02:00
Simon Pirkelmann a71f68ade3 worked on ldap validation 2021-04-06 17:53:41 +02:00
Simon Pirkelmann 312549ac15 added route for token backup 2021-04-06 17:53:14 +02:00
Simon Pirkelmann f021f7494f added link for token backup 2021-04-06 17:52:17 +02:00
Simon Pirkelmann 8cdf549c4c added ability to backup and restore admin users for super admins 2021-03-28 21:50:44 +02:00
Simon Pirkelmann e707f4bd87 added support for different roles (admins + super admins), super admins can create new admins 2021-03-27 22:58:31 +01:00
Simon Pirkelmann 4197446a00 improved admin user creation and added basic logging features 2021-03-27 16:41:51 +01:00
27 changed files with 952 additions and 204 deletions

View File

@ -1,3 +1,6 @@
include README.md
include imaginaerraum_door_admin/templates/*
include imaginaerraum_door_admin/templates/security/*
include imaginaerraum_door_admin/static/*
include imaginaerraum_door_admin/static/css/*
include imaginaerraum_door_admin/static/js/*

View File

@ -1,24 +0,0 @@
VERSION ?= $(shell git rev-parse --short HEAD)
release: door_admin_v$(VERSION).tar.gz
TEMPLATE_FILES = $(shell find -name '*.html')
STATIC_FILES = $(shell find -name '*.png' -o -name '*.js')
PYTHON_FILES = $(shell find -name '*.py')
ALL_FILES = $(TEMPLATE_FILES) $(STATIC_FILES) $(PYTHON_FILES)
door_admin_v$(VERSION).tar.gz: Makefile $(ALL_FILES)
tar czf $@ --transform='s,^,door_admin_$(VERSION)/,' $^
install: install_templates install_static install_python
install_static:
install -D -m 0755 -t $(PREFIX)/usr/share/door_admin/static $(STATIC_FILES)
install_templates:
install -D -m 0755 -t $(PREFIX)/usr/share/door_admin/templates $(TEMPLATE_FILES)
install_python:
install -D -m 0755 -t $(PREFIX)/bin $(PYTHON_FILES)
.PHONY: release install install_static install_templates install_python

View File

@ -4,15 +4,18 @@ import argparse
from imaginaerraum_door_admin.webapp import create_application
parser = argparse.ArgumentParser()
parser.add_argument("--key_file", default='/root/flask_keys', help="Path to file with Flask SECRET_KEY and SECURITY_PASSWORD_SALT")
parser.add_argument("--token_file", default="/etc/door_tokens", help="path to the file with door tokens and users")
parser.add_argument("--nfc_socket", default="/tmp/nfc.sock", help="socket for handling NFC reader commands")
parser.add_argument("--template_folder", default="templates", help="path to Flask templates folder")
parser.add_argument("--static_folder", default="static", help="path to Flask static folder")
parser.add_argument("--admin_file", help="Path to file for creating initial admin users")
parser.add_argument("--ldap_url", default="ldaps://do.imaginaerraum.de",
parser.add_argument("--admin_file", default="/etc/admins.conf", help="Path to file for creating super admin users")
parser.add_argument("--log_file", default="/var/log/webinterface.log", help="Path to flask log file")
parser.add_argument("--nfc_log", default="/var/log/nfc.log", help="Path to nfc log file")
parser.add_argument("--ldap_url", default="ldaps://ldap.imaginaerraum.de",
help="URL for LDAP server for alternative user authorization")
parser.add_argument("--mqtt_host", default="10.10.21.2", help="IP address of MQTT broker")
parser.add_argument("--port", default=80, help="Port for running the Flask server")
parser.add_argument("--flask_port", default=80, help="Port for running the Flask server")
parser.add_argument("--mail_server", default="smtp.googlemail.com", help="email server for sending security messages")
parser.add_argument("--mail_port", default=465, help="port for security email server")
parser.add_argument("--mail_use_tls", default=False, help="use TLS for security emails")
@ -22,4 +25,4 @@ parser.add_argument("--mail_password", default="password", help="Password for em
config = parser.parse_args()
app = create_application(config)
app.run(host='0.0.0.0', port=config.port)
app.run(host='0.0.0.0', port=config.flask_port)

View File

@ -1,10 +1,11 @@
import paho.mqtt.client as mqtt
import socket
from pathlib import Path
import logging
from datetime import datetime
class DoorHandle:
def __init__(self, token_file, mqtt_host, mqtt_port=1883, nfc_socket='/tmp/nfc.sock'):
def __init__(self, token_file, mqtt_host, mqtt_port=1883, nfc_socket='/tmp/nfc.sock', logger=None):
self.state = None
self.encoder_position = None
@ -20,18 +21,25 @@ class DoorHandle:
self.mqtt_client.connect_async(host=mqtt_host, port=mqtt_port)
self.mqtt_client.loop_start()
if logger:
self.logger = logger
else:
self.logger = logging
self.nfc_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
self.nfc_sock.connect(nfc_socket)
self.logger.info(f"Connected to NFC socket at {nfc_socket}.")
except Exception as e:
print(f"Could not connect to NFC socket at {nfc_socket}. Exception: {e}")
self.nfc_sock = None
#raise
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))
self.logger.info("Connected to MQTT broker with result code " + str(rc))
# Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed.
@ -46,6 +54,7 @@ class DoorHandle:
self.encoder_position = int(msg.payload)
elif msg.topic == 'door/token/last_invalid':
timestamp, token = msg.payload.decode().split(";")
timestamp = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S')
self.last_invalid = {'timestamp': timestamp, 'token': token}
def get_tokens(self):
@ -81,13 +90,21 @@ class DoorHandle:
# write new tokens to file and trigger reload
with open(self.token_file, 'w') as f:
f.write(output)
self.nfc_sock.send(b'rld\n')
def open_door(self):
self.nfc_sock.send(b'open\n')
if self.nfc_sock is not None:
self.nfc_sock.send(b'rld\n')
def close_door(self):
self.nfc_sock.send(b'close\n')
def open_door(self, user=''):
if self.nfc_sock is not None:
self.nfc_sock.send(b'open ' + user.encode() + b'\n')
else:
raise Exception("No connection to NFC socket. Cannot close door!")
def close_door(self, user=''):
if self.nfc_sock is not None:
self.nfc_sock.send(b'close ' + user.encode() + b'\n')
else:
raise Exception("No connection to NFC socket. Cannot close door!")
def get_most_recent_token(self):
# read last invalid token from logfile

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 13.204024 10.591578"
id="svg2"
height="10.591578mm"
width="13.204024mm"
version="1.0"
sodipodi:docname="iR.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1853"
inkscape:window-height="1025"
id="namedview8"
showgrid="false"
inkscape:zoom="4"
inkscape:cx="33.91598"
inkscape:cy="-6.5287442"
inkscape:window-x="67"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg2"
fit-margin-bottom="1.5"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0" />
<metadata
id="metadata13">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4">
<pattern
y="0"
x="0"
height="6"
width="6"
patternUnits="userSpaceOnUse"
id="WMFhbasepattern" />
</defs>
<path
id="path7"
d="m 3.356704,7.0871845 -0.008,-0.04772 -0.07159,-0.06363 h -0.05568 -0.07159 l -0.07954,0.111357 -0.04772,0.151131 -0.127268,0.42157 -0.238626,0.47725 -0.167037,0.246581 -0.167038,0.182945 -0.182945,0.135219 -0.167037,0.0875 -0.174993,0.03977 H 1.7181 1.65446 l -0.111358,-0.03183 -0.103405,-0.0875 -0.05568,-0.174993 -0.008,-0.135219 0.008,-0.13522 0.04773,-0.278399 0.13522,-0.429525 0.111357,-0.294302 0.914731,-2.433977 0.03977,-0.09545 0.07954,-0.310213 0.0159,-0.182948 -0.0159,-0.206807 -0.143175,-0.373845 -0.1909,-0.222718 -0.159083,-0.111357 -0.1909,-0.0875 -0.222718,-0.03977 -0.111357,-0.008 -0.111361,0.008 -0.214763,0.03977 -0.28635,0.127268 L 0.763594,3.6350655 0.501106,3.9691415 0.2068,4.5179795 0.0159,5.0986315 0,5.1940815 l 0.008,0.03977 0.07159,0.07159 0.06364,0.008 0.07954,-0.008 0.07159,-0.111361 0.03183,-0.119313 0.127268,-0.389752 0.294302,-0.612474 0.254536,-0.318164 0.182944,-0.151131 0.182945,-0.09545 0.1909,-0.04772 0.09545,-0.008 h 0.09545 l 0.167038,0.111361 0.07159,0.174989 v 0.143175 l -0.01589,0.270443 -0.167038,0.556793 -0.0875,0.238626 -0.914731,2.433976 -0.06363,0.151127 -0.0875,0.326124 -0.008,0.174989 0.0159,0.214763 0.143175,0.3818 0.1909,0.222718 0.167038,0.111357 0.182944,0.07954 0.214763,0.04772 h 0.119313 0.111361 l 0.206807,-0.04772 0.28635,-0.119312 0.318168,-0.278395 0.262488,-0.334075 0.28635,-0.556794 0.1909,-0.5727 0.0159,-0.09545 z M 3.245347,0.7954355 v -0.07159 l -0.05568,-0.15113 -0.119313,-0.127268 -0.159082,-0.07159 -0.111361,-0.008 -0.111357,0.008 -0.222718,0.09545 -0.182945,0.174993 -0.111357,0.222718 -0.008,0.127265 0.008,0.111361 0.08749,0.167037 0.127268,0.103402 0.143175,0.04772 h 0.06363 l 0.135219,-0.008 0.230674,-0.111361 0.174989,-0.182944 0.103406,-0.214763 0.008,-0.111357 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.37578771"
inkscape:connector-curvature="0" />
<path
id="path9"
d="m 7.508782,4.8520635 h 0.477251 l 1.956726,3.038492 0.294306,0.461343 0.294302,0.453388 0.06364,0.09545 0.174989,0.06363 h 0.198855 1.805596 0.167038 l 0.1909,-0.05568 0.06364,-0.103405 0.008,-0.07954 -0.008,-0.07954 -0.103405,-0.11136 -0.0875,-0.03183 -0.0875,-0.02386 -0.1909,-0.103406 -0.302257,-0.238625 -0.580656,-0.652243 -0.278395,-0.36589 -0.135219,-0.1909 -0.851099,-1.224944 -0.636333,-0.99427 0.437481,-0.0875 0.61247,-0.214763 0.373845,-0.198852 0.318168,-0.262488 0.254532,-0.326123 0.1909,-0.397708 0.09545,-0.493157 0.008,-0.28635 v -0.167038 l -0.04772,-0.310213 -0.07954,-0.28635 -0.119312,-0.262488 -0.238626,-0.349986 -0.42157,-0.373845 -0.501112,-0.28635 L 10.260982,0.1988555 9.664419,0.063632 9.0599,0 H 8.765595 4.661241 4.486252 l -0.198855,0.05568 -0.06363,0.095452 -0.008,0.07954 0.008,0.07954 0.07159,0.09545 0.174989,0.04772 h 0.09545 l 0.262488,0.008 0.334075,0.05568 0.127268,0.111361 0.05568,0.111357 0.03183,0.230669 V 1.201123 7.75536 7.98603 L 5.345307,8.224655 5.289627,8.336016 5.162359,8.447373 4.828284,8.503053 H 4.565796 4.470346 l -0.174989,0.05568 -0.07159,0.09545 -0.008,0.07159 0.008,0.07954 0.06363,0.103405 0.198855,0.05568 h 0.174989 3.587334 0.167037 l 0.1909,-0.05568 0.06363,-0.103405 0.008,-0.07954 -0.008,-0.07159 -0.07159,-0.09545 -0.167038,-0.05568 H 8.312202 8.049714 L 7.795182,8.463283 7.675869,8.407603 7.564512,8.280335 7.516792,7.978077 7.508792,7.755359 V 4.852087 Z m 2.529427,-0.644288 0.127268,-0.182948 0.167038,-0.429526 0.07954,-0.453388 0.03183,-0.461339 v -0.222718 -0.238625 l -0.04772,-0.485206 -0.103405,-0.477251 -0.198852,-0.453387 -0.151131,-0.206808 0.23067,0.05568 0.437481,0.159082 0.302257,0.159082 0.294306,0.206811 0.254532,0.278395 0.198856,0.334075 0.111357,0.413619 0.008,0.238625 -0.008,0.159082 -0.03977,0.302257 -0.08749,0.278399 -0.135224,0.254532 -0.198852,0.23067 -0.270443,0.198855 -0.34203,0.167038 -0.413615,0.127268 -0.246581,0.04772 z M 7.508782,1.1613245 v -0.103401 l 0.03977,-0.238626 0.103405,-0.167037 0.119313,-0.0875 0.174993,-0.07159 0.23067,-0.03183 0.143175,-0.008 0.206807,0.008 0.365893,0.04773 0.310213,0.09545 0.262488,0.167037 0.206807,0.23067 0.15113,0.302257 0.103406,0.389756 0.05568,0.485206 v 0.278395 0.302257 l -0.04772,0.509068 -0.103402,0.389756 -0.182949,0.294302 -0.270439,0.198856 -0.373849,0.135219 -0.493157,0.07159 -0.620425,0.03183 -0.381801,0.008 V 1.1613685 Z M 5.735,8.5030285 5.79864,8.3121285 5.83841,7.9064655 V 7.7871535 1.1772355 1.0499675 L 5.79864,0.6443045 5.735,0.4534045 h 1.487432 l -0.05568,0.07954 -0.07159,0.17499 -0.03977,0.262487 v 0.151131 6.665598 0.119312 l 0.03977,0.405663 0.06364,0.1909 z m 2.783963,-3.650965 0.119313,-0.0159 0.127264,-0.008 0.342031,-0.0159 0.342031,-0.03977 0.302257,0.493158 1.113583,1.662424 0.644288,0.859051 0.429525,0.501113 0.206811,0.214762 H 10.873397 L 8.518963,4.8520785 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.37578771"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -0,0 +1,84 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Nutzer Übersicht</h1>{% endblock %}
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}
<table class="table">
<thead>
<th scope="col">Benutzer</th>
<th scope="col">E-Mail</th>
<th scope="col">Aktiv</th>
<th scope="col">Admin</th>
<th scope="col">Super-Admin</th>
<th scope="col">Aktionen</th>
</thead>
<tbody>
{% for data in admin_data %}
{% if data['active'] %}
<tr>
{% else %}
<tr style="background-color: lightgrey">
{% endif %}
{% for field in ['username', 'email', 'active', 'admin', 'super_admin'] %}
<th scope="row">{{ data[field] if data[field] }}</th>
{% endfor %}
<td>
{% if not data['super_admin'] %}
<a href="{{ url_for('admin_toggle_active', username=data['username']) }}"><img src="static/stop.png" title="Aktivieren/Deaktivieren" alt="Toggle active"></a>
<a href="{{ url_for('delete_admins', username=data['username']) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
{% endif %}
{% if data['admin'] %}
{% if not data['super_admin'] %}
<a href="{{ url_for('demote_admin', username=data['username']) }}"><img src="static/demote.png" title="Admin-Rechte widerrufen" alt="Demote"></a>
{% endif %}
{% else %}
<a href="{{ url_for('promote_admin', username=data['username']) }}"><img src="static/promote.png" title="Zu Admin machen" alt="Promote"></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-grid gap-3">
<div class="p-2 bg-light border">
<h3>Neuen Benutzer erstellen:</h3>
<form method="POST">
<table>
{{ form.csrf_token }}
<tr>
<td>{{ form.name.label }}</td>
<td>{{ form.name(size=20) }}</td>
</tr>
<tr>
<td>{{ form.email.label }}</td>
<td>{{ form.email(size=20) }}</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit" value="Abschicken">
</td>
</tr>
</table>
</form>
</div>
<div class="p-2 bg-light border">
<h3>Nutzerdaten sichern:</h3>
<form action="{{ url_for('backup_user_datastore') }}" method="get">
<input type="submit" value="Download">
</form>
</div>
<div class="p-2 bg-light border">
<h3>Nutzerdaten wiederherstellen:</h3>
<form action="{{ url_for('restore_user_datastore') }}" method=post enctype=multipart/form-data>
<input type=file name=file>
<input type=submit value="Abschicken">
</form>
</div>
</div>
{% endblock %}

View File

@ -1,27 +1,84 @@
<!doctype html>
<title>Space Token Administration - {% block title %}{% endblock %}</title>
<html lang="de">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<nav>
<ul>
<li><a href="{{ url_for('door_lock') }}">Home</a>
<li><a href="{{ url_for('register') }}">Token Registrierung</a>
<li><a href="{{ url_for('list_tokens') }}">Token Übersicht</a>
<li><a href="{{ url_for('open_door') }}">Tür öffnen</a>
<li><a href="{{ url_for('close_door') }}">Tür schließen</a>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('security.change_password') }}">Passwort ändern</a>
<li><a href="{{ url_for('security.logout') }}">Benutzer <span>{{ current_user.username }}</span> ausloggen</a>
{% else %}
<li><a href="{{ url_for('security.login') }}">Einloggen</a>
{% endif %}
</ul>
</nav>
<section class="content">
<header>
{% block header %}{% endblock %}
</header>
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
</section>
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<title>Space Token Administration - {% block title %}{% endblock %}</title>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('door_lock') }}"><img src="{{ url_for('static', filename='iR.svg') }}" alt="iR Logo"></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if current_user.is_authenticated %}
{% if current_user.has_role('admin') %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
Tokens
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="{{ url_for('register') }}">Token Registrierung</a>
<a class="dropdown-item" href="{{ url_for('list_tokens') }}">Token Übersicht</a>
{% if current_user.has_role('super_admin') %}
<a class="dropdown-item" href="{{ url_for('token_log') }}">Token Log</a>
{% endif %}
</div>
</li>
{% endif %}
{% if current_user.has_role('super_admin') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('manage_admins') }}">Benutzer verwalten</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" id="navbarDropdown" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Benutzer <span>{{ current_user.username }}</span>
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item" href="{{ url_for('security.change_password') }}">Passwort
ändern</a>
<a class="dropdown-item" href="{{ url_for('security.logout') }}">Ausloggen</a>
</div>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('security.login') }}">Einloggen</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<section class="content">
<div class="container">
<header>
{% block header %}{% endblock %}
</header>
{% for message in get_flashed_messages() %}
<div class="alert alert-primary" role="alert">
{{ message }}
</div>
{% endfor %}
{% block content %}{% endblock %}
</div>
</section>
<script src="../static/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Token löschen</h1>{% endblock %}
<script src="../static/jquery-3.6.0.js"></script>
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Benutzer löschen</h1>{% endblock %}
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}
<div>
Achtung, Benutzer '{{ username }}' wird gelöscht.
Bitte zur Bestätigung den Nutzernamen eingeben:
<form method="POST">
<table>
{{ form.csrf_token }}
<tr>
<td>{{ form.name.label }}</td>
<td>{{ form.name(size=20) }}</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit" value="Bestätigen">
</td>
</tr>
</table>
</form>
</div>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Token editieren</h1>{% endblock %}
<script src="../static/jquery-3.6.0.js"></script>
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}

View File

@ -4,16 +4,35 @@
{% endblock %}
{% block content %}
Zustand der Tür:
{% if door_state == 'closed' %}
<div style="color: red">
Abgeschlossen
<div class="row">
Zustand der Tür:
{% if door_state == 'close' %}
<div style="color: red">
Abgeschlossen
</div>
{% elif door_state == 'open' %}
<div style="color: limegreen">
Geöffnet
</div>
{% endif %}
</div>
<div class="row">
Position Drehgeber: {{ encoder_position }}
</div>
{% if current_user.is_authenticated %}
<div class="row">
<div class="col-3">
<a href="{{ url_for('open_door') }}" class="btn btn-success" role="button">Tür öffnen</a>
</div>
{% elif door_state == 'open' %}
<div style="color: limegreen">
Geöffnet
<div class="col-1"></div>
<div class="col-3">
<a href="{{ url_for('close_door') }}" class="btn btn-danger" role="button">Tür schließen</a>
</div>
<div class="col-5"></div>
</div>
{% endif %}
<br>
Position Drehgeber: {{ encoder_position }}
{% endblock %}
{% endblock %}

View File

@ -1,57 +1,88 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Token Registrierung</h1>{% endblock %}
<script src="../static/jquery-3.6.0.js"></script>
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}
{% if not token.vars %}
Letzter gelesener unregistrierter Token: {{ token['token'] }} <br>
Gelesen: {{ token['timestamp']}}
<div>
<p>
RaumnutzerIn registrieren:
</p>
<form method="POST">
<table>
{{ form.csrf_token }}
<tr>
<td>{{ form.name.label }}</td>
<td>{{ form.name(size=20) }}</td>
</tr>
<tr>
<td>{{ form.email.label }}</td>
<td>{{ form.email(size=20) }}</td>
</tr>
<tr>
<td>{{ form.organization.label }}</td>
<td>{{ form.organization(size=20) }}</td>
</tr>
<tr>
<td>{{ form.limit_validity.label }}</td>
<td> {{ form.limit_validity() }}</td>
</tr>
<tr id="valid_thru_row" style="display: none">
<td>{{ form.valid_thru.label }} </td>
<td>{{ form.valid_thru() }}</td>
</tr>
<tr>
<td>{{ form.dsgvo.label }} </td>
<td> {{ form.dsgvo() }}</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit" value="Abschicken">
</td>
</tr>
</table>
</form>
<div class="d-grid gap-3">
<div class="p-2 bg-light border">
<h2>Anleitung zur Schlüsselregistrierung</h2>
<ol>
<li>RFID-Token bereithalten (liegen im Regal im hinteren Raum)<br>
<img src="static/token.png" title="Token" alt="Token"><br>
Wichtig: Der RFID-Token darf nicht bereits registriert sein. Falls ein Token neu beschrieben werden soll,
muss zunächst die bestehende Registrierung gelöscht werden.</li>
<li>RFID-Token einmal von außen an das Lesegerät an der Tür halten. Danach diese Seite neu laden.</li>
<li>Im Feld weiter unten erscheint die ID des Token. Außerdem wird die Uhrzeit des Lesevorgangs angezeigt.
Diese bitte überprüfen, damit nicht versehentlich ein falscher Token registriert wird.</li>
<li>Wenn alles passt, kann der Token registiert werden. Hierzu im Registierungsfeld Namen,
Organisationszugehörigkeit (z.B. imaginärraum o. TransitionHaus) und eine E-Mail-Adresse zur
Kontaktaufnahme angeben. Optional kann die Gültigkeitsdauer des Tokens begrenzt werden.
Zusätzlich muss angegeben werden, dass der/die NutzerIn über die Nutzungsbedingungen aufgeklärt wurde
und diesen zustimmt.
</li>
<li>Achtung: Der Token funktioniert nicht sofort, sondern muss erst explizit aktiviert werden!
Dazu in der <a href="{{ url_for('list_tokens') }}">Token-Übersicht</a> auf das Bearbeiten-Symbol
(<img src="static/edit.png" title="Editieren" alt="Edit">) klicken und den Haken bei "Aktiv?" setzen.
</li>
<li>Jetzt kann der Token verwendet werden.</li>
</ol>
</div>
{% if 'token' in token and 'timestamp' in token %}
<div class="alert alert-success" role="alert">
<h4 class="alert-heading">Unregistrierter Token gelesen:</h4>
<p>Token ID: {{ token['token'] }}</p>
<hr>
<p class="mb-0">Zeitstempel (UTC): {{ token['timestamp']}} (vor {{ token['timedelta_minutes'] }} Minuten)</p>
</div>
<div class="p-2 bg-light border">
<h3>RaumnutzerIn registrieren:</h3>
<form method="POST">
<table>
{{ form.csrf_token }}
<tr>
<td>{{ form.name.label }}</td>
<td>{{ form.name(size=20) }}</td>
</tr>
<tr>
<td>{{ form.email.label }}</td>
<td>{{ form.email(size=20) }}</td>
</tr>
<tr>
<td>{{ form.organization.label }}</td>
<td>{{ form.organization(size=20) }}</td>
</tr>
<tr>
<td>{{ form.limit_validity.label }}</td>
<td> {{ form.limit_validity() }}</td>
</tr>
<tr id="valid_thru_row" style="display: none">
<td>{{ form.valid_thru.label }} </td>
<td>{{ form.valid_thru() }}</td>
</tr>
<tr>
<td>{{ form.dsgvo.label }} </td>
<td> {{ form.dsgvo() }}</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit" value="Abschicken">
</td>
</tr>
</table>
</form>
</div>
</div>
{% else %}
Keine unregistrierten Tokens in MQTT Nachrichten. Bitte Token scannen und die Seite neu laden.
<div class="alert alert-warning" role="alert">
<p>Keine unregistrierten Tokens in MQTT Nachrichten. </p>
<p>Bitte Token scannen und die Seite neu laden.</p>
</div>
{% endif %}
<script>

View File

@ -0,0 +1 @@
<!-- empty since messages are already displayed in the base.html template -->

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block doc -%}
<!DOCTYPE html>
<html{% block html_attribs %}{% endblock html_attribs %}>
{%- block html %}
<head>
{%- block head %}
<title>{% block title %}{{ title|default }}{% endblock title %}</title>
{%- block metas %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{%- endblock metas %}
{%- block styles %}
{%- endblock styles %}
{%- endblock head %}
</head>
<body{% block body_attribs %}{% endblock body_attribs %}>
{% block body -%}
{% block navbar %}
{%- endblock navbar %}
{% block content -%}
{%- endblock content %}
{% block scripts %}
{%- endblock scripts %}
{%- endblock body %}
</body>
{%- endblock html %}
</html>
{% endblock doc -%}

View File

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% block header %}
{% block title %}<h1>Token Log</h1>{% endblock %}
{% endblock %}
{% block content %}
<table class="table">
<thead>
<td>Timestamp</td>
<td>Level</td>
<td>Message</td>
</thead>
<tbody>
{% for line in log %}
{% if line[2] == 'INFO' %}
<tr style="background-color: lightgreen">
{% elif line[2] == 'WARNING'%}
<tr style="background-color: orange">
{% elif line[2] == 'ERROR' %}
<tr style="background-color: red">
{% elif line[2] == 'DEBUG'%}
<tr style="background-color: lightblue">
{% else %}
<tr>
{% endif %}
<td>{{ line[0] }}</td>
<td>{{ line[2] }}</td>
<td>{{ line[3] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -2,19 +2,28 @@
{% block header %}
{% block title %}<h1>Token Übersicht</h1>{% endblock %}
<script src="../static/jquery-3.6.0.js"></script>
<script src="../static/js/jquery-3.6.0.js"></script>
{% endblock %}
{% block content %}
<table border="1">
<table class="table">
<thead>
<td>Token</td>
<td>NutzerIn</td>
<td>Organisation</td>
<td>E-Mail</td>
<td>Gültig bis</td>
<td>Aktionen</td>
</thead>
<tbody>
{% for t, data in assigned_tokens.items() %}
<tr>
<tr
{% if loop.index % 2 %}
style="background-color: lightgrey"
{% else %}
style="background-color: mintcream"
{% endif %}
>
<td>{{ t }}</td>
{% for field in ['name', 'organization', 'email', 'valid_thru'] %}
<td>{{ data[field] if data[field] }}</td>
@ -26,17 +35,26 @@
</td>
</tr>
{% endfor %}
{% if inactive_tokens | length > 0 %}
<tr>
<td>Inaktive Tokens:</td>
</tr>
{% endif %}
{% for t, data in inactive_tokens.items() %}
<tr style="background-color: lightgrey">
<tr style="background-color: sandybrown">
<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>
<img src="static/delete.png" title="Löschen" alt="Delete" onclick="confirmDelete('{{ t }}')">
<a href="{{ url_for('delete_token', token=t) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="{{ url_for('backup_tokens') }}" method="get">
<input type="submit" value="Token Daten sichern">
</form>
{% endblock %}

View File

@ -1,21 +1,29 @@
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
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_security.utils import find_user
from flask_security.views import change_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 datetime import date, datetime, timedelta
from .door_handle import DoorHandle
@ -39,26 +47,84 @@ 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')
logger.setLevel(logging.INFO)
if config.log_file is not None:
ch = logging.FileHandler(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)
# do some checks for file existence etc.
try:
with open(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(config.key_file).absolute()}. Exception: {e}. Using default values instead.")
secret_key = 'Q7PJu2fg2jabYwP-Psop6c6f2G4'
security_password_salt = '10036796768252925167749545152988277953'
if Path(config.template_folder).is_absolute():
if not Path(config.template_folder).exists():
logger.error(f'Flask template folder not found at {Path(config.template_folder).absolute()}')
else:
if not (Path(__file__).parent / config.template_folder).exists():
logger.error(f'Flask template folder not found at {(Path(__file__).parent / config.template_folder).absolute()}')
if Path(config.static_folder).is_absolute():
if not Path(config.static_folder).exists():
logger.error(f'Flask static folder not found at {Path(config.static_folder).absolute()}')
else:
if not (Path(__file__).parent / config.static_folder).exists():
logger.error(f'Flask static folder not found at {(Path(__file__).parent / config.static_folder).absolute()}')
if not Path(config.token_file).exists():
logger.warning(f"Token file not found at {Path(config.token_file).absolute()}")
# create door objects which provides access to the token file and current door state via MQTT
door = DoorHandle(token_file=config.token_file, mqtt_host=config.mqtt_host, nfc_socket=config.nfc_socket)
door = DoorHandle(token_file=config.token_file, mqtt_host=config.mqtt_host, nfc_socket=config.nfc_socket,
logger=logger)
app = Flask(__name__, template_folder=config.template_folder, static_folder=config.static_folder)
# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'Q7PJu2fg2jabYwP-Psop6c6f2G4')
app.config['SECRET_KEY'] = secret_key
# 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_PASSWORD_SALT'] = security_password_salt
app.config['SECURITY_USER_IDENTITY_ATTRIBUTES'] = [
{"email": {"mapper": uia_email_mapper, "case_insensitive": True}},
@ -97,111 +163,337 @@ def create_application(config):
pass
class User(db.Model, fsqla.FsUserMixin):
username = db.Column(db.String(255))
pass
# LDAP
ldap_server = ldap3.Server(config.ldap_url)
local_ldap_cache = {} # dict for caching LDAP authorization locally (stores username + hashed password)
def validate_ldap(user, password):
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 and the password is stored
locally for future authorization without internet connectivity.
If the server is not reachable we check the password against a locally stored password (if the user previously
authorized through LDAP).
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
----------
user : username for the LDAP server
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="uid=%s,ou=Users,dc=imaginaerraum,dc=de" % (user.username,),
password=password, auto_bind=True)
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
return False, None
except LDAPSocketOpenError:
# server not reachable -> try cached authorization data
return user.username in local_ldap_cache and verify_password(password, local_ldap_cache[user.username])
except Exception:
# 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
return False, None
# TODO check if user has permission to edit tokens
# if LDAP authorization succeeds we cache the password locally (in memory) to allow LDAP authentication even if
# the server is not reachable
local_ldap_cache[user.username] = hash_password(password)
return True
# 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
class ExtendedLoginForm(LoginForm):
email = StringField('Benutzername oder E-Mail', [Required()])
password = PasswordField('Passwort', [Required()])
remember = BooleanField('Login merken?')
def validate(self):
# try authorizing locally using Flask security user datastore
authorized = super(ExtendedLoginForm, self).validate()
# 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 not authorized:
# try authorizing using LDAP
# authorization in LDAP uses username -> get username associated with email from the database
user = find_user(self.email.data)
authorized = validate_ldap(user, self.password.data)
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
# we override the change_password view from flask security to only allow local users to change their passwords
# LDAP users should use the LDAP self service for changing passwords
# this route needs to be defined before the Flask Security setup
@app.route('/change', methods=['GET', 'POST'])
@auth_required()
def change_pw():
if current_user.has_role('local'):
# local users can change their password
return change_password()
else:
# LDAP users get redirected to the LDAP self service
return redirect('https://ldap.imaginaerraum.de/')
# Setup Flask-Security
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(app, user_datastore, login_form=ExtendedLoginForm)
# create admin users (only if they don't exists already)
def create_admins(admin_user_file):
with open(admin_user_file) as f:
admin_data = f.readlines()
for i, d in enumerate(admin_data):
try:
user, email, pw = d.split()
if user_datastore.find_user(email=email, username=user) is None:
validate_email(email)
# create new admin (only if admin does not already exist)
user_datastore.create_user(email=email, username=user, password=hash_password(pw))
except Exception as e:
print(f"Error while parsing line {i} in admin config file. Config file should contain lines of "
f"'<username> <email> <password>\\n'\n Exception: {e}\nAdmin account could not be created.")
db.session.commit()
# admin user management
@app.route('/manage_admins', methods=['GET', 'POST'])
@roles_required('super_admin')
def manage_admins():
form = AdminCreationForm()
if request.method == 'GET':
users = user_datastore.user_model.query.all()
admin_data = [{'username': u.username, 'email': u.email, 'active': u.is_active,
'admin': u.has_role('admin'), 'super_admin': u.has_role('super_admin'),
} for u in users]
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("Ein Benutzer mit diesem Nutzernamen oder dieser E-Mail-Adresse existiert bereits!")
return redirect('/manage_admins')
else:
pw = secrets.token_urlsafe(16)
new_user = user_datastore.create_user(username=form.name.data, email=form.email.data,
password=hash_password(pw))
user_datastore.add_role_to_user(new_user, 'local')
logger.info(
f"Super admin {current_user.username} created new user account for {new_user.username} <{new_user.email}>")
flash(f"Ein Account für den Nutzer {new_user.username} wurde erstellt. Verwende das Passwort {pw} um den Nutzer einzuloggen.")
db.session.commit()
return redirect('/manage_admins')
# Create a user to test with
@app.before_first_request
def create_user():
db.create_all()
if config.admin_file is not None:
# create admin accounts from given file
create_admins(config.admin_file)
db.session.commit()
@app.route('/delete_admins/<username>', methods=['GET', 'POST'])
@roles_required('super_admin')
def delete_admins(username):
user = user_datastore.find_user(username=username)
if user is None:
flash(f"Ungültiger Nutzer {username}")
return redirect('/manage_admins')
if user.has_role('super_admin'):
flash('Super-Admins können nicht gelöscht werden!')
return redirect('/manage_admins')
if user.is_active:
flash('Aktive Nutzer können nicht gelöscht werden! Bitte den Benutzer zuerst deaktivieren.')
return redirect('/manage_admins')
# set up form for confirming deletion
form = ConfirmDeleteForm()
form.name_confirm.data = username
if request.method == 'GET':
# return page asking the user to confirm delete
return render_template('delete_user.html', username=username, form=form)
elif form.validate():
user_datastore.delete_user(user)
flash(f"Benutzer {username} wurde gelöscht.")
logger.info(f"Super admin {current_user.username} deleted admin user {username}")
db.session.commit()
return redirect('/manage_admins')
else:
flash("Der eingegebene Nutzername stimmt nicht überein. Der Benutzer wurde nicht gelöscht!")
return redirect('/manage_admins')
@app.route('/admin_toggle_active/<username>')
@roles_required('super_admin')
def admin_toggle_active(username):
user = user_datastore.find_user(username=username)
if user is None:
flash(f"Ungültiger Nutzer {username}")
return redirect('/manage_admins')
if user.has_role('super_admin'):
flash('Super-Admins können nicht deaktiviert werden!')
return redirect('/manage_admins')
user_datastore.toggle_active(user)
if user.is_active:
logger.info(f"Super admin {current_user.username} activated access for admin user {username}")
else:
logger.info(f"Super admin {current_user.username} deactivated access for admin user {username}")
db.session.commit()
return redirect('/manage_admins')
@app.route('/promote_admin/<username>')
@roles_required('super_admin')
def promote_admin(username):
user = user_datastore.find_user(username=username)
if user is None:
flash(f"Ungültiger Nutzer {username}")
return redirect('/manage_admins')
if user.has_role('admin'):
flash(f'Benutzer {username} hat bereits Admin-Rechte!')
return redirect('/manage_admins')
user_datastore.add_role_to_user(user, 'admin')
logger.info(f"Super admin {current_user.username} granted admin privileges to user {username}")
db.session.commit()
return redirect('/manage_admins')
@app.route('/demote_admin/<username>')
@roles_required('super_admin')
def demote_admin(username):
user = user_datastore.find_user(username=username)
if user is None:
flash(f"Ungültiger Nutzer {username}")
return redirect('/manage_admins')
if user.has_role('super_admin'):
flash(f'Benutzer {username} hat Super-Admin-Rechte und kann nicht verändert werden!')
return redirect('/manage_admins')
if user.has_role('admin'):
user_datastore.remove_role_from_user(user, 'admin')
logger.info(f"Super admin {current_user.username} revoked admin privileges of user {username}")
db.session.commit()
else:
flash(f'Benutzer {username} ist bereits kein Admin!')
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,
'roles': [r.name for r in u.roles]}
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', 'roles'}
entry_valid &= all(len(d[key]) > 0 for key in ['email', 'password_hash', 'username'])
entry_valid &= type(d['active']) == bool
entry_valid &= type(d['roles']) == list
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'], active=d['active'],
roles=d['roles'])
flash(f"Account für Benutzer '{d['username']} wurde wiederhergestellt.")
else:
flash(f"Benutzer '{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("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()
@roles_required('admin')
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('/token-log')
@roles_required('super_admin')
def token_log():
log = []
try:
with open(config.nfc_log) as f:
log += f.readlines()
log.reverse()
log = [l.split(' - ') for l in log]
return render_template('token_log.html', log=log)
except Exception as e:
flash(f"NFC logfile {Path(config.nfc_log).absolute()} konnte nicht gelesen werden. Exception: {e}")
return redirect('/')
# routes for registering, editing and deleting tokens
@app.route('/register-token', methods=['GET', 'POST'])
@auth_required()
@roles_required('admin')
def register():
"""Register new token for locking and unlocking the door.
@ -211,12 +503,22 @@ def create_application(config):
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.
"""
token = door.get_most_recent_token()
recent_token = {}
if {'token', 'timestamp'}.issubset(set(token.keys())):
dt = datetime.utcnow() - token['timestamp']
if dt < timedelta(minutes=10):
recent_token = token
recent_token['timedelta_minutes'] = int(dt.total_seconds() / 60.0)
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)
return render_template('register.html', token=recent_token, form=form)
elif request.method == 'POST' and form.validate():
# store data in session cookie
session['token'] = door.get_most_recent_token()['token']
@ -228,13 +530,12 @@ def create_application(config):
else:
session['valid_thru'] = ''
session['inactive'] = not form.active.data
return redirect(f'/store-token')
return redirect('/store-token')
else:
return render_template('register.html', token=door.get_most_recent_token(), form=form)
return render_template('register.html', token=recent_token, form=form)
@app.route('/edit-token/<token>', methods=['GET', 'POST'])
@auth_required()
@roles_required('admin')
def edit_token(token):
"""Edit data in the token file (name, email, valid_thru date, active/inactive).
@ -249,7 +550,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:
@ -288,9 +589,8 @@ def create_application(config):
else:
return render_template('edit.html', token=token, form=form)
@app.route('/store-token')
@auth_required()
@roles_required('admin')
def store_token():
"""Store token to the token file on disk.
@ -306,13 +606,13 @@ def create_application(config):
'organization': session['organization']}
try:
door.store_tokens(tokens)
logger.info(f"Token {token} stored in database by admin user {current_user.username}")
except Exception as e:
flash(f"Error during store_tokens. Exception: {e}")
return redirect('/tokens')
@app.route('/delete-token/<token>', methods=['GET', 'POST'])
@auth_required()
@roles_required('admin')
def delete_token(token):
"""Delete the given token from the token file and store the new token file to disk
@ -327,7 +627,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':
@ -338,21 +638,22 @@ def create_application(config):
tokens.pop(token)
try:
door.store_tokens(tokens)
logger.info(f"Token {token} was deleted from database by admin user {current_user.username}")
except Exception as e:
flash(f"Error during store_tokens. Exception: {e}")
flash(f"Token {token} wurde gelöscht!")
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/<token>')
@auth_required()
@roles_required('admin')
def deactivate_token(token):
"""Deactivate access for the given token. This updates the token file on disk.
@ -366,26 +667,86 @@ def create_application(config):
tokens[token]['inactive'] = True
try:
door.store_tokens(tokens)
logger.info(f"Token {token} deactivated by admin user {current_user.username}")
except Exception as e:
flash(f"Error during store_tokens. Exception: {e}")
return redirect('/tokens')
@app.route('/backup_tokens')
@roles_required('admin')
def backup_tokens():
# get list of defined admin users for backup
tokens = door.get_tokens()
try:
with tempfile.TemporaryDirectory() as tmpdir:
file = Path(tmpdir, 'token_data.txt')
file.write_text(json.dumps(tokens))
return send_file(file, as_attachment=True, cache_timeout=-1)
except Exception as e:
return str(e)
@app.route('/open')
@auth_required()
def open_door():
try:
door.open_door()
door.open_door(user=current_user.username)
logger.info(f"Door opened by admin user {current_user.username}")
except Exception as e:
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():
try:
door.close_door()
door.close_door(user=current_user.username)
logger.info(f"Door closed by admin user {current_user.username}")
except Exception as e:
flash(f'Could not close door. Exception: {e}')
return redirect('/')
# 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"'<username> <email> <password>\\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)
return app

View File

@ -1,6 +1,6 @@
[metadata]
name = imaginaerraum_door_admin
version = 0.0.7
version = 0.0.9
author = Telos4
author_email = simon.pirkelmann@gmail.com
description = A simple web interface for our hackerspace's door token administration