"""Account settings: own AD details, PIN, sessions, access log, and registering this device.""" from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Query, Request, Response from psycopg.rows import dict_row from pydantic import BaseModel, Field from .. import audit from ..auth import devices, sessions, signin from ..auth import directory as userdir from ..auth import pin as pins from .deps import ( Db, Principal, current_principal, directory_of, get_db, require_device_registrar, require_password_session, session_key, set_device_cookie, settings_of, ) from .errors import ApiError router = APIRouter(prefix="/api/account", tags=["account"]) LOG_PREFIXES = ("auth.%", "pin.%", "device.%", "sig.%") class PinBody(BaseModel): password: str = Field(max_length=1024) pin: str = Field(max_length=64) class DeviceBody(BaseModel): name: str = Field(max_length=devices.NAME_MAX) @router.get("") def account(p: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008 return { "user": userdir.public_user(p.user), "groups": p.groups, "roles": p.roles, "pin": pins.state(d.conn, p.user_id).public(), "ad_synced_at": p.user.get("ad_synced_at"), } @router.post("/pin") def set_pin( body: PinBody, request: Request, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: require_password_session(p) signin.set_pin( d.conn, directory_of(request), settings_of(request), p.actor, password=body.password, new_pin=body.pin, ip=p.ip, ) return {"pin": {"set": True, "disabled": False}} def device_names(conn: Any, ids: list[Any]) -> dict[Any, dict[str, Any]]: if not ids: return {} rows = conn.execute("SELECT id, name FROM devices WHERE id = ANY(%s)", (ids,)).fetchall() return {r[0]: {"id": str(r[0]), "name": r[1]} for r in rows} def active_sessions(conn: Any, user_id: Any, current_id: Any = None) -> list[dict[str, Any]]: with conn.cursor(row_factory=dict_row) as cur: rows = cur.execute( "SELECT id, kind::text AS kind, created_at, last_seen_at, host(ip) AS ip, user_agent, device_id" " FROM sessions WHERE user_id = %s AND revoked_at IS NULL AND expires_at > now()" " ORDER BY last_seen_at DESC", (user_id,), ).fetchall() names = device_names(conn, list({r["device_id"] for r in rows if r["device_id"]})) return [ { "id": str(r["id"]), "kind": r["kind"], "created_at": r["created_at"], "last_seen_at": r["last_seen_at"], "ip": r["ip"], "user_agent": r["user_agent"], "device": names.get(r["device_id"]) if r["device_id"] else None, "current": current_id is not None and r["id"] == current_id, } for r in rows ] @router.get("/sessions") def my_sessions(p: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> list[dict[str, Any]]: # noqa: B008 return active_sessions(d.conn, p.user_id, p.session_id) @router.post("/sessions/signout-others") def signout_others(p: Principal = Depends(current_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008 require_password_session(p) conn = d.conn with conn.transaction(): ids = sessions.revoke_user(conn, p.user_id, "signout_others", except_id=p.session_id) audit.append( conn, action="auth.signout_others", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, detail={"count": len(ids)}, ip=p.ip, ) return {"revoked": len(ids)} @router.get("/access-log") def access_log( p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 limit: int = Query(50, ge=1, le=500), ) -> list[dict[str, Any]]: conn = d.conn u = p.user accounts = [ a for a in ( (u.get("sam_account") or "").lower(), (u.get("upn") or "").lower(), f"emp:{u['emp_no']}" if u.get("emp_no") else "", f"user:{u['id']}", ) if a ] with conn.cursor(row_factory=dict_row) as cur: audits = cur.execute( "SELECT at, action, detail, host(ip) AS ip FROM audit_events" " WHERE (user_id = %s OR (subject_type = 'user' AND subject_id = %s)) AND action LIKE ANY(%s)" " ORDER BY id DESC LIMIT %s", (p.user_id, str(p.user_id), list(LOG_PREFIXES), limit), ).fetchall() failed = cur.execute( "SELECT at, kind, reason, host(ip) AS ip FROM signin_attempts" " WHERE NOT ok AND (user_id = %s OR account = ANY(%s)) ORDER BY at DESC LIMIT %s", (p.user_id, accounts, limit), ).fetchall() out = [{"at": r["at"], "action": r["action"], "detail": r["detail"], "ip": r["ip"], "ok": True} for r in audits] out += [ {"at": r["at"], "action": f"attempt.{r['kind']}", "detail": {"reason": r["reason"]}, "ip": r["ip"], "ok": False} for r in failed ] out.sort(key=lambda r: r["at"], reverse=True) return out[:limit] @router.post("/device") def register_device( body: DeviceBody, request: Request, response: Response, p: Principal = Depends(current_principal), # noqa: B008 d: Db = Depends(get_db), # noqa: B008 ) -> dict[str, Any]: require_device_registrar(p) name = " ".join(body.name.split()) if not name: raise ApiError(400, "name_required", "Give the device a name.") conn = d.conn with conn.transaction(): token, device_id = devices.register(conn, session_key(request), name=name, user_id=p.user_id) audit.append( conn, action="device.registered", user_id=p.user_id, session_id=p.session_id, role_snapshot=p.role_snapshot, subject_type="device", subject_id=device_id, detail={"name": name}, ip=p.ip, ) set_device_cookie(request, response, token, settings_of(request).device_cookie_days) return {"device": {"id": str(device_id), "name": name}}