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.
+
+
+
Token | +Name | +Created | +Actions | +
---|---|---|---|
•••••••• | +{{ token.name }} | +{{ token.date }} | +🗑 | +