Skip to content

Commit

Permalink
Add user passwords (#301) (#308)
Browse files Browse the repository at this point in the history
* add password fields to schema

* add password related config options

* add ability to set password

* add hidden fields

* password prompt at login

* rearchitect login to use callbacks

* add password check to stockterminal

* update tillweb

* handle hotkeypress() in password prompt

* force setting password on login if required

* add password-only logon

* update last seen when logging in with password

* show user ID in edit dialogs

* clear last_successful_login when tokens are (re)assigned

* allow users to remove their own passwords
  • Loading branch information
inventor02 authored Feb 14, 2025
1 parent 2ce7090 commit 6f1bb3b
Show file tree
Hide file tree
Showing 11 changed files with 536 additions and 34 deletions.
1 change: 1 addition & 0 deletions quicktill/keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def __repr__(self):
keycode("K_PRICECHECK", "Price Check")
keycode("K_LOCK", "Lock")
keycode("K_ONLINE_ORDERS", "Online Orders")
keycode("K_PASS_LOGON", "Log On")

# Tendering keys referred to in the code
keycode("K_CASH", "Cash / Enter")
Expand Down
19 changes: 19 additions & 0 deletions quicktill/localutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
K_LOCK,
K_QUANTITY,
K_DRINKIN,
K_PASS_LOGON,
Key,
notekey,
paymentkey,
Expand Down Expand Up @@ -507,6 +508,7 @@ def global_hotkeys(register_hotkeys, stockterminal_location=["Bar"]):
K_STOCKTERMINAL: lambda: stockterminal.page(
register_hotkeys, stockterminal_location),
K_LOCK: lockscreen.lockpage,
K_PASS_LOGON: lambda: tillconfig.passlogon_handler(),
}


Expand All @@ -523,6 +525,13 @@ def activate_register_with_usertoken(register_hotkeys, timeout=300):
}


def activate_register_with_password(register_hotkeys, timeout=300):
return {
'passlogon_handler': lambda: register.handle_passlogon(
register_hotkeys, autolock=K_LOCK, timeout=timeout),
}


def activate_stockterminal_with_usertoken(
register_hotkeys,
stockterminal_location=["Bar"],
Expand All @@ -539,6 +548,16 @@ def activate_stockterminal_with_usertoken(
}


def activate_stockterminal_with_password(register_hotkeys,
stockterminal_location=["Bar"],
max_unattended_updates=5):
return {
'passlogon_handler': lambda: stockterminal.handle_passlogon(
register_hotkeys, stockterminal_location,
max_unattended_updates=max_unattended_updates),
}


class ServiceCharge(register.RegisterPlugin):
"""Apply a service charge to the current transaction
Expand Down
2 changes: 2 additions & 0 deletions quicktill/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,7 @@ class User(Base, Logged):
message = Column(String(), nullable=True,
doc="Message to present to user on their next keypress")
last_seen = Column(DateTime)
password = Column(String(), nullable=True)
groups = relationship("Group", secondary="group_grants", backref="users")
permissions = relationship(
"Permission",
Expand Down Expand Up @@ -932,6 +933,7 @@ class UserToken(Base, Logged):
description = Column(String())
user_id = Column('user', Integer, ForeignKey('users.id'))
last_seen = Column(DateTime)
last_successful_login = Column(DateTime, nullable=True)
user = relationship(User, backref='tokens')


Expand Down
49 changes: 49 additions & 0 deletions quicktill/passwords.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Till password hashing and checking logic.
This module provides functions to create 'password tuples' which take the format
`algorithm$iterations$salt$hash_hex`.
"""

import secrets
import hashlib


def compute_password_tuple(password):
"""Computes the password tuple for storage in the database.
Returns a string in the format `algorithm$iterations$salt$hash_hex`.
"""
iterations = 500_000
salt = secrets.token_hex(16)
hash = compute_pbkdf2(password, salt, iterations)
return f"pbkdf2${iterations}${salt}${hash}"


def compute_pbkdf2(value, salt, iterations):
"""Computes t he PBKDF2 hash for a value given a salt and number of
iterations.
"""
hash = hashlib.pbkdf2_hmac("sha256", bytes(value, "utf-8"),
bytes(salt, "utf-8"), iterations)
return hash.hex()


def check_password(password, tuple):
"""Checks a password against a tuple.
The tuple must be in the format `algorithm$iterations$salt$hash_hex`.
Malformed values will raise an exception.
"""
elems = tuple.split("$")
if len(elems) != 4:
raise Exception("Invalid password tuple presented (len(elems) != 4).")

algo = elems[0]
iterations = int(elems[1])
salt = elems[2]
hash = elems[3]

if algo == 'pbkdf2':
return compute_pbkdf2(password, salt, iterations) == hash
else:
raise Exception("Unsupported password algorithm: " + algo)
17 changes: 16 additions & 1 deletion quicktill/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -3443,7 +3443,22 @@ def handle_usertoken(t, *args, **kwargs):
Used in the configuration file to specify what happens when a user
token is handled by the default hotkey handler.
"""
u = user.user_from_token(t)
user.token_login(t,
lambda u: finalize_logon(u, *args, **kwargs))


def handle_passlogon(*args, **kwargs):
"""Password logon handler for the register.
"""
user.password_login(lambda u: finalize_logon(u, *args, **kwargs))


def finalize_logon(u, *args, **kwargs):
"""The other half of handle_usertoken.
This allows this function to be used as a callback, where, for example,
logging in requires some asynchronous user input (like a password).
"""
if u is None:
return
for p in ui.basicpage._pagelist:
Expand Down
18 changes: 14 additions & 4 deletions quicktill/stockterminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,22 @@ def set_location(self, location):


def handle_usertoken(t, *args, **kwargs):
"""
Called when a usertoken has been handled by the default hotkey
"""Called when a usertoken has been handled by the default hotkey
handler.
"""
user.token_login(t,
lambda u: finalize_logon(u, *args, **kwargs))


def handle_passlogon(*args, **kwargs):
"""Password logon handler for the register.
"""
user.password_login(lambda u: finalize_logon(u, *args, **kwargs))


def finalize_logon(u, *args, **kwargs):
"""a la register.finalize_handle_usertoken
"""
u = user.user_from_token(t)
if u is None:
return # Should already have toasted
return
return page(*args, user=u, **kwargs)
2 changes: 2 additions & 0 deletions quicktill/till.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ def main():
tillconfig.usertoken_listen_v6 = val
elif opt == 'description':
tillconfig.configdescription = val
elif opt == 'passlogon_handler':
tillconfig.passlogon_handler = val
else:
log.warning("Unknown configuration option '%s'", opt)

Expand Down
5 changes: 5 additions & 0 deletions quicktill/tillconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ def usertoken_handler(t):
pass


# Called by UI code whenever the password logon key is pressed
def passlogon_handler():
pass


usertoken_listen = None
usertoken_listen_v6 = None

Expand Down
3 changes: 2 additions & 1 deletion quicktill/tillweb/templates/tillweb/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,13 @@
<th scope="col">Description</th>
<th scope="col">Value</th>
<th scope="col">Last used</th>
<th scope="col">Last successful login</th>
</tr>
</thead>
<tbody>
{% for t in tuser.tokens %}
<tr>
<td>{{t.description}}</td><td>{{t.token}}</td><td>{{t.last_seen|date:dtf}}</td>
<td>{{t.description}}</td><td>{{t.token}}</td><td>{{t.last_seen|date:dtf}}</td><td>{{t.last_successful_login|date:dtf}}</td>
</tr>
{% endfor %}
</tbody>
Expand Down
15 changes: 12 additions & 3 deletions quicktill/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1435,9 +1435,11 @@ class editfield(valuefield):
is passed the proposed new contents and the cursor index, and
should return either a (potentially updated) string or None if the
input is not allowed
hidden: mask value with * characters
"""
def __init__(self, y, x, w, keymap={}, f=None, flen=None, validate=None,
readonly=False):
readonly=False, hidden=False):
self.y = y
self.x = x
self.w = w
Expand All @@ -1450,6 +1452,7 @@ def __init__(self, y, x, w, keymap={}, f=None, flen=None, validate=None,
self.flen = flen
self.validate = validate
self.readonly = readonly
self.hidden = hidden
super().__init__(keymap, f)

# Internal attributes:
Expand Down Expand Up @@ -1487,8 +1490,14 @@ def draw(self):
self.i = self.c
self.win.clear(self.y, self.x, 1, self.w,
colour=self.win.colour.reversed)
self.win.addstr(self.y, self.x, self._f[self.i:self.i + self.w],
self.win.colour.reversed)

if self.hidden:
self.win.addstr(self.y, self.x, '*' * (self.c - self.i),
self.win.colour.reversed)
else:
self.win.addstr(self.y, self.x, self._f[self.i:self.i + self.w],
self.win.colour.reversed)

if self.focused:
self.win.move(self.y, self.x + self.c - self.i)
else:
Expand Down
Loading

0 comments on commit 6f1bb3b

Please sign in to comment.