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]>
442 lines
12 KiB
Python
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,
|
|
)
|