195 lines
6.3 KiB
Python
195 lines
6.3 KiB
Python
"""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}}
|