"""Step-up checks inside a session (screen unlock and signatures): PIN or domain password. Wrong attempts are recorded (account `user:`), count toward the PIN's failures when the factor is the PIN, and raise `sessions.stepup_failures`. At STEPUP_MAX_FAILURES the session ends. A right answer resets the session's count. """ from __future__ import annotations from typing import Any from .. import audit from ..api.errors import ApiError from ..domain import security_alerts from ..settings import Settings from . import pin as pins from . import ratelimit, sessions from .ldap import DirectoryProtocol, LdapUnavailable from .signin import ldap_down, rate_gate FACTORS = ("pin", "password") def end_session(conn: Any, actor: sessions.Actor, reason: str, ip: str | None) -> None: with conn.transaction(): if sessions.revoke(conn, actor.session_id, reason): audit.append( conn, action="auth.session_ended", user_id=actor.user_id, session_id=actor.session_id, role_snapshot=actor.role_snapshot, detail={"reason": reason}, ip=ip, ) def verify( conn: Any, directory: DirectoryProtocol, settings: Settings, actor: sessions.Actor, *, factor: str, secret: str, kind: str, ip: str | None, ) -> None: """Return when the secret is right; raise ApiError otherwise. Wrong: 403 `wrong_secret` with `attempts_left`. The last allowed miss: 401 `session_ended`. """ if factor not in FACTORS: raise ApiError(400, "bad_factor", "factor must be pin or password") if kind not in ("unlock", "signature"): raise ValueError(f"bad step-up kind {kind!r}") account = f"user:{actor.user_id}" secret = secret or "" if factor == "pin": stored = pins.stored_hash(conn, actor.user_id) if stored is None: raise ApiError(409, "pin_not_set", "No PIN is set. Use your password.") if stored[1]: raise ApiError(409, "pin_disabled", "Your PIN is disabled. Use your password.") ok = pins.verify_hash(stored[0], secret) else: rate_gate( conn, settings, accounts=[account, actor.sam_account.lower()], ip=ip, kind=kind, record_account=account, user_id=actor.user_id, session_id=actor.session_id, ) try: u = directory.get_user_by_guid(actor.user_id) if u is None or u.disabled: end_session(conn, actor, "roles_lost", ip) raise ApiError(401, "session_ended", "Your account is no longer active. Sign in again.") ok = bool(secret) and directory.verify_password(u.dn, secret) except LdapUnavailable as e: raise ldap_down(e) from None if ok: with conn.transaction(): conn.execute( "UPDATE sessions SET stepup_failures = 0 WHERE id = %s AND stepup_failures <> 0", (actor.session_id,) ) if factor == "pin": pins.reset_failures(conn, actor.user_id) ratelimit.record( conn, account=account, ip=ip, kind=kind, ok=True, user_id=actor.user_id, session_id=actor.session_id ) return pin_disabled = False with conn.transaction(): ratelimit.record( conn, account=account, ip=ip, kind=kind, ok=False, reason="bad_pin" if factor == "pin" else "bad_password", user_id=actor.user_id, session_id=actor.session_id, ) if factor == "pin": pin_disabled = pins.register_failure( conn, settings, actor.user_id, session_id=actor.session_id, role_snapshot=actor.role_snapshot, ip=ip, where=kind, ) row = conn.execute( "UPDATE sessions SET stepup_failures = stepup_failures + 1 WHERE id = %s RETURNING stepup_failures", (actor.session_id,), ).fetchone() failures = int(row[0]) if row else settings.stepup_max_failures security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=actor.user_id) if failures >= settings.stepup_max_failures: end_session(conn, actor, "stepup_failures", ip) security_alerts.stepup_session_ended(conn, actor.user_id, failures, kind, ip) raise ApiError( 401, "session_ended", f"{settings.stepup_max_failures} wrong attempts. The session has ended. Sign in again.", ) raise ApiError( 403, "wrong_secret", "Wrong PIN." if factor == "pin" else "Wrong password.", extra={"attempts_left": settings.stepup_max_failures - failures, "pin_disabled": pin_disabled}, )