110 lines
3.6 KiB
Python
110 lines
3.6 KiB
Python
"""The optional Packet Station PIN: Argon2id hash on the server, set by the user, cleared by an Admin."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from argon2 import PasswordHasher
|
||
|
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||
|
|
|
||
|
|
from .. import audit
|
||
|
|
from ..settings import Settings
|
||
|
|
|
||
|
|
_hasher = PasswordHasher()
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class PinState:
|
||
|
|
set: bool
|
||
|
|
disabled: bool
|
||
|
|
failed_count: int = 0
|
||
|
|
|
||
|
|
def public(self) -> dict[str, bool]:
|
||
|
|
return {"set": self.set, "disabled": self.disabled}
|
||
|
|
|
||
|
|
|
||
|
|
def valid_format(pin: str, settings: Settings) -> bool:
|
||
|
|
"""Digits only (ASCII), PIN_MIN_DIGITS to PIN_MAX_DIGITS long."""
|
||
|
|
return pin.isascii() and pin.isdigit() and settings.pin_min_digits <= len(pin) <= settings.pin_max_digits
|
||
|
|
|
||
|
|
|
||
|
|
def hash_pin(pin: str) -> str:
|
||
|
|
return _hasher.hash(pin)
|
||
|
|
|
||
|
|
|
||
|
|
def verify_hash(stored: str, pin: str) -> bool:
|
||
|
|
if not pin:
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
return _hasher.verify(stored, pin)
|
||
|
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def state(conn: Any, user_id: Any) -> PinState:
|
||
|
|
row = conn.execute("SELECT failed_count, disabled_at FROM user_pins WHERE user_id = %s", (user_id,)).fetchone()
|
||
|
|
if row is None:
|
||
|
|
return PinState(False, False)
|
||
|
|
return PinState(True, row[1] is not None, int(row[0]))
|
||
|
|
|
||
|
|
|
||
|
|
def stored_hash(conn: Any, user_id: Any) -> tuple[str, bool] | None:
|
||
|
|
"""(hash, disabled) or None when the user has no PIN."""
|
||
|
|
row = conn.execute("SELECT hash, disabled_at FROM user_pins WHERE user_id = %s", (user_id,)).fetchone()
|
||
|
|
return None if row is None else (str(row[0]), row[1] is not None)
|
||
|
|
|
||
|
|
|
||
|
|
def set_pin(conn: Any, user_id: Any, pin: str) -> None:
|
||
|
|
"""Store a new PIN hash and clear the failure count and the disabled mark."""
|
||
|
|
h = hash_pin(pin)
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO user_pins (user_id, hash, failed_count, disabled_at, set_at) VALUES (%s, %s, 0, NULL, now())"
|
||
|
|
" ON CONFLICT (user_id) DO UPDATE SET hash = EXCLUDED.hash, failed_count = 0, disabled_at = NULL,"
|
||
|
|
" set_at = now()",
|
||
|
|
(user_id, h),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def reset_failures(conn: Any, user_id: Any) -> None:
|
||
|
|
conn.execute("UPDATE user_pins SET failed_count = 0 WHERE user_id = %s AND failed_count <> 0", (user_id,))
|
||
|
|
|
||
|
|
|
||
|
|
def register_failure(
|
||
|
|
conn: Any,
|
||
|
|
settings: Settings,
|
||
|
|
user_id: Any,
|
||
|
|
*,
|
||
|
|
session_id: Any = None,
|
||
|
|
role_snapshot: str | None = None,
|
||
|
|
ip: str | None = None,
|
||
|
|
where: str = "signin",
|
||
|
|
) -> bool:
|
||
|
|
"""Count one wrong PIN. At PIN_MAX_FAILURES the PIN is disabled and `pin.disabled` audited. True if disabled now."""
|
||
|
|
with conn.transaction():
|
||
|
|
row = conn.execute(
|
||
|
|
"UPDATE user_pins SET failed_count = failed_count + 1 WHERE user_id = %s AND disabled_at IS NULL"
|
||
|
|
" RETURNING failed_count",
|
||
|
|
(user_id,),
|
||
|
|
).fetchone()
|
||
|
|
if row is None or int(row[0]) < settings.pin_max_failures:
|
||
|
|
return False
|
||
|
|
conn.execute("UPDATE user_pins SET disabled_at = now() WHERE user_id = %s", (user_id,))
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="pin.disabled",
|
||
|
|
user_id=user_id,
|
||
|
|
session_id=session_id,
|
||
|
|
role_snapshot=role_snapshot,
|
||
|
|
subject_type="user",
|
||
|
|
subject_id=user_id,
|
||
|
|
detail={"failures": int(row[0]), "where": where},
|
||
|
|
ip=ip,
|
||
|
|
)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def clear(conn: Any, user_id: Any) -> bool:
|
||
|
|
row = conn.execute("DELETE FROM user_pins WHERE user_id = %s RETURNING user_id", (user_id,)).fetchone()
|
||
|
|
return row is not None
|