Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
454 lines
13 KiB
Python
454 lines
13 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 ..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,
|
|
)
|