From 418cff7348cab018c53219d028612139b8b33dc1 Mon Sep 17 00:00:00 2001 From: s3lph Date: Sun, 1 Dec 2024 21:58:16 +0100 Subject: [PATCH] feat: add barcode login feature --- CHANGELOG.md | 13 ++++ matemat/__init__.py | 2 +- matemat/db/facade.py | 76 +++++++++++++++++++++- matemat/db/migrations.py | 14 ++++ matemat/db/primitives/Token.py | 40 ++++++++++++ matemat/db/primitives/__init__.py | 1 + matemat/db/schemas.py | 93 +++++++++++++++++++++++++++ matemat/db/wrapper.py | 6 +- matemat/webserver/pagelets/admin.py | 39 +++++++++-- matemat/webserver/pagelets/main.py | 13 ++++ matemat/webserver/pagelets/moduser.py | 14 ++++ templates/admin.html | 7 ++ templates/admin_all.html | 33 ++++++++++ templates/moduser.html | 19 ++++++ 14 files changed, 363 insertions(+), 7 deletions(-) create mode 100644 matemat/db/primitives/Token.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce611a0..5093203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Matemat Changelog + +## Version 0.3.17 + +Add barcode login feature + +### Changes + + +- feat: add barcode login feature + + + + ## Version 0.3.16 diff --git a/matemat/__init__.py b/matemat/__init__.py index b91143c..1736e67 100644 --- a/matemat/__init__.py +++ b/matemat/__init__.py @@ -1,2 +1,2 @@ -__version__ = '0.3.16' +__version__ = '0.3.17' diff --git a/matemat/db/facade.py b/matemat/db/facade.py index 3d6c33a..c8fda1e 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -5,7 +5,7 @@ import crypt from hmac import compare_digest from datetime import datetime, UTC -from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \ +from matemat.db.primitives import User, Token, Product, ReceiptPreference, Receipt, \ Transaction, Consumption, Deposit, Modification from matemat.exceptions import AuthenticationError, DatabaseConsistencyError from matemat.db import DatabaseWrapper @@ -358,6 +358,80 @@ class MatematDatabase(object): raise DatabaseConsistencyError( f'delete_user should affect 1 users row, but affected {affected}') + def list_tokens(self, uid: int) -> List[Token]: + tokens: List[Token] = [] + with self.db.transaction(exclusive=False) as c: + for row in c.execute(''' + SELECT token_id, user_id, token, name, date + FROM tokens + WHERE user_id = :user_id + ORDER BY date + ''', {'user_id': uid}): + token_id, user_id, token, name, date = row + tokens.append( + Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC))) + return tokens + + def get_token(self, tid: int) -> List[Token]: + with self.db.transaction(exclusive=False) as c: + c.execute(''' + SELECT token_id, user_id, token, name, date + FROM tokens + WHERE token_id = :token_id + ''', {'token_id': tid}) + token_id, user_id, token, name, date = c.fetchone() + return Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC)) + + def tokenlogin(self, token: str) -> User: + """ + Validte a user's token and return a User object on success. + :param token: The token to log in with. + :return: A tuple of (User, Token). + :raises ValueError: If none or both of password and touchkey are provided. + :raises AuthenticationError: If the user does not exist or the password or touchkey is wrong. + """ + if token is None: + raise ValueError('token must be provided') + with self.db.transaction(exclusive=False) as c: + c.execute(''' + SELECT token_id, user_id, token, name, date + FROM tokens + WHERE token = :token + ''', {'token': token}) + row = c.fetchone() + if row is None: + raise AuthenticationError('Token does not exist') + token_id, user_id, token, name, date = row + user = self.get_user(user_id) + return user, Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC)) + + def add_token(self, user: User, token: str, name: str): + with self.db.transaction() as c: + if name is None: + name = token[:3] + (len(token) - 3) * '*' + c.execute(''' + INSERT INTO tokens (user_id, token, name) + VALUES (:user_id, :token, :name) + ''', { + 'user_id': user.id, + 'token': token, + 'name': name + }) + c.execute('SELECT last_insert_rowid()') + token_id = int(c.fetchone()[0]) + return Token(token_id, user.id, token, name) + + def delete_token(self, token: Token): + with self.db.transaction() as c: + c.execute(''' + DELETE FROM tokens + WHERE token_id = :token_id + ''', {'token_id': token.id}) + affected = c.execute('SELECT changes()').fetchone()[0] + if affected != 1: + raise DatabaseConsistencyError( + f'delete_token should affect 1 token row, but affected {affected}') + def list_products(self) -> List[Product]: """ Return a list of products in the database. diff --git a/matemat/db/migrations.py b/matemat/db/migrations.py index 2e823d6..d9f8c64 100644 --- a/matemat/db/migrations.py +++ b/matemat/db/migrations.py @@ -295,3 +295,17 @@ def migrate_schema_7_to_8(c: sqlite3.Cursor): c.execute(''' CREATE UNIQUE INDEX _matemat_products_ean_unique ON products(ean) ''') + + +def migrate_schema_8_to_9(c: sqlite3.Cursor): + c.execute(''' + CREATE TABLE tokens ( + token_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + name TEXT UNIQUE NOT NULL, + date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')), + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE ON UPDATE CASCADE + ) + ''') diff --git a/matemat/db/primitives/Token.py b/matemat/db/primitives/Token.py new file mode 100644 index 0000000..8d515b6 --- /dev/null +++ b/matemat/db/primitives/Token.py @@ -0,0 +1,40 @@ + +from typing import Optional + +from datetime import datetime, UTC + + +class Token: + """ + Representation of an authentication token (such as a barcode) associated with a user. + + :param _id: The token ID in the database. + :param user_id: The ID of the user this token belongs to. + :param token: The token secret. + :param name: The display name of the token: + :param date: The date the tokenw as created. + """ + + def __init__(self, + _id: int, + user_id: int, + token: str, + name: Optional[str] = None, + date: Optional[datetime] = None) -> None: + self.id: int = _id + self.user_id: int = user_id + self.token: str = token + self.name = name + self.date = date + + def __eq__(self, other) -> bool: + if not isinstance(other, Token): + return False + return self.id == other.id and \ + self.user_id == other.user_id and \ + self.token == other.token and \ + self.name == other.name and \ + self.date == other.date + + def __hash__(self) -> int: + return hash((self.id, self.user_id, self.token, self.name, self.date)) diff --git a/matemat/db/primitives/__init__.py b/matemat/db/primitives/__init__.py index ff7e95b..3bc677a 100644 --- a/matemat/db/primitives/__init__.py +++ b/matemat/db/primitives/__init__.py @@ -3,6 +3,7 @@ This package provides the 'primitive types' the Matemat software deals with - na """ from .User import User +from .Token import Token from .Product import Product from .ReceiptPreference import ReceiptPreference from .Transaction import Transaction, Consumption, Deposit, Modification diff --git a/matemat/db/schemas.py b/matemat/db/schemas.py index a595322..54d0980 100644 --- a/matemat/db/schemas.py +++ b/matemat/db/schemas.py @@ -576,3 +576,96 @@ SCHEMAS[8] = [ ON DELETE SET NULL ON UPDATE CASCADE ); '''] + + +SCHEMAS[9] = [ + ''' + CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT DEFAULT NULL, + password TEXT NOT NULL, + touchkey TEXT DEFAULT NULL, + is_admin INTEGER(1) NOT NULL DEFAULT 0, + is_member INTEGER(1) NOT NULL DEFAULT 1, + balance INTEGER(8) NOT NULL DEFAULT 0, + lastchange INTEGER(8) NOT NULL DEFAULT 0, + receipt_pref INTEGER(1) NOT NULL DEFAULT 0, + created INTEGER(8) NOT NULL DEFAULT 0, + logout_after_purchase INTEGER(1) DEFAULT 0 + ); + ''', + ''' + CREATE TABLE products ( + product_id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + stock INTEGER(8) DEFAULT 0, + stockable INTEGER(1) DEFAULT 1, + price_member INTEGER(8) NOT NULL, + price_non_member INTEGER(8) NOT NULL, + custom_price INTEGER(1) DEFAULT 0, + ean TEXT UNIQUE DEFAULT NULL + ); + ''', + ''' + CREATE TABLE transactions ( -- "superclass" of the following 3 tables + ta_id INTEGER PRIMARY KEY, + user_id INTEGER DEFAULT NULL, + value INTEGER(8) NOT NULL, + old_balance INTEGER(8) NOT NULL, + date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')), + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE SET NULL ON UPDATE CASCADE + ); + ''', + ''' + CREATE TABLE consumptions ( -- transactions involving buying a product + ta_id INTEGER PRIMARY KEY, + product TEXT NOT NULL, + FOREIGN KEY (ta_id) REFERENCES transactions(ta_id) + ON DELETE CASCADE ON UPDATE CASCADE + ); + ''', + ''' + CREATE TABLE deposits ( -- transactions involving depositing cash + ta_id INTEGER PRIMARY KEY, + FOREIGN KEY (ta_id) REFERENCES transactions(ta_id) + ON DELETE CASCADE ON UPDATE CASCADE + ); + ''', + ''' + CREATE TABLE modifications ( -- transactions involving balance modification by an admin + ta_id INTEGER NOT NULL, + agent TEXT NOT NULL, + reason TEXT DEFAULT NULL, + PRIMARY KEY (ta_id), + FOREIGN KEY (ta_id) REFERENCES transactions(ta_id) + ON DELETE CASCADE ON UPDATE CASCADE + ); + ''', + ''' + CREATE TABLE receipts ( -- receipts sent to the users + receipt_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + first_ta_id INTEGER DEFAULT NULL, + last_ta_id INTEGER DEFAULT NULL, + date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')), + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (first_ta_id) REFERENCES transactions(ta_id) + ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (last_ta_id) REFERENCES transactions(ta_id) + ON DELETE SET NULL ON UPDATE CASCADE + ); + ''', + ''' + CREATE TABLE tokens ( -- authentication tokens such as barcodes + token_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + name TEXT UNIQUE NOT NULL, + date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')), + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE ON UPDATE CASCADE + ); + '''] diff --git a/matemat/db/wrapper.py b/matemat/db/wrapper.py index 996f150..2493b86 100644 --- a/matemat/db/wrapper.py +++ b/matemat/db/wrapper.py @@ -40,7 +40,7 @@ class DatabaseTransaction(object): class DatabaseWrapper(object): - SCHEMA_VERSION = 8 + SCHEMA_VERSION = 9 def __init__(self, filename: str) -> None: self._filename: str = filename @@ -93,6 +93,10 @@ class DatabaseWrapper(object): migrate_schema_6_to_7(c) if from_version <= 7 and to_version >= 8: migrate_schema_7_to_8(c) + if from_version <= 7 and to_version >= 8: + migrate_schema_7_to_8(c) + if from_version <= 8 and to_version >= 9: + migrate_schema_8_to_9(c) def connect(self) -> None: if self.is_connected(): diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py index 99f3771..c073629 100644 --- a/matemat/webserver/pagelets/admin.py +++ b/matemat/webserver/pagelets/admin.py @@ -30,7 +30,7 @@ def admin(): redirect('/login') authlevel: int = session.get(session_id, 'authentication_level') uid: int = session.get(session_id, 'authenticated_user') - # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1) + # Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey or token (1) if authlevel < 2: abort(403) @@ -39,19 +39,20 @@ def admin(): # Fetch the authenticated user user = db.get_user(uid) # If the POST request contains a "change" parameter, delegate the change handling to the function below - if request.method == 'POST' and 'change' in request.params: + if 'change' in request.params: handle_change(request.params, request.files, user, db) # If the POST request contains an "adminchange" parameter, delegate the change handling to the function below - elif request.method == 'POST' and 'adminchange' in request.params and user.is_admin: + elif 'adminchange' in request.params and user.is_admin: handle_admin_change(request.params, request.files, db) # Fetch all existing users and products from the database users = db.list_users() + tokens = db.list_tokens(uid) products = db.list_products() # Render the "Admin/Settings" page now = str(int(datetime.now(UTC).timestamp())) return template.render('admin.html', - authuser=user, authlevel=authlevel, users=users, products=products, + authuser=user, authlevel=authlevel, tokens=tokens, users=users, products=products, receipt_preference_class=ReceiptPreference, now=now, setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts']) @@ -122,6 +123,36 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematData # Write the new touchkey to the database db.change_touchkey(user, '', touchkey, verify_password=False) + # The user added a new token + elif change == 'addtoken': + if 'token' not in args: + return + token = str(args.token) + if len(token) < 6: + return + name = None if 'name' not in args or len(args.name) == 0 else str(args.name) + try: + tokobj = db.add_token(user, token, name) + Notification.success(f'Token {tokobj.name} created successfully') + except DatabaseConsistencyError: + Notification.error('Token already exists', decay=True) + + elif change == 'deltoken': + try: + tokid = int(str(request.params.token)) + token = db.get_token(tokid) + except Exception as e: + Notification.error('Token not found', decay=True) + return + if token.user_id != user.id: + Notification.error('Token not found', decay=True) + return + try: + db.delete_token(token) + except DatabaseConsistencyError: + Notification.error(f'Failed to delete token {token.name}', decay=True) + Notification.success(f'Token {token.name} removed', decay=True) + # The user requested an avatar change elif change == 'avatar': # The new avatar field must be present diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py index a3ee069..478a082 100644 --- a/matemat/webserver/pagelets/main.py +++ b/matemat/webserver/pagelets/main.py @@ -3,6 +3,7 @@ from datetime import datetime, UTC from bottle import route, redirect, request from matemat.db import MatematDatabase +from matemat.exceptions import AuthenticationError from matemat.webserver import template, session from matemat.webserver.template import Notification from matemat.webserver.config import get_app_config, get_stock_provider @@ -28,7 +29,19 @@ def main_page(): Notification.success( f'Login will purchase {buyproduct.name}. Click here to abort.') except ValueError: + if not session.has(session_id, 'authenticated_user'): + try: + user, token = db.tokenlogin(str(request.params.ean)) + # Set the user ID session variable + session.put(session_id, 'authenticated_user', user.id) + # Set the authlevel session variable (0 = none, 1 = touchkey/token, 2 = password login) + session.put(session_id, 'authentication_level', 1) + redirect('/') + except AuthenticationError: + # Redirect to main page on token login error + redirect('/') Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True) + redirect('/') # Check whether a user is logged in if session.has(session_id, 'authenticated_user'): diff --git a/matemat/webserver/pagelets/moduser.py b/matemat/webserver/pagelets/moduser.py index 547d589..54884d8 100644 --- a/matemat/webserver/pagelets/moduser.py +++ b/matemat/webserver/pagelets/moduser.py @@ -88,6 +88,20 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User, except FileNotFoundError: pass + elif change == 'deltoken': + try: + tokid = id(str(request.params.token)) + token = db.get_token(tokid) + except Exception as e: + Notification.error('Token not found', decay=True) + return + try: + db.delete_token(token) + except DatabaseConsistencyError: + Notification.error(f'Failed to delete token {token.name}', decay=True) + Notification.success(f'Token {token.name} removed', decay=True) + return + # Admin requested update of the user's details elif change == 'update': # Only write a change if all properties of the user are present in the request arguments diff --git a/templates/admin.html b/templates/admin.html index 31d8d41..c55da8b 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -25,8 +25,15 @@ {% endblock %} {% block eanwebsocket %} +{% if authuser.is_admin %} let eaninput = document.getElementById("admin-newproduct-ean"); eaninput.value = e.data; eaninput.select(); eaninput.scrollIntoView(); +{% else %} + let tokeninput = document.getElementById("admin-newtoken-token"); + tokeninput.value = e.data; + tokeninput.select(); + tokeninput.scrollIntoView(); +{% endif %} {% endblock %} diff --git a/templates/admin_all.html b/templates/admin_all.html index e21fbb9..4679a06 100644 --- a/templates/admin_all.html +++ b/templates/admin_all.html @@ -78,3 +78,36 @@ initTouchkey(true, 'touchkey-svg', null, 'admin-touchkey-touchkey'); + +
+

Tokens

+ + Warning: + Login tokens are a convenience feature that if used may weaken security. + Make sure you only use tokens not easily accessible to other people. + +
+ + + + + + + + + + + + + + {% for token in tokens %} + + + + + + + {% endfor %} +
TokenNameCreatedActions
••••••••{{ token.name }}{{ token.date }}🗑
+
+
diff --git a/templates/moduser.html b/templates/moduser.html index 3bfa7b7..8f0aa24 100644 --- a/templates/moduser.html +++ b/templates/moduser.html @@ -54,6 +54,25 @@ +

Tokens

+ + + + + + + + + {% for token in tokens %} + + + + + + + {% endfor %} +
TokenNameCreatedActions
••••••••{{ token.name }}{{ token.date }}🗑
+