"""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 ..domain import security_alerts 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, *, settings: Settings, 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, ) security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=user_id) 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, settings=settings, 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, settings=settings, 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, settings=settings, 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, settings=settings, 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, settings=settings, 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, settings=settings, 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") security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin", user_id=uid) 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, settings=settings, 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, ) security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin_set", user_id=actor.user_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, )