Files
PacketStation/server/packetstation/auth/signin.py
T

442 lines
12 KiB
Python
Raw Normal View History

"""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,
)