"""Authentication endpoints.""" import asyncio import json import logging import os import time from ipaddress import ip_address, ip_network from typing import Literal import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, ConfigDict, EmailStr, Field, TypeAdapter, ValidationError, field_validator, model_validator from app.gateway.admin_query_window import recent_admin_query_cutoff from app.gateway.auth import ( UserResponse, create_access_token, ) from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.models import User from app.gateway.auth.password import has_account_password, hash_password_async, is_password_hash, verify_account_password from app.gateway.auth.repositories.base import CredentialConflictError from app.gateway.csrf_middleware import is_secure_request from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store from app.gateway.perf_logging import finish_trace, trace_request, trace_stage from deerflow.config import get_app_config from deerflow.config.system_settings import AuthenticationSettings, load_system_settings from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.skills.base import SkillStore logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) _USER_NOT_LOOKED_UP = object() # ── Request/Response Models ────────────────────────────────────────────── class LoginResponse(BaseModel): """Response model for login — token only lives in HttpOnly cookie.""" expires_in: int # seconds needs_setup: bool = False # Top common-password blocklist. Drawn from the public SecLists "10k worst # passwords" set, lowercased + length>=8 only (shorter ones already fail # the min_length check). Kept tight on purpose: this is the **lower bound** # defense, not a full HIBP / passlib check, and runs in-process per request. _COMMON_PASSWORDS: frozenset[str] = frozenset( { "password", "password1", "password12", "password123", "password1234", "12345678", "123456789", "1234567890", "qwerty12", "qwertyui", "qwerty123", "abc12345", "abcd1234", "iloveyou", "letmein1", "welcome1", "welcome123", "admin123", "administrator", "passw0rd", "p@ssw0rd", "monkey12", "trustno1", "sunshine", "princess", "football", "baseball", "superman", "batman123", "starwars", "dragon123", "master123", "shadow12", "michael1", "jennifer", "computer", } ) def _password_is_common(password: str) -> bool: """Case-insensitive blocklist check. Lowercases the input so trivial mutations like ``Password`` / ``PASSWORD`` are also rejected. Does not normalize digit substitutions (``p@ssw0rd`` is included as a literal entry instead) — keeping the rule cheap and predictable. """ return password.lower() in _COMMON_PASSWORDS def _validate_strong_password(value: str) -> str: """Pydantic field-validator body shared by Register + ChangePassword. Constraint = function, not type-level mixin. The two request models have no "is-a" relationship; they only share the password-strength rule. Lifting it into a free function lets each model bind it via ``@field_validator(field_name)`` without inheritance gymnastics. """ if _password_is_common(value): raise ValueError("密码过于常见,请设置更安全的密码") if value != value.strip(): raise ValueError("密码首尾不能包含空格") return value class RegisterRequest(BaseModel): """Request model for user registration.""" username: str | None = Field(default=None, min_length=1, max_length=128) email: EmailStr | None = None password: str = Field(..., min_length=8, max_length=255) model_config = ConfigDict(extra="forbid") _strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v))) @model_validator(mode="after") def validate_identity(self): if bool(self.username) == bool(self.email): raise ValueError("请填写用户名或邮箱(只需一项)") if self.username is not None: value = self.username.strip() if "@" not in value and (not value or value[0] in "._-" or value[-1] in "._-" or any(not (ch.isalnum() or ch in "._-") for ch in value)): raise ValueError("用户名仅支持字母、数字、点、下划线和短横线,首尾须为字母或数字") self.username = value if "@" in value: TypeAdapter(EmailStr).validate_python(value) return self class UsernameLoginRequest(BaseModel): """Request model for passwordless username login. Behavior: - Existing user: direct login without password - Missing user: auto-register and login without password """ username: str = Field(..., min_length=1, max_length=128) class UsernameLoginResponse(BaseModel): """Response model for passwordless username login. Returns the access token directly in the body so non-browser clients can use it as a Bearer credential, while a session cookie is still set for browser-based callers. """ access_token: str token_type: str = "bearer" expires_in: int user_id: str email: str system_role: Literal["admin", "user"] = "user" needs_setup: bool = False created: bool = Field(default=False, description="True when the user was auto-registered by this call") username: str | None = Field( default=None, description=( "The login username this call resolved (token login: the value read " "from getTokenInfo; username login: the supplied username). Used by " "the frontend to append ?username= on task-workspace jump links; the " "stored email local-part is lossy (lowercased/normalized)." ), ) class ChangePasswordRequest(BaseModel): """Request model for password change (also handles setup flow).""" current_password: str = Field(default="", max_length=255) new_password: str = Field(..., min_length=8, max_length=255) new_email: EmailStr | None = None _strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v))) class MessageResponse(BaseModel): """Generic message response.""" message: str class PasswordChangeResponse(MessageResponse): access_token: str token_type: str = "bearer" expires_in: int # ── Helpers ─────────────────────────────────────────────────────────────── def _normalize_username_local_part(username: str) -> str | None: """Return the canonical local-part for a username, or None if user typed an email. Used both to build the synthetic email and to look up legacy accounts that were registered with a different default domain. Returning ``None`` for raw-email input tells callers to skip the domain-agnostic fallback — if you typed ``alice@old.com``, you meant that specific row. """ raw = username.strip() if not raw: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能为空") if "@" in raw: return None normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-") if not normalized: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不合法") return normalized def _username_to_email(username: str) -> str: """Map a frontend username to a valid email used by user storage. - If username already looks like an email (`contains @`), use it directly. - Otherwise generate `@`. """ local = _normalize_username_local_part(username) if local is None: return username.strip().lower() # Default mail domain for passwordless / username-only registrations. # Kept overridable via env so individual deployments can swap it out, but the # built-in default targets the cm.com tenant. domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "cm.com").strip().lower() or "cm.com" return f"{local}@{domain}" # 后台岗位同步任务的强引用集合:防止 create_task 的任务被 GC 中途回收。 _position_sync_tasks: set = set() _POSITION_SYNC_CACHE_SECONDS = 60.0 _MAX_POSITION_SYNC_CACHE_USERS = 10_000 _position_sync_completed_at: dict[str, float] = {} _position_sync_inflight: dict[str, asyncio.Task[None]] = {} def _sync_user_position(request: Request, user_id: str) -> None: """Best-effort: schedule the user's 岗位 (position) sync off the login path. Ensures a user who has never been synced — e.g. a brand-new account that inherits the ``is_default`` position — gets the default agents / tasks / prompts loaded into their "我的" tables. The sync is idempotent but touches a dozen tables per user, so it runs as a fire-and-forget task instead of blocking the login response; the materialized rows land a moment later and failures are logged, never surfaced. """ normalized_user_id = str(user_id) now = time.monotonic() completed_at = _position_sync_completed_at.get(normalized_user_id) if completed_at is not None and now - completed_at < _POSITION_SYNC_CACHE_SECONDS: return if normalized_user_id in _position_sync_inflight: return sync = getattr(request.app.state, "position_sync", None) if sync is None: return async def _run() -> None: try: await sync.sync_user(normalized_user_id) _position_sync_completed_at[normalized_user_id] = time.monotonic() if len(_position_sync_completed_at) > _MAX_POSITION_SYNC_CACHE_USERS: oldest = sorted(_position_sync_completed_at, key=_position_sync_completed_at.get) for stale_user_id in oldest[: len(oldest) // 2]: _position_sync_completed_at.pop(stale_user_id, None) except Exception: logger.debug("position sync on login failed for user %s", user_id, exc_info=True) finally: _position_sync_inflight.pop(normalized_user_id, None) try: task = asyncio.create_task(_run()) except RuntimeError: # 防御:请求处理器内必有运行中的事件循环,这里理论上走不到。 logger.debug("position sync scheduling failed for user %s", user_id, exc_info=True) return _position_sync_tasks.add(task) _position_sync_inflight[normalized_user_id] = task task.add_done_callback(_position_sync_tasks.discard) async def _get_or_create_user_by_username( username: str, *, existing_user: User | None | object = _USER_NOT_LOOKED_UP, ): """Resolve a username to a local user, auto-registering when absent. Shared by ``/login/username`` and ``/login/token`` — both turn an externally-supplied username into a local account and sign it in. Returns ``(user, created)`` where ``created`` is True only when this call inserted the row. Roles are persisted authorization data, never inferred from names. """ provider = get_local_provider() email = _username_to_email(username) local_part = _normalize_username_local_part(username) created = False # A bare username is globally unique across synthetic email domains. Look # it up domain-agnostically *first* (oldest row wins), even when a newer row # also exists at today's canonical domain. Choosing the exact canonical # row first made the same username alternate backend user_ids after a # domain migration, which in turn made its existing threads inaccessible. if existing_user is not _USER_NOT_LOOKED_UP: # /login/username already performed this lookup to validate the # account-specific password. Reuse both hits and misses instead of # issuing the same MySQL query twice during a login burst. user = existing_user elif local_part is not None: user = await provider.find_user_by_email_local_part(local_part) if user is not None and user.email != email: logger.info("Matched username %r to stable legacy account %s", username, user.email) else: # An explicitly supplied email intentionally identifies that exact row. user = await provider.get_user_by_email(email) if user is None: try: # 黑名单模式:从未登录过的新用户(此处即首次自动注册)默认放行(approved=True)。 # 管理员可在白名单管理页对个别用户「取消放行」来封禁——仅当登录白名单开关打开时生效。 user = await provider.create_user(email=email, password=None, system_role="user", needs_setup=False, approved=True) created = True logger.info("Auto-registered passwordless user: %s", email) except ValueError: # Concurrent first login may create the same user in parallel. user = await provider.get_user_by_email(email) if user is None and local_part is not None: user = await provider.find_user_by_email_local_part(local_part) if user is None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该用户名已存在,请使用原账号登录") return user, created async def _lookup_user_by_username(username: str) -> User | None: """Resolve a username to an **existing** local user, without creating one. Mirrors the lookup half of ``_get_or_create_user_by_username`` (canonical ``@`` email + legacy local-part fallback) so the per-account 登录口令 gate can read the target account's ``login_password`` before deciding whether to admit the login. Returns ``None`` for a username that has never registered (the personal-password gate then simply does not apply, and ``_get_or_create_user_by_username`` later auto-registers it). """ provider = get_local_provider() email = _username_to_email(username) local_part = _normalize_username_local_part(username) if local_part is not None: return await provider.find_user_by_email_local_part(local_part) return await provider.get_user_by_email(email) def enforce_user_approved(user) -> None: """登录白名单拦截(**黑名单模式**):开关打开且用户被「取消放行」时拒绝登录。 新账号首次登录即默认放行(``approved=True``,见 ``_get_or_create_user_by_username`` 与 ``create_user``),所以从未在本系统登录过的用户可以直接使用;白名单开关只用来 封禁管理员显式「取消放行」(``approved=False``) 的个别用户。 规则(三条硬约束防锁死): - 开关关(``login_whitelist.enabled`` 默认 False)→ 直接放行,人人可登录。 - admin → 永远放行(避免管理员把自己关在门外)。 - 其余:``approved`` 为 False(被管理员取消放行)→ 403 ``PENDING_APPROVAL``; 前端据此提示「等待管理员开通」,且不写任何登录态。 放在各登录端点拿到 user 之后、签发 JWT 之前调用。 """ try: enabled = load_system_settings().login_whitelist.enabled except Exception: # 读配置失败时绝不误伤(白名单是附加闸门,故障时回到「不拦截」)。 logger.warning("读取登录白名单配置失败,本次登录不拦截", exc_info=True) return if not enabled: return if getattr(user, "system_role", None) == "admin": return if not getattr(user, "approved", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"code": "PENDING_APPROVAL", "message": "您的账号已被限制登录,请联系管理员开通"}, ) async def _fetch_username_from_token(token: str) -> str: """Resolve an upstream access token to a username via the configured endpoint. Calls ``auth_login.token_login.token_info_url`` with the caller-supplied token placed directly in the ``Authorization: Bearer `` header — the upstream reads the bearer token from there, not from a cookie. Expects ``{"state": "200", "data": {"username": ...}}`` and returns ``data.username``. Any upstream failure, non-200 ``state``, or missing username raises HTTP 401. """ config = get_app_config().auth_login.token_login # Offline test shortcut: a token listed in ``mock_tokens`` resolves to its # mapped username without ever calling the (possibly unreachable) upstream # ``token_info_url``. Lets token login be exercised end-to-end where the real # getTokenInfo host can't be reached (external networks). Empty in prod. mocked = config.mock_tokens.get(token) if mocked and str(mocked).strip(): return str(mocked).strip() if not config.token_info_url: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)") # TLS verification for the (HTTPS) upstream. A custom CA bundle wins; else # the boolean toggle. UAT/internal hosts often need verify_ssl=false because # they serve self-signed or private-CA certs — without this the call dies # with an SSLError and every token login silently 401s. verify: bool | str = config.ca_cert_path if config.ca_cert_path else config.verify_ssl try: async with httpx.AsyncClient(timeout=config.timeout_seconds, verify=verify) as client: upstream = await client.post( config.token_info_url, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/x-www-form-urlencoded", }, ) except httpx.HTTPError as exc: # Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs # so HTTPS/cert misconfiguration is diagnosable; client still sees 401. logger.warning( "Token login: upstream call to %s failed (verify=%r): %s: %s", config.token_info_url, verify, type(exc).__name__, exc, ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 校验失败").model_dump()) if upstream.status_code != 200: logger.warning("Token login: upstream returned HTTP %s", upstream.status_code) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效").model_dump()) try: payload = upstream.json() except ValueError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 接口返回格式异常").model_dump()) # Upstream signals success with the string state "200" (see API docs). if str(payload.get("state")) != "200": logger.warning("Token login: upstream state=%r msg=%r", payload.get("state"), payload.get("msg")) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效或已过期").model_dump()) username = (payload.get("data") or {}).get("username") if not username or not str(username).strip(): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="未能从 token 解析出用户名").model_dump()) return str(username).strip() def _set_session_cookie(response: Response, token: str, request: Request) -> None: """Set the access_token HttpOnly cookie on the response.""" config = get_auth_config() is_https = is_secure_request(request) response.set_cookie( key="access_token", value=token, httponly=True, secure=is_https, samesite="lax", max_age=config.token_expiry_days * 24 * 3600 if is_https else None, ) # ── Rate Limiting ──────────────────────────────────────────────────────── # In-process dict — not shared across workers. # # **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each # worker maintains its own lockout table, so an attacker effectively gets # N × _MAX_LOGIN_ATTEMPTS guesses before being locked out everywhere. For # production multi-worker setups, replace this with a shared store (Redis, # database-backed counter) to enforce a true per-IP limit. _MAX_LOGIN_ATTEMPTS = 100 _LOCKOUT_SECONDS = 300 # 5 minutes # ip → (fail_count, lock_until_timestamp) _login_attempts: dict[str, tuple[int, float]] = {} def _trusted_proxies() -> list: """Parse ``AUTH_TRUSTED_PROXIES`` env var into a list of ip_network objects. Comma-separated CIDR or single-IP entries. Empty / unset = no proxy is trusted (direct mode). Invalid entries are skipped with a logger warning. Read live so env-var overrides take effect immediately and tests can ``monkeypatch.setenv`` without poking a module-level cache. """ raw = os.getenv("AUTH_TRUSTED_PROXIES", "").strip() if not raw: return [] nets = [] for entry in raw.split(","): entry = entry.strip() if not entry: continue try: nets.append(ip_network(entry, strict=False)) except ValueError: logger.warning("AUTH_TRUSTED_PROXIES: ignoring invalid entry %r", entry) return nets def _get_client_ip(request: Request) -> str: """Extract the real client IP for rate limiting. Trust model: - The TCP peer (``request.client.host``) is always the baseline. It is whatever the kernel reports as the connecting socket — unforgeable by the client itself. - ``X-Real-IP`` is **only** honored if the TCP peer is in the ``AUTH_TRUSTED_PROXIES`` allowlist (set via env var, comma-separated CIDR or single IPs). When set, the gateway is assumed to be behind a reverse proxy (nginx, Cloudflare, ALB, …) that overwrites ``X-Real-IP`` with the original client address. - With no ``AUTH_TRUSTED_PROXIES`` set, ``X-Real-IP`` is silently ignored — closing the bypass where any client could rotate the header to dodge per-IP rate limits in dev / direct-gateway mode. ``X-Forwarded-For`` is intentionally NOT used because it is naturally client-controlled at the *first* hop and the trust chain is harder to audit per-request. """ peer_host = request.client.host if request.client else None trusted = _trusted_proxies() if trusted and peer_host: try: peer_ip = ip_address(peer_host) if any(peer_ip in net for net in trusted): real_ip = request.headers.get("x-real-ip", "").strip() if real_ip: return real_ip except ValueError: # peer_host wasn't a parseable IP (e.g. "unknown") — fall through pass return peer_host or "unknown" def _check_rate_limit(ip: str) -> None: """Raise 429 if the IP is currently locked out.""" record = _login_attempts.get(ip) if record is None: return fail_count, lock_until = record if fail_count >= _MAX_LOGIN_ATTEMPTS: if time.time() < lock_until: raise HTTPException( status_code=429, detail="登录尝试次数过多,请稍后再试", ) del _login_attempts[ip] _MAX_TRACKED_IPS = 10000 def _record_login_failure(ip: str) -> None: """Record a failed login attempt for the given IP.""" # Evict expired lockouts when dict grows too large if len(_login_attempts) >= _MAX_TRACKED_IPS: now = time.time() expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t] for k in expired: del _login_attempts[k] # If still too large, evict cheapest-to-lose half: below-threshold # IPs (lock_until=0.0) sort first, then earliest-expiring lockouts. if len(_login_attempts) >= _MAX_TRACKED_IPS: by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1]) for k, _ in by_time[: len(by_time) // 2]: del _login_attempts[k] record = _login_attempts.get(ip) if record is None: _login_attempts[ip] = (1, 0.0) else: new_count = record[0] + 1 lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0 _login_attempts[ip] = (new_count, lock_until) def _record_login_success(ip: str) -> None: """Clear failure counter for the given IP on successful login.""" _login_attempts.pop(ip, None) # ── Endpoints ───────────────────────────────────────────────────────────── def _require_login_method(method: str) -> None: settings = load_system_settings().authentication if not getattr(settings, method): labels = {"password_login_enabled": "账号密码登录", "registration_enabled": "自助注册", "sso_enabled": "外部系统免登"} raise HTTPException(status_code=403, detail=f"管理员尚未开启{labels[method]}") def _login_response(user: User, token: str, *, username: str | None = None, created: bool = False) -> UsernameLoginResponse: return UsernameLoginResponse( access_token=token, expires_in=get_auth_config().token_expiry_days * 24 * 3600, user_id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup, created=created, username=username or user.email.split("@", 1)[0], ) async def _prepare_login_session(user: User) -> User: """Apply the administrator's one-online-session policy after authentication.""" if not load_system_settings().authentication.single_session_enabled: return user try: return await get_local_provider().start_login_session(user) except CredentialConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc class AuthenticationOptions(AuthenticationSettings): needs_setup: bool = False @router.get("/options", response_model=AuthenticationOptions) async def authentication_options(request: Request, response: Response) -> AuthenticationOptions: """Anonymous, non-secret capability discovery for the login page.""" response.headers["Cache-Control"] = "no-store" trace = trace_request(request, "auth_options") try: stage_started = time.perf_counter() authentication = load_system_settings().authentication.model_dump() trace_stage(trace, "load_system_settings", stage_started) stage_started = time.perf_counter() needs_setup = await get_local_provider().count_admin_users() == 0 trace_stage(trace, "count_admin_users", stage_started) return AuthenticationOptions(**authentication, needs_setup=needs_setup) finally: finish_trace(trace) @router.post("/login/local", response_model=UsernameLoginResponse) async def login_local( request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends(), ): """Username or email login with the account's unified password/passphrase.""" _require_login_method("password_login_enabled") client_ip = _get_client_ip(request) _check_rate_limit(client_ip) user = await _lookup_user_by_username(form_data.username) if user is not None and not await verify_account_password(form_data.password, user): user = None if user is None: _record_login_failure(client_ip) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="账号或密码错误").model_dump(), ) _record_login_success(client_ip) enforce_user_approved(user) user = await _prepare_login_session(user) token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password") _set_session_cookie(response, token, request) _sync_user_position(request, user.id) return _login_response(user, token, username=form_data.username.strip()) def _username_login_request(username: object) -> UsernameLoginRequest: if not isinstance(username, str): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required") try: return UsernameLoginRequest(username=username) except ValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required") from exc async def _read_username_login(request: Request) -> UsernameLoginRequest: """Accept the historical JSON body and a form body. ``application/x-www-form-urlencoded`` is a CORS simple content type. Old Firefox aborts a credentialed JSON login during preflight and reports NS_BINDING_ABORTED, so the browser client sends a form body instead. """ media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() if media_type == "application/x-www-form-urlencoded": form = await request.form() return _username_login_request(form.get("username")) try: payload = await request.json() except json.JSONDecodeError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="username is required") from exc username = payload.get("username") if isinstance(payload, dict) else None return _username_login_request(username) @router.post("/login/username", response_model=UsernameLoginResponse) async def login_by_username( request: Request, response: Response, password: str | None = Query(default=None, description="Account password or shared passphrase; required when password login is enabled."), ): """Compatibility username login, gated by the current credential policy. Personal password/passphrase wins. With password login enabled, accounts without personal credentials must supply the existing shared passphrase (default 123ewq). Identity-only login is retained only while password login is disabled and the historical shared-password gate is also disabled. """ body = await _read_username_login(request) _require_login_method("sso_enabled") username_login_config = get_app_config().auth_login.username_login password_login_enabled = load_system_settings().authentication.password_login_enabled # Per-account password takes precedence: look the account up (no create) and, # if it carries a personal 登录口令, require an exact match — ignoring the # shared gate entirely for that account. existing = await _lookup_user_by_username(body.username) credentials_verified = False if has_account_password(existing) or password_login_enabled or username_login_config.require_password: client_ip = _get_client_ip(request) _check_rate_limit(client_ip) if not await verify_account_password(password or "", existing): _record_login_failure(client_ip) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(), ) credentials_verified = True _record_login_success(client_ip) user, created = await _get_or_create_user_by_username(body.username, existing_user=existing) # A registration/reset may commit after the initial lookup/password gate. # Never mint the new account/version's session using an earlier decision. if has_account_password(user) and (existing is None or existing.id != user.id or existing.token_version != user.token_version): client_ip = _get_client_ip(request) _check_rate_limit(client_ip) if not await verify_account_password(password or "", user): _record_login_failure(client_ip) raise HTTPException(status_code=401, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump()) credentials_verified = True enforce_user_approved(user) user = await _prepare_login_session(user) token = create_access_token(str(user.id), token_version=user.token_version, auth_method="passphrase" if credentials_verified else None) _set_session_cookie(response, token, request) _sync_user_position(request, user.id) return UsernameLoginResponse( access_token=token, token_type="bearer", expires_in=get_auth_config().token_expiry_days * 24 * 3600, user_id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup, created=created, username=body.username, ) @router.post("/login/token", response_model=UsernameLoginResponse) async def login_by_token( request: Request, response: Response, token: str = Query(..., min_length=1, description="Upstream access token to exchange for a username."), ): """Token-exchange login. Verifies an upstream access token by calling the configured ``token_info_url`` (see ``auth_login.token_login``), reads the resolved username, then signs the matching local account in — auto-registering it when the username is not yet in the database. Gated by ``auth_login.token_login.enabled``. Returns the JWT access token in the response body and also sets it as a session cookie for browser callers. """ _require_login_method("sso_enabled") config = get_app_config().auth_login.token_login if not config.enabled: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token 登录未开启") client_ip = _get_client_ip(request) _check_rate_limit(client_ip) try: username = await _fetch_username_from_token(token) except HTTPException as exc: if exc.status_code == status.HTTP_401_UNAUTHORIZED: _record_login_failure(client_ip) raise _record_login_success(client_ip) user, created = await _get_or_create_user_by_username(username) enforce_user_approved(user) user = await _prepare_login_session(user) access_token = create_access_token(str(user.id), token_version=user.token_version, auth_method="sso") _set_session_cookie(response, access_token, request) _sync_user_position(request, user.id) return UsernameLoginResponse( access_token=access_token, token_type="bearer", expires_in=get_auth_config().token_expiry_days * 24 * 3600, user_id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup, created=created, # The raw username resolved from getTokenInfo (pre email-normalization), # so the frontend can append the exact ?username= on jump links. username=username, ) @router.post("/register", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED) async def register(request: Request, response: Response, body: RegisterRequest): """Register a new user account (always 'user' role). Admin is auto-created on first boot. This endpoint creates regular users. Auto-login by setting the session cookie. """ _require_login_method("password_login_enabled") _require_login_method("registration_enabled") client_ip = _get_client_ip(request) _check_rate_limit(client_ip) provider = get_local_provider() username = body.username or str(body.email) email = _username_to_email(username) # Match every domain, even for explicit emails: a self-registration must # never shadow the stable account selected by bare-username login. duplicate = await provider.find_user_by_email_local_part(email.split("@", 1)[0]) try: if duplicate is not None: raise ValueError("Duplicate username") user = await provider.create_user(email=email, password=body.password, system_role="user", approved=True) except ValueError: _record_login_failure(client_ip) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="该用户名或邮箱已注册").model_dump(), ) enforce_user_approved(user) user = await _prepare_login_session(user) token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password") _set_session_cookie(response, token, request) _sync_user_position(request, user.id) return _login_response(user, token, username=username, created=True) @router.post("/logout", response_model=MessageResponse) async def logout(request: Request, response: Response): """Logout current user by clearing the cookie.""" response.delete_cookie(key="access_token", secure=is_secure_request(request), samesite="lax") return MessageResponse(message="Successfully logged out") @router.post("/change-password", response_model=PasswordChangeResponse) async def change_password(request: Request, response: Response, body: ChangePasswordRequest): """Change password for the currently authenticated user. Also handles the first-boot setup flow: - If new_email is provided, updates email (checks uniqueness) - If user.needs_setup is True and new_email is given, clears needs_setup - Always increments token_version to invalidate old sessions - Re-issues session cookie with new token_version """ user = await get_current_user_from_request(request) client_ip = _get_client_ip(request) _check_rate_limit(client_ip) if not await verify_account_password(body.current_password, user): _record_login_failure(client_ip) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="当前密码错误").model_dump()) provider = get_local_provider() # Update email if provided if body.new_email is not None: existing = await provider.find_user_by_email_local_part(body.new_email.split("@", 1)[0]) if existing and str(existing.id) != str(user.id): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump()) try: user = await provider.update_credentials(user, await hash_password_async(body.new_password), email=body.new_email) except ValueError as exc: raise HTTPException(status_code=409, detail="账号信息已变更,请刷新后重试") from exc _record_login_success(client_ip) # Re-issue cookie with new token_version token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password") _set_session_cookie(response, token, request) return PasswordChangeResponse(message="密码已更新", access_token=token, expires_in=get_auth_config().token_expiry_days * 24 * 3600) @router.get("/me", response_model=UserResponse) async def get_me(request: Request): """Get current authenticated user info.""" user = await get_current_user_from_request(request) return UserResponse( id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup, has_password=has_account_password(user), requires_current_password=True, uses_default_password=not has_account_password(user), ) _SETUP_STATUS_COOLDOWN: dict[str, float] = {} _SETUP_STATUS_COOLDOWN_SECONDS = 60 _MAX_TRACKED_SETUP_STATUS_IPS = 10000 @router.get("/setup-status") async def setup_status(request: Request): """Check if an admin account exists. Returns needs_setup=True when no admin exists.""" client_ip = _get_client_ip(request) now = time.time() last_check = _SETUP_STATUS_COOLDOWN.get(client_ip, 0) elapsed = now - last_check if elapsed < _SETUP_STATUS_COOLDOWN_SECONDS: retry_after = max(1, int(_SETUP_STATUS_COOLDOWN_SECONDS - elapsed)) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Setup status check is rate limited", headers={"Retry-After": str(retry_after)}, ) # Evict stale entries when dict grows too large to bound memory usage. if len(_SETUP_STATUS_COOLDOWN) >= _MAX_TRACKED_SETUP_STATUS_IPS: cutoff = now - _SETUP_STATUS_COOLDOWN_SECONDS stale = [k for k, t in _SETUP_STATUS_COOLDOWN.items() if t < cutoff] for k in stale: del _SETUP_STATUS_COOLDOWN[k] # If still too large after evicting expired entries, remove oldest half. if len(_SETUP_STATUS_COOLDOWN) >= _MAX_TRACKED_SETUP_STATUS_IPS: by_time = sorted(_SETUP_STATUS_COOLDOWN.items(), key=lambda kv: kv[1]) for k, _ in by_time[: len(by_time) // 2]: del _SETUP_STATUS_COOLDOWN[k] _SETUP_STATUS_COOLDOWN[client_ip] = now admin_count = await get_local_provider().count_admin_users() return {"needs_setup": admin_count == 0} class InitializeAdminRequest(BaseModel): """Request model for first-boot admin account creation.""" email: EmailStr password: str = Field(..., min_length=8, max_length=255) _strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v))) @router.post("/initialize", response_model=UsernameLoginResponse, status_code=status.HTTP_201_CREATED) async def initialize_admin(request: Request, response: Response, body: InitializeAdminRequest): """Create the first admin account on initial system setup. Only callable when no admin exists. Returns 409 Conflict if an admin already exists. On success, the admin account is created with ``needs_setup=False`` and the session cookie is set. """ admin_count = await get_local_provider().count_admin_users() if admin_count > 0: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(), ) try: user = await get_local_provider().create_initial_admin(email=str(body.email), password=body.password) except ValueError: # DB unique-constraint race: another concurrent request beat us. raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(), ) user = await _prepare_login_session(user) token = create_access_token(str(user.id), token_version=user.token_version, auth_method="password") _set_session_cookie(response, token, request) return _login_response(user, token, created=True) # ── Admin Endpoints ─────────────────────────────────────────────────────── class UserListItem(BaseModel): id: str email: str system_role: Literal["admin", "user"] skill_count: int agent_count: int qa_count: int = 0 # 问答次数: number of runs (one run = one question) approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消) login_password: str | None = None # Legacy plaintext only; never return hashes. has_login_password: bool = False access_token: str | None = None token_type: str = "bearer" expires_in: int | None = None def _legacy_login_password(user: User) -> str | None: value = user.login_password return value if value and not is_password_hash(value) else None class UserListPage(BaseModel): """Paged user list — returned when the caller passes ``limit``.""" items: list[UserListItem] total: int approved_count: int # approved 或 admin(搜索范围内) limit: int offset: int _USER_LIST_TIMEOUT_SECONDS = 15 def _user_list_skill_store(request: Request, include_counts: bool = True) -> SkillStore | None: return get_skill_store(request) if include_counts else None def _user_list_agent_store(request: Request, include_counts: bool = True) -> AgentStore | None: return get_agent_store(request) if include_counts else None @router.get("/users", response_model=list[UserListItem] | UserListPage) async def list_all_users( request: Request, include_counts: bool = Query(True, description="False=只查 users 表,跳过 runs 统计与 skill/agent 计数(白名单管理页用,避免慢查询)"), q: str | None = Query(None, max_length=100, description="可选:按邮箱/用户ID 子串过滤(仅分页模式生效)"), limit: int | None = Query(None, ge=1, le=200, description="传入即分页返回 UserListPage;不传保持旧的全量列表"), offset: int = Query(0, ge=0), skill_store: SkillStore | None = Depends(_user_list_skill_store), agent_store: AgentStore | None = Depends(_user_list_agent_store), ): """List all users with their skill, agent and Q&A counts. Admin only. Passing ``limit`` switches to a paged response that reads one page of the users table plus two COUNT queries; omitting it keeps the legacy flat list. """ try: async with asyncio.timeout(_USER_LIST_TIMEOUT_SECONDS if not include_counts else None): user = await get_current_user_from_request(request) if user.system_role != "admin": raise HTTPException(status_code=403, detail="Admin only") provider = get_local_provider() users = ( await provider.list_users() if limit is None else await provider.list_users_page(q=q, limit=limit, offset=offset) ) qa_counts: dict[str, int] = {} # One recent-window GROUP BY for everyone's 问答次数. This keeps the admin # user list from scanning the complete lifetime of the runs table. if include_counts: try: qa_counts = await get_run_store(request).count_by_user( since=recent_admin_query_cutoff() ) except Exception: logger.warning("Failed to load per-user run counts for user list", exc_info=True) result = [] for u in users: uid = str(u.id) result.append( UserListItem( id=uid, email=u.email, system_role=u.system_role, skill_count=(await skill_store.count_owned(uid)) if include_counts else 0, agent_count=(await agent_store.count_owned(uid)) if include_counts else 0, qa_count=qa_counts.get(uid, 0), approved=getattr(u, "approved", True), login_password=_legacy_login_password(u), has_login_password=has_account_password(u), ) ) if limit is None: return result total = await provider.count_users_filtered(q=q) approved_count = await provider.count_users_filtered(q=q, approved_or_admin=True) return UserListPage(items=result, total=total, approved_count=approved_count, limit=limit, offset=offset) except TimeoutError as exc: logger.warning("User list timed out waiting for account data") raise HTTPException(status_code=503, detail="用户基础信息查询超时,请稍后重试") from exc class UserApprovalRequest(BaseModel): approved: bool = Field(..., description="True=放行该用户登录;False=取消放行(下次登录被拦)") @router.put("/users/{user_id}/approval", response_model=UserListItem) async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Request) -> UserListItem: """放行 / 取消放行某个注册用户(登录白名单)。Admin only。 取消放行只拦该用户的下次登录(不动 ``token_version``,现有会话不强制下线)。 """ admin = await get_current_user_from_request(request) if admin.system_role != "admin": raise HTTPException(status_code=403, detail="Admin only") provider = get_local_provider() target = await provider.get_user(user_id) if target is None: raise HTTPException(status_code=404, detail="用户不存在") target.approved = bool(body.approved) try: saved = await provider.update_user(target) except CredentialConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return UserListItem( id=str(saved.id), email=saved.email, system_role=saved.system_role, skill_count=0, agent_count=0, qa_count=0, approved=saved.approved, login_password=_legacy_login_password(saved), has_login_password=has_account_password(saved), ) class UserLoginPasswordRequest(BaseModel): password: str | None = Field( default=None, max_length=255, description="设置账号密码(与登录口令一致);不允许清除后退回免密码登录。", ) @router.put("/users/{user_id}/login-password", response_model=UserListItem) @router.put("/users/{user_id}/reset-password", response_model=UserListItem) async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest, request: Request, response: Response) -> UserListItem: """Admin password reset; rotates both credential columns and all sessions.""" admin = await get_current_user_from_request(request) if admin.system_role != "admin": raise HTTPException(status_code=403, detail="Admin only") provider = get_local_provider() target = await provider.get_user(user_id) if target is None: raise HTTPException(status_code=404, detail="用户不存在") pwd = body.password or "" if not pwd: raise HTTPException(status_code=400, detail="不能清除账号密码,请设置新密码") if len(pwd) < 8: raise HTTPException(status_code=400, detail="密码至少需要 8 个字符") try: _validate_strong_password(pwd) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc try: saved = await provider.update_credentials(target, await hash_password_async(pwd)) except CredentialConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc token = None expires_in = None if str(admin.id) == str(saved.id): token = create_access_token(str(saved.id), token_version=saved.token_version, auth_method="password") _set_session_cookie(response, token, request) expires_in = get_auth_config().token_expiry_days * 24 * 3600 return UserListItem( id=str(saved.id), email=saved.email, system_role=saved.system_role, skill_count=0, agent_count=0, qa_count=0, approved=saved.approved, login_password=None, has_login_password=True, access_token=token, expires_in=expires_in, ) # ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── @router.get("/oauth/{provider}") async def oauth_login(provider: str): """Initiate OAuth login flow. Redirects to the OAuth provider's authorization URL. Currently a placeholder - requires OAuth provider implementation. """ _require_login_method("sso_enabled") if provider not in ["github", "google"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported OAuth provider: {provider}", ) raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login not yet implemented", ) @router.get("/callback/{provider}") async def oauth_callback(provider: str, code: str, state: str): """OAuth callback endpoint. Handles the OAuth provider's callback after user authorization. Currently a placeholder. """ _require_login_method("sso_enabled") raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback not yet implemented", )