Files

195 lines
6.3 KiB
Python
Raw Permalink Normal View History

"""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}}