881 lines
31 KiB
Python
881 lines
31 KiB
Python
"""Authentication REST routes (U4 — Centralized Auth & Token Persistence).
|
|
|
|
Endpoints
|
|
---------
|
|
- ``POST /auth/login`` — username + password (+ remember_me) → JWT pair + session
|
|
- ``POST /auth/refresh`` — refresh token → new access token (rotated session)
|
|
- ``POST /auth/logout`` — revoke current session
|
|
- ``GET /auth/whoami`` — return the current user (used for cold-start)
|
|
- ``GET /auth/sessions`` — list the current user's active sessions
|
|
- ``DELETE /auth/sessions/{id}`` — revoke a specific session (own only)
|
|
- ``POST /auth/change-password`` — change password + revoke all sessions
|
|
- ``GET /admin/sessions`` — list all sessions (admin)
|
|
- ``DELETE /admin/sessions/{id}`` — revoke a session as admin
|
|
- ``GET /admin/users/{user_id}/sessions`` — list a specific user's sessions (admin)
|
|
- ``DELETE /admin/users/{user_id}/sessions/{session_id}`` — revoke a specific user's session (admin)
|
|
|
|
The auth DB (SQLite via aiosqlite) and JWT secret are resolved from
|
|
``app.state`` if set by the app factory, otherwise from the defaults in
|
|
:mod:`agentkit.server.auth.models` / :mod:`agentkit.server.auth.jwt_utils`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiosqlite
|
|
import jwt
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, ConfigDict, EmailStr
|
|
|
|
from agentkit.server.auth.dependencies import require_authenticated
|
|
from agentkit.server.auth.jwt_utils import (
|
|
ACCESS_TOKEN_TTL,
|
|
REFRESH_TOKEN_TTL,
|
|
REFRESH_TOKEN_TTL_REMEMBER_ME,
|
|
create_token_pair,
|
|
get_or_create_jwt_secret,
|
|
verify_token,
|
|
)
|
|
from agentkit.server.auth.models import (
|
|
auth_session_row_to_dict,
|
|
DEFAULT_AUTH_DB_PATH,
|
|
init_auth_db,
|
|
)
|
|
from agentkit.server.auth.password import hash_password, verify_password
|
|
from agentkit.server.auth.permissions import Permission, has_permission
|
|
from agentkit.server.auth.session_service import (
|
|
REVOKE_REASON_PASSWORD_CHANGED,
|
|
REVOKE_REASON_USER_TERMINATED,
|
|
SessionCreate,
|
|
SessionService,
|
|
get_session_service,
|
|
)
|
|
from agentkit.server.auth.denylist import hash_token
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
# Paths under /auth that the AuthMiddleware whitelists. The middleware
|
|
# already knows about /auth/login, /auth/refresh, /auth/logout; the new
|
|
# /auth/whoami requires a valid token so it's NOT whitelisted.
|
|
_AUTH_PUBLIC_PATHS = ("/auth/login", "/auth/refresh")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic request/response models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
"""Username + password login payload."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
username: str
|
|
password: str
|
|
remember_me: bool = False
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
"""Refresh-token exchange payload."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
refresh_token: str
|
|
|
|
|
|
class LogoutRequest(BaseModel):
|
|
"""Logout payload — accepts the current refresh token to revoke."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
refresh_token: str
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
"""Change-password payload — old + new password."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
old_password: str
|
|
new_password: str
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
"""Public user representation (no password hash)."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
id: str
|
|
username: str
|
|
email: EmailStr
|
|
role: str
|
|
is_active: bool
|
|
is_terminal_authorized: bool
|
|
is_server_terminal_authorized: bool
|
|
|
|
|
|
class SessionResponse(BaseModel):
|
|
"""Public projection of an ``auth_sessions`` row."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
id: str
|
|
device_fingerprint: str
|
|
device_label: str
|
|
ip: str
|
|
user_agent: str
|
|
auth_provider: str
|
|
created_at: str
|
|
last_active_at: str
|
|
expires_at: str
|
|
is_current: bool = False
|
|
revoked: bool = False
|
|
revoked_reason: str | None = None
|
|
user_id: str | None = None
|
|
previous_session_id: str | None = None
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
"""JWT pair + user info returned by /auth/login and /auth/refresh."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
expires_in: int = int(ACCESS_TOKEN_TTL.total_seconds())
|
|
user: UserResponse
|
|
session_id: str
|
|
|
|
|
|
class WhoamiResponse(BaseModel):
|
|
"""Response of ``GET /auth/whoami`` (cold-start + session metadata).
|
|
|
|
When the client calls whoami with a refresh token (cold-start), the
|
|
server issues a fresh access token so the client doesn't need a
|
|
separate ``/auth/refresh`` round-trip. When called with an access
|
|
token, ``access_token`` is ``None`` (the caller already has one).
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
user: UserResponse
|
|
access_token: str | None = None
|
|
session_id: str | None = None
|
|
session: SessionResponse | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _resolve_db_path(request: Request) -> Path:
|
|
path = getattr(request.app.state, "auth_db_path", None)
|
|
return Path(path) if path else DEFAULT_AUTH_DB_PATH
|
|
|
|
|
|
def _resolve_jwt_secret(request: Request) -> str:
|
|
secret = getattr(request.app.state, "jwt_secret", None)
|
|
if secret:
|
|
return secret
|
|
return get_or_create_jwt_secret()
|
|
|
|
|
|
async def _ensure_db(request: Request) -> Path:
|
|
db_path = _resolve_db_path(request)
|
|
if not db_path.exists():
|
|
await init_auth_db(db_path)
|
|
return db_path
|
|
|
|
|
|
def _user_row_to_response(row: aiosqlite.Row) -> UserResponse:
|
|
return UserResponse(
|
|
id=row["id"],
|
|
username=row["username"],
|
|
email=row["email"],
|
|
role=row["role"],
|
|
is_active=bool(row["is_active"]),
|
|
is_terminal_authorized=bool(row["is_terminal_authorized"]),
|
|
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
|
|
)
|
|
|
|
|
|
def _refresh_ttl_seconds(remember_me: bool) -> int:
|
|
return (
|
|
int(REFRESH_TOKEN_TTL_REMEMBER_ME.total_seconds())
|
|
if remember_me
|
|
else int(REFRESH_TOKEN_TTL.total_seconds())
|
|
)
|
|
|
|
|
|
# U10 rollout: clients whose X-Client-Version header reports a version
|
|
# below this cutoff receive legacy (no-sid) tokens so that they
|
|
# continue to work during the migration window. After the cutoff is
|
|
# bumped, the legacy code path is removed (Phase 5 cleanup).
|
|
LEGACY_CLIENT_VERSION_CUTOFF = "0.5.0"
|
|
|
|
|
|
def _is_legacy_client(request: Request) -> bool:
|
|
"""Return True if the request's client version is below the cutoff.
|
|
|
|
The check is intentionally lenient: missing header, unparseable
|
|
version, or any error evaluating the version all default to
|
|
``False`` (treat as a new client). The only path that returns
|
|
``True`` is a syntactically valid semver that compares strictly
|
|
less than the cutoff. This avoids accidentally downgrading
|
|
legitimate clients with unusual but new-enough versions.
|
|
"""
|
|
raw = request.headers.get("X-Client-Version", "").strip()
|
|
if not raw:
|
|
return False
|
|
try:
|
|
client_v = _parse_semver(raw)
|
|
cutoff_v = _parse_semver(LEGACY_CLIENT_VERSION_CUTOFF)
|
|
if client_v is None or cutoff_v is None:
|
|
return False
|
|
return client_v < cutoff_v
|
|
except Exception: # noqa: BLE001
|
|
logger.debug("Failed to parse X-Client-Version %r", raw)
|
|
return False
|
|
|
|
|
|
def _parse_semver(value: str) -> tuple[int, int, int] | None:
|
|
"""Parse ``"MAJOR.MINOR.PATCH"`` into a comparable tuple.
|
|
|
|
Returns ``None`` when the value is not a strict 3-part semver.
|
|
Pre-release / build-metadata are ignored — we only need a coarse
|
|
ordering for the rollout policy.
|
|
"""
|
|
parts = value.split(".")
|
|
if len(parts) != 3:
|
|
return None
|
|
try:
|
|
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client-info extraction (device fingerprint / IP / user agent)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _client_info(request: Request) -> dict[str, str]:
|
|
"""Extract device fingerprint, IP and user-agent from the request.
|
|
|
|
The fingerprint is a coarse best-effort hash of the User-Agent +
|
|
Accept-Language — enough to distinguish "Mac/Safari" from
|
|
"Windows/Chrome" in the sessions list without storing anything
|
|
sensitive. Production deployments that need stronger device
|
|
identification can layer that on top later.
|
|
"""
|
|
ua = request.headers.get("user-agent", "")
|
|
al = request.headers.get("accept-language", "")
|
|
fp_seed = f"{ua}|{al}"
|
|
# Use the same sha256 helper as the denylist for consistency.
|
|
fp = hash_token(fp_seed)[:16]
|
|
# Best-effort client IP (no X-Forwarded-For trust for now).
|
|
ip = (request.client.host if request.client else "") or ""
|
|
label = _device_label(ua)
|
|
return {
|
|
"fingerprint": fp,
|
|
"label": label,
|
|
"ip": ip,
|
|
"user_agent": ua[:512],
|
|
}
|
|
|
|
|
|
def _device_label(user_agent: str) -> str:
|
|
"""Human-readable device label, e.g. ``"macOS — Chrome 124"``."""
|
|
ua = user_agent.lower()
|
|
if "edg/" in ua:
|
|
browser = "Edge"
|
|
elif "chrome" in ua and "chromium" in ua:
|
|
browser = "Chrome"
|
|
elif "firefox" in ua:
|
|
browser = "Firefox"
|
|
elif "safari" in ua and "chrome" not in ua:
|
|
browser = "Safari"
|
|
elif "tauri" in ua or "fischer" in ua:
|
|
return "Fischer Desktop"
|
|
else:
|
|
browser = "Browser"
|
|
if "mac os x" in ua or "macintosh" in ua:
|
|
os_ = "macOS"
|
|
elif "windows" in ua:
|
|
os_ = "Windows"
|
|
elif "iphone" in ua or "ipad" in ua:
|
|
os_ = "iOS"
|
|
elif "android" in ua:
|
|
os_ = "Android"
|
|
elif "linux" in ua:
|
|
os_ = "Linux"
|
|
else:
|
|
os_ = "Unknown"
|
|
return f"{os_} — {browser}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(payload: LoginRequest, request: Request) -> TokenResponse:
|
|
"""Authenticate with username + password and receive a JWT pair.
|
|
|
|
Flow:
|
|
1. Validate via the configured :class:`AuthProvider`.
|
|
2. Issue access + refresh JWTs (with ``sid`` / ``jti``).
|
|
3. Persist the session to ``auth_sessions`` (V2).
|
|
4. Update ``last_login_at``.
|
|
|
|
U10 back-compat: when the caller's ``X-Client-Version`` header is
|
|
below the rollout cutoff, the issued tokens carry no ``sid`` claim
|
|
and the session row is removed. The client can still authenticate
|
|
using the legacy ``user_sessions`` table; new clients always get
|
|
the V2 flow. See ``docs/migrations/2026-06-20-client-version-rollout.md``.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
secret = _resolve_jwt_secret(request)
|
|
from agentkit.server.auth.providers import (
|
|
InvalidCredentials,
|
|
LocalAuthProvider,
|
|
)
|
|
|
|
# 1. Authenticate (construct a provider bound to this request's
|
|
# auth DB so the route works for both the global singleton and
|
|
# per-request overrides such as test fixtures that swap the path).
|
|
try:
|
|
user = await LocalAuthProvider(db_path=db_path).authenticate(
|
|
username=payload.username, password=payload.password
|
|
)
|
|
except InvalidCredentials as exc:
|
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(status_code=403, detail="Account is disabled")
|
|
|
|
# 2. Issue tokens
|
|
# The session_id is created up front (SessionService.create returns
|
|
# the id) so we can sign the tokens with the same id used in the
|
|
# session row.
|
|
svc: SessionService = get_session_service()
|
|
info = _client_info(request)
|
|
|
|
# U10 rollout policy: detect old clients by the X-Client-Version
|
|
# header and downgrade them to the legacy (no-sid) token shape.
|
|
# Old clients can't handle the new flow (they don't call
|
|
# /auth/whoami with a refresh token), so handing them a sid-bearing
|
|
# token would just look invalid to them.
|
|
legacy_mode = _is_legacy_client(request)
|
|
|
|
# Pre-create the session with a placeholder row using the id we are
|
|
# about to issue, so the JWT's ``sid`` claim is consistent with the
|
|
# row's primary key. We use SessionService directly because the
|
|
# refresh_token has not been issued yet — we let create() generate
|
|
# the row first, then issue the tokens referencing it.
|
|
#
|
|
# Actually: simpler — call create() AFTER issuing the tokens, then
|
|
# re-issue nothing; just persist the row with the known id.
|
|
|
|
# Step A: create the session row with a placeholder hash. The real
|
|
# hash will be written in a follow-up UPDATE; this is a tiny race
|
|
# window that no one can exploit because the row has no valid
|
|
# refresh_token_hash to match.
|
|
pre_session = await svc.create(
|
|
SessionCreate(
|
|
user_id=user.id,
|
|
refresh_token="__pending__", # overwritten in Step C
|
|
device_fingerprint=info["fingerprint"],
|
|
device_label=info["label"],
|
|
ip=info["ip"],
|
|
user_agent=info["user_agent"],
|
|
auth_provider="local",
|
|
ttl_seconds=_refresh_ttl_seconds(payload.remember_me),
|
|
)
|
|
)
|
|
|
|
# Step B: issue tokens using the session id from Step A.
|
|
token_pair = create_token_pair(
|
|
user_id=user.id,
|
|
username=user.username,
|
|
role=user.role,
|
|
secret=secret,
|
|
session_id=pre_session.id,
|
|
remember_me=payload.remember_me,
|
|
legacy_mode=legacy_mode,
|
|
)
|
|
|
|
# Step C: overwrite the row with the real refresh token hash.
|
|
import aiosqlite as _aio
|
|
|
|
async with _aio.connect(str(db_path)) as db:
|
|
if legacy_mode:
|
|
# U10: a legacy client cannot use a session row (no
|
|
# X-Client-Version-aware client logic), so drop the V2
|
|
# session we just created. Legacy refresh-tokens will be
|
|
# tracked via the existing user_sessions table on the next
|
|
# refresh (the /auth/refresh route handles both shapes).
|
|
await db.execute(
|
|
"DELETE FROM auth_sessions WHERE id = ?",
|
|
(pre_session.id,),
|
|
)
|
|
else:
|
|
await db.execute(
|
|
"UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
|
|
(hash_token(token_pair.refresh_token), pre_session.id),
|
|
)
|
|
await db.execute(
|
|
"UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?",
|
|
(_now_iso(), _now_iso(), user.id),
|
|
)
|
|
await db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=token_pair.access_token,
|
|
refresh_token=token_pair.refresh_token,
|
|
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
|
user=UserResponse(
|
|
id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
role=user.role,
|
|
is_active=user.is_active,
|
|
is_terminal_authorized=user.is_terminal_authorized,
|
|
is_server_terminal_authorized=user.is_server_terminal_authorized,
|
|
),
|
|
session_id="" if legacy_mode else pre_session.id,
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model=TokenResponse)
|
|
async def refresh(payload: RefreshRequest, request: Request) -> TokenResponse:
|
|
"""Exchange a valid refresh token for a new access token.
|
|
|
|
Implements refresh-token rotation:
|
|
1. Verify the refresh token's JWT signature and ``type`` claim.
|
|
2. Look up the session by the token's SHA-256 hash.
|
|
3. Verify the session is not revoked or expired.
|
|
4. Issue a new access + refresh pair.
|
|
5. Replace the session's stored hash with the new token's hash.
|
|
6. Add the old hash to the denylist (reuse detection).
|
|
|
|
On reuse (old hash in the denylist) the caller's
|
|
:class:`SessionService.rotate` revokes all of that user's sessions
|
|
and raises :class:`SessionReuseDetected`. The route maps that to
|
|
a 401 so the client is forced to re-authenticate.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
secret = _resolve_jwt_secret(request)
|
|
svc: SessionService = get_session_service()
|
|
|
|
# 1. Verify signature + type
|
|
try:
|
|
refresh_payload = verify_token(payload.refresh_token, secret, expected_type="refresh")
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
|
|
|
# 2-3. Validate the session (also handles reuse detection)
|
|
try:
|
|
new_pair = create_token_pair(
|
|
user_id=refresh_payload["sub"],
|
|
username=refresh_payload["username"],
|
|
role=refresh_payload["role"],
|
|
secret=secret,
|
|
session_id=refresh_payload.get("sid"),
|
|
remember_me=False,
|
|
)
|
|
await svc.rotate(
|
|
old_refresh_token=payload.refresh_token,
|
|
new_refresh_token=new_pair.refresh_token,
|
|
new_ttl_seconds=int(REFRESH_TOKEN_TTL.total_seconds()),
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — SessionReuseDetected / SessionNotFound
|
|
logger.info("Refresh rejected: %s", exc)
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token") from exc
|
|
|
|
# Re-fetch the user to surface fresh role / is_active
|
|
async with aiosqlite.connect(str(db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (refresh_payload["sub"],))
|
|
user_row = await cursor.fetchone()
|
|
if user_row is None or not bool(user_row["is_active"]):
|
|
raise HTTPException(status_code=401, detail="User not found or disabled")
|
|
|
|
return TokenResponse(
|
|
access_token=new_pair.access_token,
|
|
refresh_token=new_pair.refresh_token,
|
|
expires_in=int(ACCESS_TOKEN_TTL.total_seconds()),
|
|
user=_user_row_to_response(user_row),
|
|
session_id=refresh_payload.get("sid", ""),
|
|
)
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(payload: LogoutRequest, request: Request) -> dict[str, Any]:
|
|
"""Revoke the refresh-token session. Idempotent."""
|
|
svc: SessionService = get_session_service()
|
|
revoked = await svc.revoke_by_refresh_token(
|
|
payload.refresh_token, reason=REVOKE_REASON_USER_TERMINATED
|
|
)
|
|
return {"revoked": revoked}
|
|
|
|
|
|
@router.get("/whoami", response_model=WhoamiResponse)
|
|
async def whoami(request: Request) -> WhoamiResponse:
|
|
"""Return the current user + session metadata (cold-start support).
|
|
|
|
Accepts **either** an access token (normal call) **or** a refresh
|
|
token (cold-start, when the access token is gone). The route does
|
|
its own auth because the middleware only accepts access tokens.
|
|
|
|
On cold-start (refresh token presented), the server issues a fresh
|
|
access token so the client doesn't need a separate ``/auth/refresh``
|
|
round-trip. On 401 from this endpoint, the client treats it as
|
|
'invalid' state (NOT 'error' state) so the router redirects to
|
|
/login.
|
|
"""
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="missing bearer token")
|
|
token = auth_header[7:]
|
|
|
|
secret = _resolve_jwt_secret(request)
|
|
try:
|
|
# Accept both access and refresh tokens for cold-start.
|
|
payload = verify_token(token, secret, expected_type=None)
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail="token expired")
|
|
except jwt.InvalidTokenError:
|
|
raise HTTPException(status_code=401, detail="invalid token")
|
|
|
|
user_id = payload.get("sub")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="invalid token: no subject")
|
|
|
|
token_type = payload.get("type")
|
|
sid = payload.get("sid")
|
|
|
|
# Load the user row.
|
|
db_path = await _ensure_db(request)
|
|
async with aiosqlite.connect(str(db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
user_response = _user_row_to_response(row)
|
|
|
|
# V2 token with sid: validate the session is still active.
|
|
session_response: SessionResponse | None = None
|
|
if sid:
|
|
svc: SessionService = get_session_service()
|
|
info = await svc.get(sid)
|
|
if info is None or info.revoked:
|
|
raise HTTPException(status_code=401, detail="session revoked or expired")
|
|
session_response = SessionResponse(
|
|
**auth_session_row_to_dict(_info_to_dict(info)),
|
|
is_current=True,
|
|
)
|
|
|
|
# Cold-start: refresh token presented → issue a fresh access token.
|
|
new_access_token: str | None = None
|
|
if token_type == "refresh":
|
|
new_pair = create_token_pair(
|
|
user_id=str(row["id"]),
|
|
username=str(row["username"]),
|
|
role=str(row["role"]),
|
|
secret=secret,
|
|
session_id=sid,
|
|
)
|
|
new_access_token = new_pair.access_token
|
|
|
|
return WhoamiResponse(
|
|
user=user_response,
|
|
access_token=new_access_token,
|
|
session_id=sid,
|
|
session=session_response,
|
|
)
|
|
|
|
|
|
@router.get("/sessions", response_model=list[SessionResponse])
|
|
async def list_sessions(
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> list[SessionResponse]:
|
|
"""List the current user's active sessions."""
|
|
user_id = user.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
svc: SessionService = get_session_service()
|
|
current_sid = user.get("sid") # the calling session's id, if V2
|
|
sessions = await svc.list_for_user(user_id)
|
|
return [
|
|
SessionResponse(
|
|
**auth_session_row_to_dict(
|
|
# build a dict-like object with the right keys
|
|
_info_to_dict(s),
|
|
),
|
|
is_current=(s.id == current_sid),
|
|
)
|
|
for s in sessions
|
|
]
|
|
|
|
|
|
def _info_to_dict(s: Any) -> dict[str, Any]:
|
|
"""Convert a SessionInfo to the dict shape :func:`auth_session_row_to_dict` expects."""
|
|
return {
|
|
"id": s.id,
|
|
"user_id": s.user_id,
|
|
"device_fingerprint": s.device_fingerprint,
|
|
"device_label": s.device_label,
|
|
"ip": s.ip,
|
|
"user_agent": s.user_agent,
|
|
"auth_provider": s.auth_provider,
|
|
"created_at": s.created_at,
|
|
"last_active_at": s.last_active_at,
|
|
"expires_at": s.expires_at,
|
|
"revoked": s.revoked,
|
|
"revoked_reason": s.revoked_reason,
|
|
"previous_session_id": s.previous_session_id,
|
|
}
|
|
|
|
|
|
@router.delete("/sessions/{session_id}")
|
|
async def revoke_own_session(
|
|
session_id: str,
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> dict[str, Any]:
|
|
"""Revoke one of the current user's sessions (e.g. a lost device)."""
|
|
user_id = user.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
svc: SessionService = get_session_service()
|
|
info = await svc.get(session_id)
|
|
if info is None or info.user_id != user_id:
|
|
# Don't reveal whether the session exists for another user.
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
ok = await svc.revoke(session_id, reason=REVOKE_REASON_USER_TERMINATED)
|
|
return {"revoked": ok}
|
|
|
|
|
|
@router.post("/logout-others")
|
|
async def logout_others(
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> dict[str, Any]:
|
|
"""Revoke all of the current user's sessions except the calling one.
|
|
|
|
Used by the "Log out other devices" button in the security settings.
|
|
The current session (identified by the JWT's ``sid`` claim) is spared.
|
|
"""
|
|
user_id = user.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
current_sid = user.get("sid")
|
|
if not current_sid:
|
|
# Legacy token without sid — can't identify the current session.
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Current session cannot be identified (legacy token). Please log in again.",
|
|
)
|
|
svc: SessionService = get_session_service()
|
|
count = await svc.revoke_all_for_user(
|
|
user_id,
|
|
except_sid=current_sid,
|
|
reason=REVOKE_REASON_USER_TERMINATED,
|
|
)
|
|
return {"revoked_count": count}
|
|
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
payload: ChangePasswordRequest,
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> dict[str, Any]:
|
|
"""Change the current user's password.
|
|
|
|
Per the security policy: changing the password invalidates ALL of
|
|
the user's other active sessions (the user is re-authenticated on
|
|
this device on next request via the new password).
|
|
"""
|
|
user_id = user.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
if len(payload.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="new_password must be at least 8 characters")
|
|
db_path = await _ensure_db(request)
|
|
async with aiosqlite.connect(str(db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if not verify_password(payload.old_password, row["password_hash"]):
|
|
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
|
new_hash = hash_password(payload.new_password)
|
|
await db.execute(
|
|
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
|
(new_hash, _now_iso(), user_id),
|
|
)
|
|
await db.commit()
|
|
# Revoke all sessions except the current one (the user is staying
|
|
# logged in on the device that just changed the password).
|
|
svc: SessionService = get_session_service()
|
|
current_sid = user.get("sid")
|
|
sessions = await svc.list_for_user(user_id)
|
|
revoked = 0
|
|
for s in sessions:
|
|
if s.id != current_sid:
|
|
if await svc.revoke(s.id, reason=REVOKE_REASON_PASSWORD_CHANGED):
|
|
revoked += 1
|
|
return {"changed": True, "revoked_other_sessions": revoked}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admin routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
async def _require_admin(
|
|
request: Request, user: dict[str, Any] = Depends(require_authenticated)
|
|
) -> dict[str, Any]:
|
|
if not has_permission(user, Permission.USER_MANAGE):
|
|
raise HTTPException(status_code=403, detail="Admin permission required")
|
|
return user
|
|
|
|
|
|
@admin_router.get("/sessions", response_model=list[SessionResponse])
|
|
async def admin_list_sessions(
|
|
request: Request,
|
|
limit: int = 200,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[SessionResponse]:
|
|
"""List recent sessions across all users (admin only)."""
|
|
svc: SessionService = get_session_service()
|
|
sessions = await svc.list_all(include_revoked=False, limit=limit)
|
|
return [
|
|
SessionResponse(
|
|
**auth_session_row_to_dict(_info_to_dict(s)),
|
|
is_current=False,
|
|
)
|
|
for s in sessions
|
|
]
|
|
|
|
|
|
@admin_router.delete("/sessions/{session_id}")
|
|
async def admin_revoke_session(
|
|
session_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Revoke any session as admin (force-logout a user)."""
|
|
svc: SessionService = get_session_service()
|
|
ok = await svc.revoke(session_id, reason="admin_revoked")
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Session not found or already revoked")
|
|
return {"revoked": True}
|
|
|
|
|
|
@admin_router.get(
|
|
"/users/{user_id}/sessions",
|
|
response_model=list[SessionResponse],
|
|
)
|
|
async def admin_list_user_sessions(
|
|
user_id: str,
|
|
request: Request,
|
|
include_revoked: bool = False,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> list[SessionResponse]:
|
|
"""List all sessions for a specific user (admin only).
|
|
|
|
By default only active sessions are returned. When ``include_revoked``
|
|
is true, revoked sessions are also returned (with their ``revoked``
|
|
and ``revoked_reason`` fields populated in the response).
|
|
|
|
The endpoint verifies the user exists; if not, 404 is returned. This
|
|
prevents admins from silently filtering on a stale id and gives
|
|
clearer feedback.
|
|
"""
|
|
db_path = await _ensure_db(request)
|
|
async with aiosqlite.connect(str(db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,))
|
|
user_row = await cursor.fetchone()
|
|
if user_row is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
svc: SessionService = get_session_service()
|
|
sessions = await svc.list_for_user(user_id, include_revoked=include_revoked)
|
|
current_sid = admin.get("sid")
|
|
return [
|
|
SessionResponse(
|
|
**auth_session_row_to_dict(_info_to_dict(s)),
|
|
is_current=(s.id == current_sid),
|
|
)
|
|
for s in sessions
|
|
]
|
|
|
|
|
|
@admin_router.delete("/users/{user_id}/sessions/{session_id}")
|
|
async def admin_revoke_user_session(
|
|
user_id: str,
|
|
session_id: str,
|
|
request: Request,
|
|
admin: dict[str, Any] = Depends(_require_admin),
|
|
) -> dict[str, Any]:
|
|
"""Revoke a specific session for a specific user (admin only).
|
|
|
|
Returns 404 if the session does not exist or does not belong to
|
|
``user_id`` (defense against accidentally revoking a session on the
|
|
wrong user). Already-revoked sessions return 200 with
|
|
``revoked=False`` so the admin can detect no-op vs new revoke.
|
|
"""
|
|
svc: SessionService = get_session_service()
|
|
info = await svc.get(session_id)
|
|
if info is None or info.user_id != user_id:
|
|
raise HTTPException(status_code=404, detail="Session not found for user")
|
|
ok = await svc.revoke(session_id, reason="admin_revoked")
|
|
return {"revoked": ok}
|
|
|
|
|
|
# Backwards-compat /me endpoint (kept for the existing frontend)
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def me(
|
|
request: Request,
|
|
user: dict[str, Any] = Depends(require_authenticated),
|
|
) -> UserResponse:
|
|
"""Alias for :func:`whoami` (legacy path, access-token only).
|
|
|
|
Returns just the user profile (no session metadata) for backwards
|
|
compatibility with clients that expect the old ``UserResponse``
|
|
shape from ``/auth/me``.
|
|
"""
|
|
user_id = user.get("user_id")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
db_path = await _ensure_db(request)
|
|
async with aiosqlite.connect(str(db_path)) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return _user_row_to_response(row)
|