2026-10-16 07:40:29 -06:00
|
|
|
"""Step-up checks inside a session (screen unlock and signatures): PIN or domain password.
|
|
|
|
|
|
|
|
|
|
Wrong attempts are recorded (account `user:<uuid>`), 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
|
2026-10-21 18:27:09 -06:00
|
|
|
from ..domain import security_alerts
|
2026-10-16 07:40:29 -06:00
|
|
|
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
|
2026-10-21 18:27:09 -06:00
|
|
|
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=actor.user_id)
|
2026-10-16 07:40:29 -06:00
|
|
|
if failures >= settings.stepup_max_failures:
|
|
|
|
|
end_session(conn, actor, "stepup_failures", ip)
|
2026-10-21 18:27:09 -06:00
|
|
|
security_alerts.stepup_session_ended(conn, actor.user_id, failures, kind, ip)
|
2026-10-16 07:40:29 -06:00
|
|
|
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},
|
|
|
|
|
)
|