diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..7394ce80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.dev +docs +packaging +scripts +**/node_modules +**/dist +**/.venv +**/__pycache__ +**/*.db +**/*.pyc +*.log diff --git a/backend/.env.example b/backend/.env.example index 77fdd1c4..4df232d8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,15 @@ STAFFDECK_ROLE="all" WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com" CHANNEL_DELIVERY_POLL_SECONDS="1.0" CHANNEL_DELIVERY_MAX_ATTEMPTS="8" +# OIDC 单点登录(默认关闭,配置 issuer 并 OIDC_ENABLED="true" 后生效) +OIDC_ENABLED="false" +OIDC_ISSUER="" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_SCOPES="openid profile email" +# OIDC_REDIRECT_URI 留空时按请求自动推导 +OIDC_REDIRECT_URI="" +OIDC_TENANT_ID="tenant_demo" +OIDC_DEFAULT_ROLE="member" +OIDC_AUTO_PROVISION="true" +OIDC_CLOCK_SKEW_SECONDS="120" diff --git a/backend/app/api/oidc_auth.py b/backend/app/api/oidc_auth.py new file mode 100644 index 00000000..e912d88c --- /dev/null +++ b/backend/app/api/oidc_auth.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import logging +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from sqlmodel import Session + +from app.db import get_session +from app.security.auth import create_access_token +from app.security.oidc import ( + OIDCLoginError, + OIDCNotConfigured, + OIDCStateError, + build_authorize_url, + complete_login, + oidc_display_name, + oidc_ready, + resolve_redirect_uri, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/auth/oidc", tags=["auth"]) + +# 回调成功后携带令牌落地到登录页;令牌放在 URL fragment(# 之后),不会进入 +# 服务器日志/浏览器历史外的任何记录,由前端读取后写入会话存储并清理 hash。 +_LOGIN_LANDING = "/login" + + +class OIDCConfig(BaseModel): + enabled: bool + name: str + + +@router.get("/config", response_model=OIDCConfig) +def oidc_config() -> OIDCConfig: + """登录页探测 SSO 可用性(公开)。""" + return OIDCConfig(enabled=oidc_ready(), name=oidc_display_name()) + + +@router.get("/authorize") +def oidc_authorize(request: Request, db: Session = Depends(get_session)) -> RedirectResponse: + """发起 PKCE 授权流:生成并持久化 state,302 跳转 IdP。""" + try: + url = build_authorize_url(db, resolve_redirect_uri(request)) + except OIDCNotConfigured: + return _error_redirect("OIDC 未启用或配置不完整") + except (OIDCLoginError, OIDCStateError) as exc: + logger.warning("OIDC authorize failed: %s", exc) + return _error_redirect("身份提供方配置异常,请联系管理员") + return RedirectResponse(url=url, status_code=302) + + +@router.get("/callback") +def oidc_callback( + request: Request, + code: str | None = Query(None), + state: str | None = Query(None), + db: Session = Depends(get_session), +) -> RedirectResponse: + """IdP 回调:换 token → 校验 ID token → 映射用户 → 签发 StaffDeck JWT。""" + if not code or not state: + return _error_redirect("回调缺少 code 或 state 参数") + try: + user = complete_login( + db, + authorization_response=str(request.url), + state=state, + redirect_uri=resolve_redirect_uri(request), + ) + except OIDCNotConfigured: + return _error_redirect("OIDC 未启用或配置不完整") + except OIDCStateError: + return _error_redirect("登录状态无效或已过期,请重新发起登录") + except OIDCLoginError as exc: + message = str(exc) if str(exc) else "登录失败,请联系管理员" + return _error_redirect(message) + except Exception: # 兜底:网络/解析等未预期异常不向用户泄露细节 + logger.exception("OIDC callback unexpected failure") + return _error_redirect("登录失败,请联系管理员") + + token = create_access_token(user) + return RedirectResponse(url=f"{_LOGIN_LANDING}#oidc_token={token}", status_code=302) + + +def _error_redirect(message: str) -> RedirectResponse: + params = urlencode({"oidc_error": message}) + return RedirectResponse(url=f"{_LOGIN_LANDING}?{params}", status_code=302) diff --git a/backend/app/config.py b/backend/app/config.py index 0ea9d72b..1b46c734 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,23 @@ class Settings(BaseSettings): # 否则常量失效或权限未开时,每条入站消息都会留下一条失败的 reaction 投递。 channel_dingtalk_reaction_enabled: bool = False + # OIDC (OpenID Connect) 单点登录。未配置 issuer 时视为关闭,登录页不显示 SSO 入口。 + oidc_enabled: bool = False + oidc_issuer: str = "" + oidc_name: str = "" # 登录页 SSO 按钮展示名;留空时回退为 issuer 主机名 + oidc_client_id: str = "" + oidc_client_secret: str = "" + oidc_scopes: str = "openid profile email" + # 留空时按请求 base_url 自动推导(https://host/api/auth/oidc/callback), + # 生产建议显式配置,避免反向代理场景下 base_url 推导偏差。 + oidc_redirect_uri: str = "" + # OIDC 用户归属租户与默认角色;首次登录自动建号(可关闭自动建号仅允许已有账号绑定)。 + oidc_tenant_id: str = "tenant_demo" + oidc_default_role: str = "member" + oidc_auto_provision: bool = True + # ID token 校验时钟偏移容忍秒数 + oidc_clock_skew_seconds: int = 120 + model_config = SettingsConfigDict( env_file=_os.environ.get("ULTRARAG_DOTENV", ".env"), env_file_encoding="utf-8", extra="ignore", diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 55dfe5c2..1841b7f5 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -109,7 +109,16 @@ def _migrate_sqlite_skill_schema() -> None: conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR NOT NULL DEFAULT 'member'")) if "source" not in user_columns: conn.execute(text("ALTER TABLE users ADD COLUMN source VARCHAR NOT NULL DEFAULT 'web'")) + if "oidc_sub" not in user_columns: + conn.execute(text("ALTER TABLE users ADD COLUMN oidc_sub VARCHAR")) _migrate_user_source_backfill(conn) + # OIDC 用户按 sub 稳定映射;partial unique index 保证普通账号不受约束 + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_user_oidc_sub " + "ON users(tenant_id, oidc_sub) WHERE oidc_sub IS NOT NULL" + ) + ) if "sessions" in tables: session_columns = {column["name"] for column in inspector.get_columns("sessions")} diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 4aff05c0..439d5694 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -36,11 +36,28 @@ class User(SQLModel, table=True): role: str = Field(default="member", index=True) # 账号来源:web=网页端创建;wechat 等=渠道懒建(用户管理列表默认隐藏) source: str = Field(default="web", index=True) + # OIDC 唯一标识(sub claim):同一身份提供方下稳定不变,用于 SSO 用户映射; + # 空值表示非 OIDC 账号,unique index 为 partial(WHERE oidc_sub IS NOT NULL) + oidc_sub: Optional[str] = None password_hash: str created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) +class OIDCAuthState(SQLModel, table=True): + """OIDC 授权流服务端状态:authorize 生成的 state 与 PKCE verifier/nonce, + 回调时一次性消费(校验后删除)。独立小表,create_all 自动建表,无需 ALTER。 + """ + + __tablename__ = "oidc_auth_states" + + state: str = Field(primary_key=True) + code_verifier: str + nonce: str + created_at: datetime = Field(default_factory=utc_now) + expires_at: datetime + + class UserAvatar(SQLModel, table=True): """用户头像:小图以 data_url 直接存库(与聊天附件内联方式一致), diff --git a/backend/app/main.py b/backend/app/main.py index 45baffc6..785c7da7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ memories, mock, model_configs, + oidc_auth, persona, scheduled_tasks, sessions, @@ -75,6 +76,7 @@ def health() -> dict[str, str]: app.include_router(agents.chat_router) app.include_router(ui_config.chat_router) app.include_router(auth.router) +app.include_router(oidc_auth.router) app.include_router(agents.scope_router) app.include_router(agents.enterprise_router) app.include_router(general_skills.router) diff --git a/backend/app/security/oidc.py b/backend/app/security/oidc.py new file mode 100644 index 00000000..eb005c49 --- /dev/null +++ b/backend/app/security/oidc.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +import time +from datetime import timedelta +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx +from authlib.common.security import generate_token +from authlib.oidc.core import CodeIDToken +from joserfc import jwt as jose_jwt +from joserfc.jwk import KeySet +from sqlalchemy import delete +from sqlmodel import Session, select + +from app.config import Settings, get_settings +from app.db.models import OIDCAuthState, User, utc_now +from app.security.auth import hash_password + +logger = logging.getLogger(__name__) + +# 授权流状态有效期:用户从点击 SSO 到回调完成不应超过 10 分钟 +_STATE_TTL_SECONDS = 600 +# discovery 文档缓存时长(IdP 元数据极少变化,按小时缓存即可) +_DISCOVERY_CACHE_TTL_SECONDS = 3600 +# 对 IdP 的 HTTP 请求超时(秒) +_IDP_TIMEOUT_SECONDS = 30 + + +class OIDCNotConfigured(Exception): + """OIDC 未启用或配置不完整。""" + + +class OIDCStateError(Exception): + """授权 state 无效、过期或已使用。""" + + +class OIDCLoginError(Exception): + """令牌校验或用户映射失败。""" + + +def oidc_ready(settings: Settings | None = None) -> bool: + """OIDC 是否已启用且配置完整(issuer + client_id 为硬性前提)。""" + settings = settings or get_settings() + return bool( + settings.oidc_enabled + and settings.oidc_issuer.strip() + and settings.oidc_client_id.strip() + ) + + +def oidc_display_name(settings: Settings | None = None) -> str: + """登录页 SSO 按钮展示名:优先 OIDC_NAME 配置,回退为 issuer 主机名。""" + settings = settings or get_settings() + if configured := settings.oidc_name.strip(): + return configured + issuer = settings.oidc_issuer.strip() + host = issuer.split("://", 1)[-1].split("/", 1)[0] if issuer else "" + return host or "SSO" + + +def _scope_list(settings: Settings) -> list[str]: + scopes = [s.strip() for s in settings.oidc_scopes.split(",") if s.strip()] + return scopes or ["openid", "profile", "email"] + + +_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + +def clear_oidc_cache() -> None: + """清空 discovery 缓存(测试用)。""" + _discovery_cache.clear() + + +def _discovery(settings: Settings) -> dict[str, Any]: + now = time.monotonic() + cached = _discovery_cache.get(settings.oidc_issuer) + if cached and now - cached[0] < _DISCOVERY_CACHE_TTL_SECONDS: + return cached[1] + discovery_url = settings.oidc_issuer.rstrip("/") + "/.well-known/openid-configuration" + try: + resp = httpx.get(discovery_url, timeout=_IDP_TIMEOUT_SECONDS) + resp.raise_for_status() + metadata = resp.json() + except Exception as exc: + raise OIDCLoginError("Failed to load IdP discovery document") from exc + _discovery_cache[settings.oidc_issuer] = (now, metadata) + return metadata + + +def resolve_redirect_uri(request, settings: Settings | None = None) -> str: + """回调地址:显式配置优先(应为完整 URL),否则按请求 base_url 推导。 + + 推导结果固定指向本服务自身的 /api/auth/oidc/callback 路径,不接受任何 + 外部输入,杜绝 open redirect。 + """ + settings = settings or get_settings() + configured = settings.oidc_redirect_uri.strip() + if configured: + return configured + base = str(request.base_url).rstrip("/") + return f"{base}/api/auth/oidc/callback" + + +def _pkce_challenge(code_verifier: str) -> str: + """S256 code_challenge = urlsafe_base64(sha256(verifier)) 去 padding。""" + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") + + +def build_authorize_url(db: Session, redirect_uri: str, settings: Settings | None = None) -> str: + """生成 PKCE 授权流:落库 state/verifier/nonce,返回 IdP 授权跳转 URL。""" + settings = settings or get_settings() + if not oidc_ready(settings): + raise OIDCNotConfigured("OIDC is not configured") + metadata = _discovery(settings) + authorization_endpoint = metadata.get("authorization_endpoint") + if not authorization_endpoint: + raise OIDCLoginError("IdP discovery missing authorization_endpoint") + + state = generate_token() + code_verifier = generate_token(48) + nonce = generate_token() + params = { + "response_type": "code", + "client_id": settings.oidc_client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(_scope_list(settings)), + "state": state, + "nonce": nonce, + "code_challenge": _pkce_challenge(code_verifier), + "code_challenge_method": "S256", + } + separator = "&" if "?" in authorization_endpoint else "?" + url = f"{authorization_endpoint}{separator}{urlencode(params)}" + + now = utc_now() + # 惰性清理:顺带删除过期 state,避免小表无限增长 + db.exec(delete(OIDCAuthState).where(OIDCAuthState.expires_at < now)) + db.add( + OIDCAuthState( + state=state, + code_verifier=code_verifier, + nonce=nonce, + created_at=now, + expires_at=now + timedelta(seconds=_STATE_TTL_SECONDS), + ) + ) + db.commit() + return url + + +def complete_login( + db: Session, + authorization_response: str, + state: str, + redirect_uri: str, + settings: Settings | None = None, +) -> User: + """消费授权回调:校验 state → 换 token → 校验 ID token → 映射/创建用户。""" + settings = settings or get_settings() + if not oidc_ready(settings): + raise OIDCNotConfigured("OIDC is not configured") + + auth_state = db.get(OIDCAuthState, state) + if not auth_state: + raise OIDCStateError("Invalid or expired state") + # 一次性消费:先删后校验,天然防重放(重放时 state 已不存在) + db.delete(auth_state) + db.commit() + if auth_state.expires_at < utc_now(): + raise OIDCStateError("State expired") + + metadata = _discovery(settings) + token_endpoint = metadata.get("token_endpoint") + if not token_endpoint: + raise OIDCLoginError("IdP discovery missing token_endpoint") + + token = _exchange_token( + settings, token_endpoint, authorization_response, auth_state.code_verifier, redirect_uri + ) + claims = _validate_id_token(settings, metadata, token, auth_state.nonce) + claims = _enrich_claims_with_userinfo(settings, metadata, token, claims) + return _resolve_user(db, claims, settings) + + +def _exchange_token( + settings: Settings, + token_endpoint: str, + authorization_response: str, + code_verifier: str, + redirect_uri: str, +) -> dict[str, Any]: + """授权码换令牌:client_secret_basic 客户端认证 + PKCE verifier。""" + try: + resp = httpx.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": _extract_code(authorization_response), + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + auth=(settings.oidc_client_id, settings.oidc_client_secret or ""), + timeout=_IDP_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + token = resp.json() + except Exception as exc: + logger.warning("OIDC token exchange failed: %s", exc) + raise OIDCLoginError("Token exchange failed") from exc + if not token.get("id_token"): + raise OIDCLoginError("Token response missing id_token") + return token + + +def _extract_code(authorization_response: str) -> str: + """从完整回调 URL 提取授权码;提取失败抛错(不向用户泄露响应细节)。""" + code = parse_qs(urlparse(authorization_response).query).get("code", [""])[0] + if not code: + raise OIDCLoginError("Authorization response missing code") + return code + + +def _validate_id_token( + settings: Settings, + metadata: dict[str, Any], + token: dict[str, Any], + nonce: str, +) -> dict[str, Any]: + """校验 ID token:签名(JWKS) + iss/aud/exp/iat/nonce。 + + 实现与 authlib 官方 async 客户端 parse_id_token 同构: + joserfc 解码签名,CodeIDToken 校验标准 claims。 + """ + jwks_uri = metadata.get("jwks_uri") + if not jwks_uri: + raise OIDCLoginError("IdP discovery missing jwks_uri") + try: + resp = httpx.get(jwks_uri, timeout=_IDP_TIMEOUT_SECONDS) + resp.raise_for_status() + key_set = KeySet.import_key_set(resp.json()) + except Exception as exc: + logger.warning("OIDC JWKS fetch failed: %s", exc) + raise OIDCLoginError("Failed to load IdP signing keys") from exc + + algorithms = metadata.get("id_token_signing_alg_values_supported") or ["RS256"] + try: + decoded = jose_jwt.decode( + token["id_token"], + key=key_set, + algorithms=algorithms, + ) + except Exception as exc: + logger.warning("OIDC id_token signature verification failed: %s", exc) + raise OIDCLoginError("Invalid id_token") from exc + + claims_options = {"iss": {"values": [metadata["issuer"]]}} + claims = CodeIDToken( + decoded.claims, + decoded.header, + claims_options, + { + "nonce": nonce, + "client_id": settings.oidc_client_id, + "access_token": token.get("access_token", ""), + }, + ) + try: + claims.validate(leeway=settings.oidc_clock_skew_seconds) + except Exception as exc: + logger.warning("OIDC id_token claims validation failed: %s", exc) + raise OIDCLoginError("Invalid id_token") from exc + return dict(claims) + + +def _enrich_claims_with_userinfo( + settings: Settings, + metadata: dict[str, Any], + token: dict[str, Any], + claims: dict[str, Any], +) -> dict[str, Any]: + """ID token claims 基础上补充 userinfo(部分 IdP 不在 ID token 中带 email)。""" + userinfo_endpoint = metadata.get("userinfo_endpoint") + if not userinfo_endpoint or not token.get("access_token"): + return claims + try: + resp = httpx.get( + userinfo_endpoint, + headers={"Authorization": f"Bearer {token['access_token']}"}, + timeout=_IDP_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + userinfo = resp.json() + except Exception as exc: + # userinfo 失败不阻断登录:ID token 已通过签名与 claims 校验 + logger.warning("OIDC userinfo fetch failed (continuing with id_token): %s", exc) + return claims + merged = dict(claims) + for key in ("sub", "preferred_username", "email", "name"): + if userinfo.get(key) and not merged.get(key): + merged[key] = userinfo[key] + return merged + + +def _resolve_user(db: Session, claims: dict[str, Any], settings: Settings) -> User: + sub = str(claims.get("sub") or "").strip() + if not sub: + raise OIDCLoginError("ID token missing sub claim") + tenant_id = settings.oidc_tenant_id.strip() or "tenant_demo" + + user = db.exec( + select(User).where(User.tenant_id == tenant_id, User.oidc_sub == sub) + ).first() + if user: + return user + + if not settings.oidc_auto_provision: + raise OIDCLoginError( + "OIDC 账号尚未绑定 StaffDeck 账号,请联系管理员创建账号后重试" + ) + + username = _unique_username(db, tenant_id, claims, sub) + display_name = str(claims.get("name") or claims.get("preferred_username") or username)[:80] + # OIDC 账号不设可用密码:随机口令仅满足模型非空约束,无法用于密码登录 + user = User( + tenant_id=tenant_id, + username=username, + display_name=display_name, + role=settings.oidc_default_role, + source="oidc", + oidc_sub=sub, + password_hash=hash_password(secrets.token_urlsafe(32)), + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Auto-provisioned OIDC user %s (tenant=%s sub=%s)", user.id, tenant_id, sub) + return user + + +def _unique_username(db: Session, tenant_id: str, claims: dict[str, Any], sub: str) -> str: + preferred = str( + claims.get("preferred_username") + or (str(claims.get("email") or "").split("@", 1)[0]) + or f"oidc_{hashlib.sha256(sub.encode('utf-8')).hexdigest()[:12]}" + ) + preferred = "".join(ch for ch in preferred if ch.isalnum() or ch in "._-")[:40] or "oidc_user" + candidate = preferred + suffix = 2 + while db.exec( + select(User.id).where(User.tenant_id == tenant_id, User.username == candidate) + ).first(): + candidate = f"{preferred[:36]}_{suffix}" + suffix += 1 + return candidate diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 79612ae2..66365f7f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "FastAPI MVP for an enterprise Skill Agent Loop service" requires-python = ">=3.11" dependencies = [ "anthropic>=0.117.0,<0.121.0", + "authlib>=1.3.0", # cryptography 49 macOS wheels dynamically link their bundled OpenSSL. PyInstaller # can then select Python's older libssl.3.dylib with the same basename, producing # an app that fails at startup with a missing SSL_get0_group_name symbol. diff --git a/backend/tests/test_oidc_auth.py b/backend/tests/test_oidc_auth.py new file mode 100644 index 00000000..eaaf775d --- /dev/null +++ b/backend/tests/test_oidc_auth.py @@ -0,0 +1,365 @@ +"""OIDC 集成测试:内嵌一个最小 RS256 mock IdP(discovery/jwks/token/userinfo), + +覆盖完整授权码 + PKCE 流程:authorize 302 → 回调换 token → ID token 校验 → +用户自动创建/稳定映射 → StaffDeck JWT 签发,以及 state 失效/重放等安全路径。 +""" + +from __future__ import annotations + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI +from fastapi.testclient import TestClient +from joserfc.jwk import RSAKey +from joserfc.jwt import encode as jwt_encode +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +import app.api.auth as auth_api +import app.api.oidc_auth as oidc_api +from app.config import get_settings +from app.db import get_session +from app.db.models import Tenant, User +from app.security.oidc import clear_oidc_cache + +CLIENT_ID = "test-client" +CLIENT_SECRET = "test-secret" + + +class MockIdP: + """线程内运行的极简 OIDC 身份提供方。""" + + def __init__(self) -> None: + self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + self.kid = "mock-key-1" + self._codes: dict[str, dict] = {} # code -> claims + self._nonce_by_code: dict[str, str] = {} + + server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(self)) + self.server = server + self.issuer = f"http://127.0.0.1:{server.server_address[1]}" + self.thread = threading.Thread(target=server.serve_forever, daemon=True) + + def __enter__(self) -> "MockIdP": + self.thread.start() + return self + + def __exit__(self, *exc) -> None: + self.server.shutdown() + self.server.server_close() + + @property + def discovery_url(self) -> str: + return f"{self.issuer}/.well-known/openid-configuration" + + def _sign_id_token(self, claims: dict) -> str: + pem = self.private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + key = RSAKey.import_key(pem, {"kid": self.kid, "use": "sig", "alg": "RS256"}) + return jwt_encode({"alg": "RS256", "kid": self.kid}, claims, key) + + def _jwks(self) -> dict: + pem = self.private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + jwk = RSAKey.import_key(pem).as_dict() + return {"keys": [{**jwk, "kid": self.kid, "use": "sig", "alg": "RS256"}]} + + def _discovery(self) -> dict: + return { + "issuer": self.issuer, + "authorization_endpoint": f"{self.issuer}/authorize", + "token_endpoint": f"{self.issuer}/token", + "userinfo_endpoint": f"{self.issuer}/userinfo", + "jwks_uri": f"{self.issuer}/jwks", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "code_challenge_methods_supported": ["S256"], + } + + def issue_code(self, nonce: str, sub: str, preferred_username: str, display_name: str) -> str: + """按 IDP 侧语义生成一次性授权码(测试直接调用,模拟用户完成 IdP 侧认证)。""" + code = f"mock-code-{len(self._codes) + 1}" + now = int(time.time()) + self._codes[code] = { + "iss": self.issuer, + "sub": sub, + "aud": CLIENT_ID, + "exp": now + 3600, + "iat": now, + "nonce": nonce, + "preferred_username": preferred_username, + "email": f"{preferred_username}@example.com", + "name": display_name, + } + self._nonce_by_code[code] = nonce + return code + + def handle(self, path: str, method: str, body: bytes | None, auth_header: str | None): + if path == "/.well-known/openid-configuration" and method == "GET": + return 200, self._discovery() + if path == "/jwks" and method == "GET": + return 200, self._jwks() + if path == "/userinfo" and method == "GET": + token = (auth_header or "").removeprefix("Bearer ").strip() + code = token.removeprefix("mock-access-") + claims = self._codes.get(code) + if not claims: + return 401, {"error": "invalid_token"} + return 200, { + "sub": claims["sub"], + "preferred_username": claims["preferred_username"], + "email": claims["email"], + "name": claims["name"], + } + if path == "/token" and method == "POST": + params = parse_qs(body.decode("utf-8")) if body else {} + code = params.get("code", [""])[0] + claims = self._codes.get(code) + if not claims: + return 400, {"error": "invalid_grant"} + return 200, { + "access_token": f"mock-access-{code}", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": self._sign_id_token(claims), + } + return 404, {"error": "not_found"} + + +def _make_handler(idp: MockIdP): + class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A002 - 静默标准库日志 + pass + + def _respond(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + status, payload = idp.handle( + urlparse(self.path).path, + self.command, + body, + self.headers.get("Authorization"), + ) + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + self._respond() + + def do_POST(self): + self._respond() + + return Handler + + +@pytest.fixture() +def oidc_env(monkeypatch): + """启用 OIDC 配置并指向 mock IdP;测试结束清理 settings/discovery 缓存。""" + with MockIdP() as idp: + monkeypatch.setenv("OIDC_ENABLED", "true") + monkeypatch.setenv("OIDC_ISSUER", idp.issuer) + monkeypatch.setenv("OIDC_CLIENT_ID", CLIENT_ID) + monkeypatch.setenv("OIDC_CLIENT_SECRET", CLIENT_SECRET) + monkeypatch.setenv("OIDC_TENANT_ID", "tenant_demo") + monkeypatch.setenv("OIDC_DEFAULT_ROLE", "member") + monkeypatch.setenv("OIDC_AUTO_PROVISION", "true") + get_settings.cache_clear() + clear_oidc_cache() + yield idp + get_settings.cache_clear() + clear_oidc_cache() + + +@pytest.fixture() +def client(oidc_env): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.commit() + + app = FastAPI() + app.include_router(auth_api.router) + app.include_router(oidc_api.router) + + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + return TestClient(app), engine + + +def _parse_authorize_location(location: str) -> tuple[str, str, str]: + """从 IdP 授权 URL 提取 state/nonce/code_challenge。""" + params = parse_qs(urlparse(location).query) + return params["state"][0], params["nonce"][0], params["code_challenge"][0] + + +def _extract_oidc_token(location: str) -> str: + assert location.startswith("/login#oidc_token="), location + return location.removeprefix("/login#oidc_token=") + + +def _run_full_login(client: TestClient, idp: MockIdP, sub: str, username: str, name: str) -> str: + """完整 SSO 流程:authorize → 模拟 IdP 认证签发 code → callback → 返回 StaffDeck JWT。""" + resp = client.get("/api/auth/oidc/authorize", follow_redirects=False) + assert resp.status_code == 302 + state, nonce, challenge = _parse_authorize_location(resp.headers["location"]) + assert challenge, "PKCE S256 challenge 应存在" + code = idp.issue_code(nonce, sub, username, name) + resp = client.get(f"/api/auth/oidc/callback?code={code}&state={state}", follow_redirects=False) + assert resp.status_code == 302, resp.text + return _extract_oidc_token(resp.headers["location"]) + + +def test_config_disabled_by_default(monkeypatch): + monkeypatch.delenv("OIDC_ENABLED", raising=False) + monkeypatch.delenv("OIDC_ISSUER", raising=False) + get_settings.cache_clear() + clear_oidc_cache() + app = FastAPI() + app.include_router(oidc_api.router) + resp = TestClient(app).get("/api/auth/oidc/config") + assert resp.status_code == 200 + assert resp.json() == {"enabled": False, "name": "SSO"} + get_settings.cache_clear() + + +def test_config_enabled(oidc_env, client): + test_client, _ = client + resp = test_client.get("/api/auth/oidc/config") + assert resp.status_code == 200 + payload = resp.json() + assert payload["enabled"] is True + assert payload["name"] == f"127.0.0.1:{urlparse(oidc_env.issuer).port}" + + +def test_authorize_redirects_to_idp(oidc_env, client): + test_client, _ = client + resp = test_client.get("/api/auth/oidc/authorize", follow_redirects=False) + assert resp.status_code == 302 + location = resp.headers["location"] + assert location.startswith(f"{oidc_env.issuer}/authorize?") + params = parse_qs(urlparse(location).query) + assert params["client_id"][0] == CLIENT_ID + assert params["response_type"][0] == "code" + assert params["scope"][0] == "openid profile email" + assert params["code_challenge_method"][0] == "S256" + assert params["redirect_uri"][0] == "http://testserver/api/auth/oidc/callback" + + +def test_full_login_flow_auto_provisions_user(oidc_env, client): + test_client, engine = client + token = _run_full_login(test_client, oidc_env, sub="sub-1", username="alice", name="Alice") + + me = test_client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200, me.text + user = me.json() + assert user["username"] == "alice" + assert user["display_name"] == "Alice" + assert user["role"] == "member" + assert user["source"] == "oidc" + assert user["tenant_id"] == "tenant_demo" + + with Session(engine) as db: + rows = db.exec(select(User)).all() + assert len(rows) == 1 + assert rows[0].oidc_sub == "sub-1" + assert rows[0].username == "alice" + + +def test_same_sub_maps_to_same_user(oidc_env, client): + test_client, engine = client + first = _run_full_login(test_client, oidc_env, sub="sub-2", username="bob", name="Bob") + second = _run_full_login(test_client, oidc_env, sub="sub-2", username="bob", name="Bob") + + me1 = test_client.get("/api/auth/me", headers={"Authorization": f"Bearer {first}"}).json() + me2 = test_client.get("/api/auth/me", headers={"Authorization": f"Bearer {second}"}).json() + assert me1["id"] == me2["id"] + with Session(engine) as db: + rows = db.exec(select(User)).all() + assert len(rows) == 1 + + +def test_username_collision_gets_suffix(oidc_env, client): + test_client, engine = client + # 预置一个同 usernam 的 web 账号,触发自动建号时加后缀 + with Session(engine) as db: + db.add( + User(id="user_existing", tenant_id="tenant_demo", username="alice", password_hash="x") + ) + db.commit() + + _run_full_login(test_client, oidc_env, sub="sub-3", username="alice", name="Alice") + with Session(engine) as db: + rows = db.exec(select(User).where(User.source == "oidc")).all() + assert len(rows) == 1 + assert rows[0].username == "alice_2" + assert rows[0].oidc_sub == "sub-3" + + +def test_invalid_state_redirects_to_error(oidc_env, client): + test_client, _ = client + resp = test_client.get( + "/api/auth/oidc/callback?code=mock-code-9&state=forged-state", + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith("/login?oidc_error=") + + +def test_state_replay_is_rejected(oidc_env, client): + test_client, _ = client + resp = test_client.get("/api/auth/oidc/authorize", follow_redirects=False) + state, nonce, _ = _parse_authorize_location(resp.headers["location"]) + code = oidc_env.issue_code(nonce, "sub-4", "carol", "Carol") + + first = test_client.get( + f"/api/auth/oidc/callback?code={code}&state={state}", follow_redirects=False + ) + assert first.status_code == 302 + assert first.headers["location"].startswith("/login#oidc_token=") + + # state 已一次性消费:重放必须被拒绝 + replay = test_client.get( + f"/api/auth/oidc/callback?code={code}&state={state}", follow_redirects=False + ) + assert replay.status_code == 302 + assert replay.headers["location"].startswith("/login?oidc_error=") + + +def test_auto_provision_disabled_rejects_unknown_user(oidc_env, client, monkeypatch): + monkeypatch.setenv("OIDC_AUTO_PROVISION", "false") + get_settings.cache_clear() + clear_oidc_cache() + test_client, _ = client + resp = test_client.get("/api/auth/oidc/authorize", follow_redirects=False) + state, nonce, _ = _parse_authorize_location(resp.headers["location"]) + code = oidc_env.issue_code(nonce, "sub-5", "dave", "Dave") + resp = test_client.get( + f"/api/auth/oidc/callback?code={code}&state={state}", follow_redirects=False + ) + assert resp.status_code == 302 + assert resp.headers["location"].startswith("/login?oidc_error=") diff --git a/frontend-enterprise/src/oidcCallback.test.ts b/frontend-enterprise/src/oidcCallback.test.ts new file mode 100644 index 00000000..c4e1fe26 --- /dev/null +++ b/frontend-enterprise/src/oidcCallback.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { consumeOidcCallback } from './oidcCallback'; + +describe('consumeOidcCallback', () => { + it('extracts oidc_token from the hash fragment and cleans the URL', () => { + const result = consumeOidcCallback('/login#oidc_token=abc.def.ghi'); + expect(result.token).toBe('abc.def.ghi'); + expect(result.error).toBeNull(); + expect(result.cleanUrl).toBe('/login'); + }); + + it('keeps unrelated hash parameters', () => { + const result = consumeOidcCallback('/login#oidc_token=tok&theme=dark'); + expect(result.token).toBe('tok'); + expect(result.cleanUrl).toBe('/login#theme=dark'); + }); + + it('extracts oidc_error from the query string and cleans the URL', () => { + const result = consumeOidcCallback('/login?oidc_error=%E5%A4%B1%E8%B4%A5'); + expect(result.token).toBeNull(); + expect(result.error).toBe('失败'); + expect(result.cleanUrl).toBe('/login'); + }); + + it('keeps unrelated query parameters', () => { + const result = consumeOidcCallback('/login?redirect=%2Fworkspace&oidc_error=bad'); + expect(result.error).toBe('bad'); + expect(result.cleanUrl).toBe('/login?redirect=%2Fworkspace'); + }); + + it('returns empty result for a plain login URL', () => { + const result = consumeOidcCallback('/login'); + expect(result.token).toBeNull(); + expect(result.error).toBeNull(); + expect(result.cleanUrl).toBe('/login'); + }); +}); diff --git a/frontend-enterprise/src/oidcCallback.ts b/frontend-enterprise/src/oidcCallback.ts new file mode 100644 index 00000000..a32fd628 --- /dev/null +++ b/frontend-enterprise/src/oidcCallback.ts @@ -0,0 +1,29 @@ +export type OidcCallbackResult = { + token: string | null; + error: string | null; + cleanUrl: string; +}; + +/** + * 解析登录页 URL 中的 OIDC 回调结果并计算清理后的 URL。 + * + * 后端在 SSO 成功后 302 到 `/login#oidc_token=`(令牌放 fragment, + * 不进服务器日志);失败时 302 到 `/login?oidc_error=<消息>`。 + * 令牌/错误被消费后应通过 history.replaceState 落到 cleanUrl,避免刷新重放。 + */ +export function consumeOidcCallback(href: string): OidcCallbackResult { + const url = new URL(href, window.location.origin); + + const hashParams = new URLSearchParams(url.hash.replace(/^#/, '')); + const token = hashParams.get('oidc_token') || null; + hashParams.delete('oidc_token'); + const nextHash = hashParams.toString(); + + const searchParams = new URLSearchParams(url.search); + const error = searchParams.get('oidc_error') || null; + searchParams.delete('oidc_error'); + const nextSearch = searchParams.toString(); + + const cleanUrl = `${url.pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash ? `#${nextHash}` : ''}`; + return { token, error, cleanUrl }; +} diff --git a/frontend-enterprise/src/pages/LoginPage.tsx b/frontend-enterprise/src/pages/LoginPage.tsx index af95c30a..9e650111 100644 --- a/frontend-enterprise/src/pages/LoginPage.tsx +++ b/frontend-enterprise/src/pages/LoginPage.tsx @@ -1,9 +1,14 @@ -import { useState, type KeyboardEvent } from 'react'; +import { useEffect, useState, type KeyboardEvent } from 'react'; import { api, TENANT_ID } from '../api/client'; -import { setEnterpriseAuthSession, type EnterpriseAuthSession } from '../auth'; +import { + clearEnterpriseAuthSession, + setEnterpriseAuthSession, + type EnterpriseAuthSession, +} from '../auth'; import AppHeader from '../components/AppHeader'; import BrandLogo from '../components/BrandLogo'; +import { consumeOidcCallback } from '../oidcCallback'; import IconFieldClear from '../assets/icons/field-clear.svg?react'; import IconFieldEye from '../assets/icons/field-eye.svg?react'; import IconFieldEyeOn from '../assets/icons/field-eye-on.svg?react'; @@ -27,6 +32,67 @@ export default function LoginPage({ onLogin }: LoginPageProps) { const [usernameError, setUsernameError] = useState(''); const [passwordError, setPasswordError] = useState(''); const [loading, setLoading] = useState(false); + const [oidcEnabled, setOidcEnabled] = useState(false); + const [oidcName, setOidcName] = useState(''); + const [oidcError, setOidcError] = useState(''); + + // 挂载时处理 SSO 回调:消费 URL 中的 oidc_token/oidc_error 并清理 URL,防止刷新重放 + useEffect(() => { + const { token, error, cleanUrl } = consumeOidcCallback(window.location.href); + if (cleanUrl !== window.location.pathname + window.location.search + window.location.hash) { + window.history.replaceState(null, '', cleanUrl); + } + if (token) { + void completeOidcLogin(token); + } else if (error) { + setOidcError(error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 探测后端是否启用 OIDC;未启用时不展示 SSO 入口 + useEffect(() => { + api + .get<{ enabled: boolean; name: string }>('/api/auth/oidc/config') + .then((config) => { + setOidcEnabled(config.enabled); + setOidcName(config.name); + }) + .catch(() => { + // 探测失败按未启用处理,不打扰正常密码登录 + }); + }, []); + + async function completeOidcLogin(token: string) { + try { + // 先落最小会话以便 /me 请求携带 Authorization 头,再以完整用户信息重建会话。 + // 注意 user.id 必须非空:auth.readStoredSession 以 user.id 判断会话有效性, + // 空串会导致 getEnterpriseAuthSession() 返回 null,/me 不带令牌而 401。 + setEnterpriseAuthSession({ + token, + user: { id: 'oidc-pending', tenant_id: '', username: '', role: 'member' }, + }); + const user = await api.get<{ + id: string; + tenant_id: string; + username: string; + display_name?: string; + role: 'admin' | 'member'; + avatar_url?: string; + }>('/api/auth/me'); + const session: EnterpriseAuthSession = { token, user }; + setEnterpriseAuthSession(session); + onLogin(session); + } catch { + // 令牌无效或 /me 失败:清理最小会话,避免残留占位会话误导登录守卫 + clearEnterpriseAuthSession(); + setOidcError('SSO 登录失败,令牌无效或已过期,请重新发起登录'); + } + } + + function startOidcLogin() { + window.location.href = '/api/auth/oidc/authorize'; + } async function login() { const trimmedUsername = username.trim(); @@ -79,14 +145,29 @@ export default function LoginPage({ onLogin }: LoginPageProps) { 数字员工运营平台 + {oidcError && ( +

{oidcError}

+ )} + {!showForm ? ( - + <> + + {oidcEnabled && ( + + )} + ) : (
{loading ? '登录中…' : '登录'} + {oidcEnabled && ( + + )}
)} diff --git a/frontend-enterprise/vite.config.ts b/frontend-enterprise/vite.config.ts index 8b9ac3f0..879e651d 100644 --- a/frontend-enterprise/vite.config.ts +++ b/frontend-enterprise/vite.config.ts @@ -27,8 +27,9 @@ export default defineConfig(({ mode }) => { port: 5173, proxy: { '/api': { + // keep the original Host header: OIDC redirect_uri is derived from it + // (changeOrigin would rewrite Host to the proxy target and break SSO) target: env.VITE_PROXY_TARGET || 'http://localhost:8000', - changeOrigin: true, }, }, },