Compare commits
33 Commits
door_admin
...
master
Author | SHA1 | Date |
---|---|---|
Simon Pirkelmann | 8e868f1674 | |
Simon Pirkelmann | 8a9a667429 | |
Simon Pirkelmann | 8bc0a642a3 | |
Simon Pirkelmann | 7cf4e3ce36 | |
Simon Pirkelmann | 79b9b69ef8 | |
Simon Pirkelmann | 4307d54505 | |
Simon Pirkelmann | 14c783b2c1 | |
Simon Pirkelmann | 2ccec6fe32 | |
Simon Pirkelmann | 5afcf1f10a | |
Simon Pirkelmann | 75ce8c46b8 | |
Simon Pirkelmann | 9a0ae93a7b | |
Simon Pirkelmann | 9d53f80cbf | |
Simon Pirkelmann | d406d254b4 | |
Simon Pirkelmann | cffdb1f797 | |
Simon Pirkelmann | 734bed2092 | |
Simon Pirkelmann | f48f78997c | |
Simon Pirkelmann | c02d5465ea | |
Simon Pirkelmann | 717fe0d83b | |
Simon Pirkelmann | 8339294277 | |
Simon Pirkelmann | db8ee556df | |
Simon Pirkelmann | be0ee36ba9 | |
Simon Pirkelmann | 7205928406 | |
Simon Pirkelmann | c215f367f5 | |
Simon Pirkelmann | 2cb93d1d3b | |
Simon Pirkelmann | 7684268002 | |
Simon Pirkelmann | 5facd44325 | |
Simon Pirkelmann | ccce39d1a0 | |
Simon Pirkelmann | a71f68ade3 | |
Simon Pirkelmann | 312549ac15 | |
Simon Pirkelmann | f021f7494f | |
Simon Pirkelmann | 8cdf549c4c | |
Simon Pirkelmann | e707f4bd87 | |
Simon Pirkelmann | 4197446a00 |
|
@ -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/*
|
||||
|
|
24
Makefile
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 6.4 KiB |
|
@ -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 |
After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.4 KiB |
After Width: | Height: | Size: 89 KiB |
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<!-- empty since messages are already displayed in the base.html template -->
|
|
@ -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 -%}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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
|
||||
|
|