Files
PacketStation/server/packetstation/auth/signin.py
T
spencermandClaude Fable 5.1 97b0973130 Phase 1: identity, sessions, audit, frontend shell
AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 07:40:29 -06:00

442 lines
12 KiB
Python

"""Password and PIN sign-in, and setting a PIN. Rate limits run before any LDAP bind or PIN hash check.
Failures are written to `signin_attempts` at once (the API connection is in autocommit mode), so they count
even though the request then fails. LDAP calls never run inside a database transaction.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, NoReturn
from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..settings import Settings
from . import devices, ratelimit, sessions
from . import directory as userdir
from . import groups as groupmap
from . import pin as pins
from .ldap import AdUser, DirectoryProtocol, LdapUnavailable
log = logging.getLogger(__name__)
PASSWORD_FAILED = "Sign-in failed. Check the account name and password." # noqa: S105 (a message)
PIN_FAILED = "Sign-in failed."
NO_ROLE = "No role in Packet Station. Ask IT to add you to a group."
LDAP_DOWN = "The directory cannot be reached. Try again in a few minutes."
@dataclass
class SigninResult:
token: str
session: dict[str, Any]
user_id: Any
roles: list[str]
groups: list[str]
device: dict[str, Any] | None
def ldap_down(e: Exception) -> ApiError:
log.warning("AD unavailable: %s", e)
return ApiError(503, "ldap_unavailable", LDAP_DOWN)
def rate_gate(
conn: Any,
settings: Settings,
*,
accounts: list[str],
ip: str | None,
kind: str,
record_account: str,
user_id: Any = None,
session_id: Any = None,
) -> None:
"""Raise 429 when the accounts or the address are over their limit; the refusal is recorded and audited."""
wait = ratelimit.check(conn, settings, accounts=accounts, ip=ip)
if wait is None:
return
with conn.transaction():
ratelimit.record(
conn,
account=record_account,
ip=ip,
kind=kind,
ok=False,
reason=ratelimit.RATE_LIMITED,
user_id=user_id,
session_id=session_id,
)
audit.append(
conn,
action="auth.signin_failed",
user_id=user_id,
session_id=session_id,
detail={"reason": "rate_limited", "account": record_account, "kind": kind},
ip=ip,
)
minutes = max(1, (wait + 59) // 60)
raise ApiError(
429,
"rate_limited",
f"Too many failed attempts. Try again in {minutes} minute{'s' if minutes != 1 else ''}.",
headers={"Retry-After": str(wait)},
extra={"retry_after": wait},
)
def _fail(
conn: Any,
*,
account: str,
ip: str | None,
kind: str,
reason: str,
status: int,
code: str,
detail: str,
user_id: Any = None,
audited: bool = False,
) -> NoReturn:
with conn.transaction():
ratelimit.record(conn, account=account, ip=ip, kind=kind, ok=False, reason=reason, user_id=user_id)
if audited:
audit.append(
conn,
action="auth.signin_failed",
user_id=user_id,
detail={"reason": reason, "account": account, "kind": kind},
ip=ip,
)
raise ApiError(status, code, detail)
def _start(
conn: Any,
settings: Settings,
key: bytes,
u: AdUser,
*,
kind: str,
account: str,
groups: list[str],
roles: list[str],
device: dict[str, Any] | None,
ip: str | None,
user_agent: str | None,
) -> SigninResult:
with conn.transaction():
userdir.upsert_user(conn, u, groups=groups, signin=True)
userdir.set_roles(conn, u.guid, roles)
if kind == "pin":
pins.reset_failures(conn, u.guid)
token, row = sessions.create(
conn,
settings,
key,
user_id=u.guid,
kind=kind,
roles=roles,
groups=groups,
device_id=device["id"] if device else None,
ip=ip,
user_agent=user_agent,
)
ratelimit.record(conn, account=account, ip=ip, kind=kind, ok=True, user_id=u.guid, session_id=row["id"])
audit.append(
conn,
action="auth.signin",
user_id=u.guid,
session_id=row["id"],
role_snapshot=",".join(roles),
detail={
"kind": kind,
"device_id": str(device["id"]) if device else None,
"device_name": device["name"] if device else None,
},
ip=ip,
)
if device:
devices.touch(conn, device["id"])
return SigninResult(token, row, u.guid, roles, groups, device)
def resolve_groups(directory: DirectoryProtocol, u: AdUser) -> tuple[list[str], list[str]]:
"""(groups in the PacketStation OU, roles they give)."""
found = sorted(directory.groups_of(u.dn))
return found, groupmap.roles_from_groups(found)
def password_signin(
conn: Any,
directory: DirectoryProtocol,
settings: Settings,
key: bytes,
*,
login: str,
password: str,
ip: str | None,
user_agent: str | None,
) -> SigninResult:
login = (login or "").strip()
account = login.lower()[:256]
if not login or not password:
# Refused before LDAP: an empty password would be an anonymous bind
_fail(
conn,
account=account or "(empty)",
ip=ip,
kind="password",
reason="empty",
status=401,
code="signin_failed",
detail=PASSWORD_FAILED,
)
rate_gate(conn, settings, accounts=[account], ip=ip, kind="password", record_account=account)
try:
u = directory.find_user(login)
except LdapUnavailable as e:
raise ldap_down(e) from None
if u is None:
_fail(
conn,
account=account,
ip=ip,
kind="password",
reason="no_such_user",
status=401,
code="signin_failed",
detail=PASSWORD_FAILED,
)
# The same person may type the account name or the UPN: both keys count before the bind
aliases = sorted({a for a in (u.sam_account.lower(), (u.upn or "").lower()) if a and a != account})
if aliases:
rate_gate(
conn,
settings,
accounts=[account, *aliases],
ip=None,
kind="password",
record_account=account,
user_id=u.guid,
)
try:
ok = directory.verify_password(u.dn, password)
except LdapUnavailable as e:
raise ldap_down(e) from None
if not ok:
_fail(
conn,
account=account,
ip=ip,
kind="password",
reason="bad_password",
status=401,
code="signin_failed",
detail=PASSWORD_FAILED,
user_id=u.guid,
)
if u.disabled:
_fail(
conn,
account=account,
ip=ip,
kind="password",
reason="disabled",
status=401,
code="signin_failed",
detail=PASSWORD_FAILED,
user_id=u.guid,
audited=True,
)
try:
groups, roles = resolve_groups(directory, u)
except LdapUnavailable as e:
raise ldap_down(e) from None
if not roles:
_fail(
conn,
account=account,
ip=ip,
kind="password",
reason="no_role",
status=403,
code="no_role",
detail=NO_ROLE,
user_id=u.guid,
audited=True,
)
return _start(
conn,
settings,
key,
u,
kind="password",
account=account,
groups=groups,
roles=roles,
device=None,
ip=ip,
user_agent=user_agent,
)
def pin_signin(
conn: Any,
directory: DirectoryProtocol,
settings: Settings,
key: bytes,
*,
device: dict[str, Any],
emp_no: str,
pin: str,
ip: str | None,
user_agent: str | None,
) -> SigninResult:
emp = (emp_no or "").strip()[:64]
account = f"emp:{emp}"
def fail(reason: str, user_id: Any = None, *, audited: bool = False) -> NoReturn:
_fail(
conn,
account=account,
ip=ip,
kind="pin",
reason=reason,
status=401,
code="signin_failed",
detail=PIN_FAILED,
user_id=user_id,
audited=audited,
)
if not emp or not pin:
fail("empty")
rate_gate(conn, settings, accounts=[account], ip=ip, kind="pin", record_account=account)
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT u.id, u.active, p.hash, p.disabled_at FROM users u"
" LEFT JOIN user_pins p ON p.user_id = u.id WHERE u.emp_no = %s",
(emp,),
).fetchall()
if not rows:
fail("no_such_user")
if len(rows) > 1:
fail("emp_duplicate")
row = rows[0]
uid = row["id"]
if not row["active"]:
fail("inactive", uid)
if row["hash"] is None:
fail("no_pin", uid)
if row["disabled_at"] is not None:
fail("pin_disabled", uid)
if not pins.verify_hash(row["hash"], pin):
with conn.transaction():
ratelimit.record(conn, account=account, ip=ip, kind="pin", ok=False, reason="bad_pin", user_id=uid)
pins.register_failure(conn, settings, uid, ip=ip, where="signin")
raise ApiError(401, "signin_failed", PIN_FAILED)
try:
u = directory.get_user_by_guid(uid)
except LdapUnavailable as e:
raise ldap_down(e) from None
if u is None or u.disabled:
with conn.transaction():
userdir.mark_inactive(conn, uid, reason="ad_gone" if u is None else "ad_disabled", gone=u is None)
fail("disabled", uid, audited=True)
try:
groups, roles = resolve_groups(directory, u)
except LdapUnavailable as e:
raise ldap_down(e) from None
if not roles:
_fail(
conn,
account=account,
ip=ip,
kind="pin",
reason="no_role",
status=403,
code="no_role",
detail=NO_ROLE,
user_id=uid,
audited=True,
)
return _start(
conn,
settings,
key,
u,
kind="pin",
account=account,
groups=groups,
roles=roles,
device=device,
ip=ip,
user_agent=user_agent,
)
def set_pin(
conn: Any,
directory: DirectoryProtocol,
settings: Settings,
actor: sessions.Actor,
*,
password: str,
new_pin: str,
ip: str | None,
) -> None:
"""Set or change the actor's PIN. Needs the domain password in the same request (password session only)."""
if not pins.valid_format(new_pin or "", settings):
raise ApiError(
400,
"invalid_pin",
f"The PIN must be {settings.pin_min_digits} to {settings.pin_max_digits} digits.",
)
account = f"user:{actor.user_id}"
rate_gate(
conn,
settings,
accounts=[account, actor.sam_account.lower()],
ip=ip,
kind="pin_set",
record_account=account,
user_id=actor.user_id,
session_id=actor.session_id,
)
try:
u = directory.get_user_by_guid(actor.user_id)
ok = bool(u and not u.disabled and password and directory.verify_password(u.dn, password))
except LdapUnavailable as e:
raise ldap_down(e) from None
if not ok:
ratelimit.record(
conn,
account=account,
ip=ip,
kind="pin_set",
ok=False,
reason="bad_password",
user_id=actor.user_id,
session_id=actor.session_id,
)
raise ApiError(403, "bad_password", "The password is wrong.")
with conn.transaction():
pins.set_pin(conn, actor.user_id, new_pin)
ratelimit.record(
conn, account=account, ip=ip, kind="pin_set", ok=True, user_id=actor.user_id, session_id=actor.session_id
)
audit.append(
conn,
action="pin.set",
user_id=actor.user_id,
session_id=actor.session_id,
role_snapshot=actor.role_snapshot,
subject_type="user",
subject_id=actor.user_id,
ip=ip,
)