e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
576 lines
22 KiB
Python
576 lines
22 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Authentication API routes."""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
|
|
import base64
|
|
import ipaddress
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from models.auth import (
|
|
ApiKeyListResponse,
|
|
ApiKeyResponse,
|
|
AuthLoginRequest,
|
|
AuthStatusResponse,
|
|
ChangePasswordRequest,
|
|
CreateApiKeyRequest,
|
|
CreateApiKeyResponse,
|
|
DesktopLoginRequest,
|
|
RefreshTokenRequest,
|
|
)
|
|
from models.users import Token
|
|
from auth import storage, hashing
|
|
from auth.authentication import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
get_current_subject,
|
|
get_current_subject_allow_password_change,
|
|
refresh_access_token,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _reset_password_command() -> str:
|
|
"""Shell command shown in the 'incorrect password' hint.
|
|
|
|
Prefer the absolute path to this install's ``unsloth`` launcher (sibling of
|
|
the running interpreter) so the hint works even when its dir isn't on PATH.
|
|
|
|
POSIX paths are shell-quoted. On Windows we use the bare absolute path only
|
|
when it has no spaces (a quoted path differs between cmd and PowerShell);
|
|
otherwise, or if the launcher can't be located, fall back to the PATH form.
|
|
"""
|
|
try:
|
|
bin_dir = os.path.dirname(os.path.abspath(sys.executable))
|
|
if os.name == "nt":
|
|
exe = os.path.join(bin_dir, "unsloth.exe")
|
|
if os.path.isfile(exe) and " " not in exe:
|
|
return f"{exe} studio reset-password"
|
|
else:
|
|
exe = os.path.join(bin_dir, "unsloth")
|
|
if os.path.isfile(exe):
|
|
return f"{shlex.quote(exe)} studio reset-password"
|
|
except Exception:
|
|
pass
|
|
return "unsloth studio reset-password"
|
|
|
|
|
|
# Per-(ip, username) bucket + per-IP aggregate. Account bucket stops one user's
|
|
# typos from blocking others; the aggregate stops username-rotation spray.
|
|
# Single-process only; multi-worker deployments need a shared store.
|
|
_LOGIN_BUCKETS: dict[tuple[str, str], deque] = {}
|
|
_LOGIN_IP_BUCKETS: dict[str, deque] = {}
|
|
_LOGIN_BUCKETS_LOCK = threading.Lock()
|
|
_LOGIN_WINDOW_SECONDS = 60.0
|
|
_LOGIN_MAX_FAILS = 5
|
|
_LOGIN_IP_MAX_FAILS = 30
|
|
_LOGIN_LOCKOUT_SECONDS = 60
|
|
# Bucket-dict cap. On overflow, reclaim expired buckets; a new IP that still can't
|
|
# fit falls back to a sharded overflow rather than evicting a hot bucket.
|
|
_LOGIN_MAX_BUCKETS = 4096
|
|
# Last full stale-sweep time; rate-limits the O(n) sweep under a burst of new IPs.
|
|
_LAST_IP_PRUNE = 0.0
|
|
# Sharded overflow for per-IP failures that can't get their own bucket while the
|
|
# dict is saturated. Each shard is a small fixed-capacity dict ``ip -> [count,
|
|
# window_start]``: a per-IP count (so a source is throttled, and cleared on
|
|
# success, by its own failures -- no cross-IP collateral) with hard-bounded
|
|
# memory and O(1) lookups. When a shard is full a new IP evicts the lowest-count
|
|
# entry (and starts clean, never inheriting its count) rather than growing without
|
|
# bound, so a high-cardinality spray can't blow memory/CPU the way a per-failure
|
|
# deque could; a persistent attacker keeps a high count and is never the one
|
|
# evicted.
|
|
_LOGIN_IP_OVERFLOW_SHARDS = 256
|
|
_LOGIN_IP_OVERFLOW_MAX = 64 # distinct IPs tracked per shard
|
|
_LOGIN_IP_OVERFLOW: list[dict] = [dict() for _ in range(_LOGIN_IP_OVERFLOW_SHARDS)]
|
|
|
|
|
|
def _overflow_shard(ip: str) -> dict:
|
|
return _LOGIN_IP_OVERFLOW[hash(ip) % _LOGIN_IP_OVERFLOW_SHARDS]
|
|
|
|
|
|
def _overflow_record(ip: str, now: float) -> int:
|
|
"""Record an overflow failure for ``ip`` and return its windowed count."""
|
|
shard = _overflow_shard(ip)
|
|
entry = shard.get(ip)
|
|
if entry is not None:
|
|
if now - entry[1] > _LOGIN_WINDOW_SECONDS:
|
|
entry[0], entry[1] = 1, now
|
|
else:
|
|
# Only "at or above the per-IP threshold" matters for blocking, so cap
|
|
# the count there. This also keeps the migration into a per-IP bucket
|
|
# bounded -- without the cap a saturated source could accrue an
|
|
# unbounded count, then materialize one deque entry per failure
|
|
# (``[start] * carried``) on the next attempt, allocating an arbitrarily
|
|
# large deque while holding the login lock.
|
|
entry[0] = min(entry[0] + 1, _LOGIN_IP_MAX_FAILS)
|
|
return entry[0]
|
|
if len(shard) >= _LOGIN_IP_OVERFLOW_MAX:
|
|
# Make room by dropping the lowest-count entry, but the new source starts
|
|
# clean -- never inherit the evicted IP's failures, or an unrelated source
|
|
# could be 429'd after one attempt. Worst case under a saturated shard is
|
|
# that a heavy hitter briefly resets, not that a bystander is blocked.
|
|
del shard[min(shard, key = lambda k: shard[k][0])]
|
|
shard[ip] = [1, now]
|
|
return 1
|
|
|
|
|
|
def _overflow_blocked(ip: str, now: float) -> int:
|
|
"""Seconds this IP is throttled by its own overflow count, or 0."""
|
|
shard = _overflow_shard(ip)
|
|
entry = shard.get(ip)
|
|
if entry is None:
|
|
return 0
|
|
if now - entry[1] > _LOGIN_WINDOW_SECONDS:
|
|
del shard[ip]
|
|
return 0
|
|
if entry[0] >= _LOGIN_IP_MAX_FAILS:
|
|
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - entry[1])))
|
|
return 0
|
|
|
|
|
|
def _overflow_take(ip: str, now: float) -> tuple[int, float]:
|
|
"""Pop ip's overflow entry, returning its ``(count, window_start)`` so the
|
|
count can migrate into a fresh per-IP bucket. ``(0, now)`` if none/expired."""
|
|
entry = _overflow_shard(ip).pop(ip, None)
|
|
if entry is None or now - entry[1] > _LOGIN_WINDOW_SECONDS:
|
|
return 0, now
|
|
# Cap the carried count so the bucket migration never allocates more than the
|
|
# per-IP threshold worth of deque entries (defensive; _overflow_record already
|
|
# clamps, but keep the bound at the consumption site too).
|
|
return min(entry[0], _LOGIN_IP_MAX_FAILS), entry[1]
|
|
|
|
|
|
# Unrepresentable as a real username (leading NUL); folds unknown-user attempts
|
|
# into one slot so attacker cardinality can't blow the bucket dict.
|
|
_UNKNOWN_LOGIN_USER = "\x00unknown-user"
|
|
|
|
|
|
def _trust_forwarded_for() -> bool:
|
|
"""Honour X-Forwarded-For only when UNSLOTH_STUDIO_TRUST_FORWARDED is set.
|
|
|
|
Off by default so a direct caller can't spoof the header.
|
|
"""
|
|
return os.environ.get("UNSLOTH_STUDIO_TRUST_FORWARDED", "").lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
|
|
|
|
def _normalize_forwarded_addr(value: str) -> str:
|
|
"""Parse an XFF / Forwarded `for=` value into a bare IP (port-stripped)."""
|
|
value = (value or "").strip().strip('"')
|
|
if not value or value.lower() == "unknown":
|
|
return ""
|
|
if value.startswith("["):
|
|
# Bracketed IPv6, optionally with port.
|
|
end = value.find("]")
|
|
if end <= 0:
|
|
return ""
|
|
host = value[1:end]
|
|
elif value.count(":") == 1:
|
|
# IPv4:port. Bare IPv6 has multiple colons → else branch.
|
|
head, _, tail = value.rpartition(":")
|
|
host = head if tail.isdigit() and head else value
|
|
else:
|
|
host = value
|
|
try:
|
|
return str(ipaddress.ip_address(host))
|
|
except ValueError:
|
|
return ""
|
|
|
|
|
|
def _forwarded_for_from_element(element: str) -> str:
|
|
"""Pick the `for=` token out of a single ``Forwarded`` element."""
|
|
for tok in element.split(";"):
|
|
key, sep, val = tok.strip().partition("=")
|
|
if sep and key.lower() == "for":
|
|
return _normalize_forwarded_addr(val)
|
|
return ""
|
|
|
|
|
|
def _client_ip(request: Request | None) -> str:
|
|
if request is None:
|
|
return "_unknown"
|
|
if _trust_forwarded_for():
|
|
xff = request.headers.get("x-forwarded-for", "")
|
|
if xff:
|
|
# First entry is the originating client.
|
|
normalized = _normalize_forwarded_addr(xff.split(",", 1)[0])
|
|
if normalized:
|
|
return normalized
|
|
fwd = request.headers.get("forwarded", "")
|
|
if fwd:
|
|
# First element only; multi-element headers can't fork buckets.
|
|
normalized = _forwarded_for_from_element(fwd.split(",", 1)[0])
|
|
if normalized:
|
|
return normalized
|
|
return (request.client.host if request.client else None) or "_unknown"
|
|
|
|
|
|
def _bucket_key(request: Request | None, username: str) -> tuple[str, str]:
|
|
return (_client_ip(request), (username or "").casefold())
|
|
|
|
|
|
def _unknown_user_key(request: Request | None) -> tuple[str, str]:
|
|
return (_client_ip(request), _UNKNOWN_LOGIN_USER)
|
|
|
|
|
|
def _prune_bucket(bucket: deque, now: float) -> None:
|
|
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
|
|
bucket.popleft()
|
|
|
|
|
|
def _prune_stale_buckets(now: float) -> None:
|
|
"""Drop empty / expired account buckets to bound memory under spray."""
|
|
stale: list[tuple[str, str]] = []
|
|
for key, bucket in _LOGIN_BUCKETS.items():
|
|
_prune_bucket(bucket, now)
|
|
if not bucket:
|
|
stale.append(key)
|
|
for key in stale:
|
|
_LOGIN_BUCKETS.pop(key, None)
|
|
|
|
|
|
def _prune_stale_ip_buckets(now: float) -> None:
|
|
"""Drop empty / expired per-IP buckets to bound memory under spray.
|
|
|
|
The dict is otherwise reclaimed only on a successful login, so a failure-only
|
|
spray from many (or spoofed) IPs would grow it without bound.
|
|
"""
|
|
stale: list[str] = []
|
|
for bucket_ip, bucket in _LOGIN_IP_BUCKETS.items():
|
|
_prune_bucket(bucket, now)
|
|
if not bucket:
|
|
stale.append(bucket_ip)
|
|
for bucket_ip in stale:
|
|
_LOGIN_IP_BUCKETS.pop(bucket_ip, None)
|
|
|
|
|
|
def _record_login_failure(key: tuple[str, str]) -> int:
|
|
global _LAST_IP_PRUNE
|
|
now = time.monotonic()
|
|
ip, _username = key
|
|
with _LOGIN_BUCKETS_LOCK:
|
|
# Keep the dict bounded without disabling throttling and without letting a
|
|
# spray reset a hot bucket: for a new IP at the cap, reclaim expired buckets
|
|
# (rate-limited) to make room.
|
|
ip_bucket = _LOGIN_IP_BUCKETS.get(ip)
|
|
if ip_bucket is None and len(_LOGIN_IP_BUCKETS) >= _LOGIN_MAX_BUCKETS:
|
|
if now - _LAST_IP_PRUNE >= 1.0:
|
|
_prune_stale_ip_buckets(now)
|
|
_LAST_IP_PRUNE = now
|
|
if ip_bucket is None and len(_LOGIN_IP_BUCKETS) >= _LOGIN_MAX_BUCKETS:
|
|
# Still full -- every bucket is hot. Count this failure in the IP's
|
|
# bounded overflow shard instead of evicting a live one, so the spray
|
|
# stays throttled but can't push out (and reset) any IP's own counter.
|
|
ip_fails = _overflow_record(ip, now)
|
|
else:
|
|
if ip_bucket is None:
|
|
ip_bucket = _LOGIN_IP_BUCKETS[ip] = deque()
|
|
# Carry over any overflow failures this IP accrued while the dict
|
|
# was saturated, so straddling the overflow -> bucket transition
|
|
# can't double the effective per-IP limit.
|
|
carried, start = _overflow_take(ip, now)
|
|
ip_bucket.extend([start] * carried)
|
|
_prune_bucket(ip_bucket, now)
|
|
ip_bucket.append(now)
|
|
ip_fails = len(ip_bucket)
|
|
|
|
if key not in _LOGIN_BUCKETS and len(_LOGIN_BUCKETS) >= _LOGIN_MAX_BUCKETS:
|
|
_prune_stale_buckets(now)
|
|
if key in _LOGIN_BUCKETS or len(_LOGIN_BUCKETS) < _LOGIN_MAX_BUCKETS:
|
|
account_bucket = _LOGIN_BUCKETS.setdefault(key, deque())
|
|
_prune_bucket(account_bucket, now)
|
|
account_bucket.append(now)
|
|
return len(account_bucket)
|
|
# Both dicts at cap (sustained spray): fall back to the per-IP count.
|
|
return ip_fails
|
|
|
|
|
|
def _blocked_for(bucket: deque | None, now: float, max_fails: int) -> int:
|
|
if not bucket:
|
|
return 0
|
|
_prune_bucket(bucket, now)
|
|
if len(bucket) >= max_fails:
|
|
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
|
|
return 0
|
|
|
|
|
|
def _login_blocked(key: tuple[str, str]) -> int:
|
|
"""Return seconds until the next attempt is allowed, or 0."""
|
|
now = time.monotonic()
|
|
ip, _username = key
|
|
with _LOGIN_BUCKETS_LOCK:
|
|
# Honor the IP's overflow shard regardless of current dict capacity: a
|
|
# source counted there during saturation must stay throttled until those
|
|
# failures age out, even if a bucket later frees up -- otherwise a fresh
|
|
# bucket would reset it. Shards are empty outside saturation, so this is a
|
|
# no-op in the common case.
|
|
ip_blocked = max(
|
|
_blocked_for(_LOGIN_IP_BUCKETS.get(ip), now, _LOGIN_IP_MAX_FAILS),
|
|
_overflow_blocked(ip, now),
|
|
)
|
|
return max(_blocked_for(_LOGIN_BUCKETS.get(key), now, _LOGIN_MAX_FAILS), ip_blocked)
|
|
|
|
|
|
def _clear_login_bucket(key: tuple[str, str]) -> None:
|
|
ip, _username = key
|
|
with _LOGIN_BUCKETS_LOCK:
|
|
_LOGIN_BUCKETS.pop(key, None)
|
|
_LOGIN_IP_BUCKETS.pop(ip, None)
|
|
# A successful login resets the IP's throttle, including any overflow it
|
|
# accumulated during saturation (drop only this IP's entry, so a
|
|
# shard-mate's throttle is untouched).
|
|
_overflow_shard(ip).pop(ip, None)
|
|
|
|
|
|
# Sync def (not async): compute_identity_proof touches SQLite on the first call,
|
|
# so FastAPI runs it in the threadpool rather than blocking the event loop.
|
|
@router.get("/identity")
|
|
def identity(nonce: str, request: Request) -> dict:
|
|
"""Challenge-response proof this is the real local Studio: caller sends a nonce,
|
|
gets HMAC(install identity secret, nonce, connection address + port).
|
|
Unauthenticated and side-effect free; a process that can't read the same-user
|
|
secret can't forge a proof, and binding to the address/port the connection
|
|
landed on stops a squatter relaying a proof from the real Studio elsewhere."""
|
|
try:
|
|
raw = base64.urlsafe_b64decode(nonce)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_400_BAD_REQUEST, detail = "nonce must be base64url"
|
|
)
|
|
if not 16 <= len(raw) <= 128:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_400_BAD_REQUEST, detail = "nonce must decode to 16-128 bytes"
|
|
)
|
|
# The address + port the connection actually landed on, from the socket
|
|
# (request.scope is getsockname, so it is the real local address even when
|
|
# bound to 0.0.0.0), never the client-controlled Host header.
|
|
server = request.scope.get("server") or ("", 0)
|
|
host = server[0] or ""
|
|
port = server[1] if server[1] is not None else 0
|
|
return {"proof": storage.compute_identity_proof(raw, host, port)}
|
|
|
|
|
|
@router.get("/status", response_model = AuthStatusResponse)
|
|
async def auth_status() -> AuthStatusResponse:
|
|
"""Auth initialization state; ``default_username`` is exposed for first-boot UI prefill only."""
|
|
return AuthStatusResponse(
|
|
initialized = storage.is_initialized(),
|
|
default_username = storage.DEFAULT_ADMIN_USERNAME,
|
|
requires_password_change = storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME)
|
|
if storage.is_initialized()
|
|
else True,
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model = Token)
|
|
async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|
"""Login with username/password. Per-account + per-IP rate-limited."""
|
|
key = _bucket_key(request, payload.username)
|
|
unknown_key = _unknown_user_key(request)
|
|
blocked_for = max(_login_blocked(key), _login_blocked(unknown_key))
|
|
if blocked_for > 0:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
|
|
# IP not interpolated into the body; behind a proxy/NAT it's
|
|
# misleading or an info leak.
|
|
detail = (f"Too many failed login attempts. " f"Try again in {blocked_for} seconds."),
|
|
headers = {"Retry-After": str(blocked_for)},
|
|
)
|
|
|
|
record = storage.get_user_and_secret(payload.username)
|
|
if record is None:
|
|
# Record under one sentinel key per IP so attacker-controlled username
|
|
# cardinality can't allocate unbounded buckets.
|
|
_record_login_failure(unknown_key)
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}",
|
|
)
|
|
|
|
salt, pwd_hash, _jwt_secret, must_change_password = record
|
|
if not hashing.verify_password(payload.password, salt, pwd_hash):
|
|
_record_login_failure(key)
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}",
|
|
)
|
|
|
|
_clear_login_bucket(key)
|
|
_clear_login_bucket(unknown_key)
|
|
access_token = create_access_token(subject = payload.username)
|
|
refresh_token = create_refresh_token(subject = payload.username)
|
|
return Token(
|
|
access_token = access_token,
|
|
refresh_token = refresh_token,
|
|
token_type = "bearer",
|
|
must_change_password = must_change_password,
|
|
)
|
|
|
|
|
|
@router.post("/logout", status_code = status.HTTP_204_NO_CONTENT)
|
|
async def logout(
|
|
request: Request, current_subject: str = Depends(get_current_subject_allow_password_change)
|
|
) -> Response:
|
|
"""Revoke refresh tokens for the subject; the access token is stateless and expires on its own."""
|
|
try:
|
|
storage.revoke_user_refresh_tokens(current_subject)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
request.app.state.bootstrap_password = None
|
|
except AttributeError:
|
|
pass
|
|
return Response(status_code = status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post("/desktop-login", response_model = Token)
|
|
async def desktop_login(payload: DesktopLoginRequest) -> Token:
|
|
"""Exchange a local desktop secret for normal admin-subject tokens."""
|
|
username = storage.validate_desktop_secret(payload.secret)
|
|
if username is None:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = "Desktop authentication failed",
|
|
)
|
|
|
|
return Token(
|
|
access_token = create_access_token(subject = username, desktop = True),
|
|
refresh_token = create_refresh_token(subject = username, desktop = True),
|
|
token_type = "bearer",
|
|
must_change_password = False,
|
|
)
|
|
|
|
|
|
@router.post("/refresh", response_model = Token)
|
|
async def refresh(payload: RefreshTokenRequest) -> Token:
|
|
"""Exchange a refresh token for a new access+refresh pair (single-use)."""
|
|
consumed = storage.consume_refresh_token(payload.refresh_token)
|
|
if consumed is None:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = "Invalid or expired refresh token",
|
|
)
|
|
username, is_desktop = consumed
|
|
new_access_token = create_access_token(subject = username, desktop = is_desktop)
|
|
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
|
|
|
|
return Token(
|
|
access_token = new_access_token,
|
|
refresh_token = new_refresh_token,
|
|
token_type = "bearer",
|
|
must_change_password = False if is_desktop else storage.requires_password_change(username),
|
|
)
|
|
|
|
|
|
@router.post("/change-password", response_model = Token)
|
|
async def change_password(
|
|
payload: ChangePasswordRequest,
|
|
request: Request,
|
|
current_subject: str = Depends(get_current_subject_allow_password_change),
|
|
) -> Token:
|
|
"""Allow the authenticated user to replace the default password."""
|
|
record = storage.get_user_and_secret(current_subject)
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = "User session is invalid",
|
|
)
|
|
|
|
salt, pwd_hash, _jwt_secret, _must_change_password = record
|
|
if not hashing.verify_password(payload.current_password, salt, pwd_hash):
|
|
raise HTTPException(
|
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
|
detail = "Current password is incorrect",
|
|
)
|
|
if payload.current_password == payload.new_password:
|
|
raise HTTPException(
|
|
status_code = status.HTTP_400_BAD_REQUEST,
|
|
detail = "New password must be different from the current password",
|
|
)
|
|
|
|
storage.update_password(current_subject, payload.new_password)
|
|
storage.revoke_user_refresh_tokens(current_subject)
|
|
try:
|
|
request.app.state.bootstrap_password = None
|
|
except AttributeError:
|
|
pass
|
|
access_token = create_access_token(subject = current_subject)
|
|
refresh_token = create_refresh_token(subject = current_subject)
|
|
return Token(
|
|
access_token = access_token,
|
|
refresh_token = refresh_token,
|
|
token_type = "bearer",
|
|
must_change_password = False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API key management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
|
|
return ApiKeyResponse(
|
|
id = row["id"],
|
|
name = row["name"],
|
|
key_prefix = row["key_prefix"],
|
|
created_at = row["created_at"],
|
|
last_used_at = row.get("last_used_at"),
|
|
expires_at = row.get("expires_at"),
|
|
is_active = bool(row["is_active"]),
|
|
)
|
|
|
|
|
|
@router.post("/api-keys", response_model = CreateApiKeyResponse)
|
|
async def create_api_key(
|
|
payload: CreateApiKeyRequest, current_subject: str = Depends(get_current_subject)
|
|
) -> CreateApiKeyResponse:
|
|
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
|
|
expires_at = None
|
|
if payload.expires_in_days is not None:
|
|
expires_at = (
|
|
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
|
|
).isoformat()
|
|
|
|
raw_key, row = storage.create_api_key(
|
|
username = current_subject,
|
|
name = payload.name,
|
|
expires_at = expires_at,
|
|
)
|
|
return CreateApiKeyResponse(
|
|
key = raw_key,
|
|
api_key = _row_to_api_key_response(row),
|
|
)
|
|
|
|
|
|
@router.get("/api-keys", response_model = ApiKeyListResponse)
|
|
async def list_api_keys(current_subject: str = Depends(get_current_subject)) -> ApiKeyListResponse:
|
|
"""List all API keys for the authenticated user (raw keys are never exposed)."""
|
|
rows = storage.list_api_keys(current_subject)
|
|
return ApiKeyListResponse(
|
|
api_keys = [_row_to_api_key_response(r) for r in rows],
|
|
)
|
|
|
|
|
|
@router.delete("/api-keys/{key_id}")
|
|
async def revoke_api_key(key_id: int, current_subject: str = Depends(get_current_subject)) -> dict:
|
|
"""Revoke (soft-delete) an API key."""
|
|
if not storage.revoke_api_key(current_subject, key_id):
|
|
raise HTTPException(
|
|
status_code = status.HTTP_404_NOT_FOUND,
|
|
detail = "API key not found",
|
|
)
|
|
return {"detail": "API key revoked"}
|