diff --git a/.env.example b/.env.example index d84effc..36c2ce4 100644 --- a/.env.example +++ b/.env.example @@ -31,15 +31,22 @@ APS_MODE=web APS_PORT=8000 APS_WORLD_PATH=server/data/world.json -# ---- 用户认证与租户隔离 ---- -# unconfigured:认证接口未接入,所有业务 API 默认拒绝访问 -# mock:仅本地开发;生产环境禁止使用 -APS_AUTH_PROVIDER=unconfigured +# ---- 用户认证与租户隔离(JMS) ---- +# jms:调用真实 JMS 登录接口;unconfigured:关闭 Web 认证和业务 API +APS_AUTH_PROVIDER=jms APS_AUTH_COOKIE_SECURE=0 APS_AUTH_TTL_SECONDS=28800 -# Mock 模式下的固定租户;可通过 APS_MOCK_USERS JSON 配置测试成员 -APS_MOCK_TENANT_UUID=demo0000000000000000000000000001 -# APS_MOCK_USERS=[{"id":1001,"username":"planner","fullname":"计划员","mobile":"13800000001"}] +JMS_AUTH_BASE_URL=https://jms-mes-api.jzplat.com +# 可选:固定为单一租户;留空时登录页从 JMS 公开租户接口加载企业列表 +JMS_AUTH_TENANT_CODE= +JMS_AUTH_TENANT_NAME= +# 至少 32 个随机字符,只用于签名本系统的 HttpOnly 会话,不发送给 JMS +JMS_AUTH_SESSION_SECRET= +# 可选:固定 JMS 登录响应中的 tokenName;配置后会严格校验 +# JMS_AUTH_TOKEN_NAME= +JMS_AUTH_TIMEOUT_SECONDS=10 +# 可选:企业自己的账号申请页面;未配置时提示联系企业管理员开通 +# JMS_AUTH_ACCOUNT_REQUEST_URL=https://example.com/account-request # ---- 桌面客户端授权 ---- # Web 用户认证与桌面授权相互独立。正式环境接入授权中心前保持 unconfigured(默认拒绝)。 diff --git a/README.md b/README.md index 5e7b43e..df53091 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,20 @@ npm run bootstrap # 安装 apps/web + apps/desktop 依赖 ### 2. 开发运行 -本地联调用户与项目分享功能前,在 `.env` 中显式设置: +联调 Web 用户登录前,在 `.env` 中设置 JMS 地址与本地会话密钥: ```bash -APS_AUTH_PROVIDER=mock +APS_AUTH_PROVIDER=jms +JMS_AUTH_BASE_URL=https://jms-mes-api.jzplat.com +JMS_AUTH_SESSION_SECRET=至少32个随机字符 APS_LICENSE_PROVIDER=mock -APS_MOCK_TENANT_UUID=demo0000000000000000000000000001 ``` -默认 `APS_AUTH_PROVIDER=unconfigured` 会关闭全部业务 API,直到正式用户管理接口完成配置。 -Web 开发账号可使用 `planner` / `collaborator` / `viewer`(密码任意非空);桌面端不使用账号, -开发授权码为 `APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、 +Web 端会从 JMS 读取启用企业,用户选择所属企业后使用真实账号密码登录;如需限制为单一企业, +可配置 `JMS_AUTH_TENANT_CODE` 与 `JMS_AUTH_TENANT_NAME`。JMS 当前没有自助注册或未登录自助 +找回密码接口,账号创建与密码重置由所属企业管理员处理。 +桌面端不使用账号,开发授权码为 +`APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、 `APS-YEAR-DEMO`、`APS-PERP-DEMO`。 ```bash diff --git a/apps/web/src/auth/AuthProvider.tsx b/apps/web/src/auth/AuthProvider.tsx index d25a33d..b35eeb4 100644 --- a/apps/web/src/auth/AuthProvider.tsx +++ b/apps/web/src/auth/AuthProvider.tsx @@ -2,13 +2,14 @@ import { createContext, type FormEvent, type ReactNode, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { - CloseOutlined, KeyOutlined, LockOutlined, MobileOutlined, SafetyCertificateOutlined, UserOutlined, + BankOutlined, CloseOutlined, EyeInvisibleOutlined, EyeOutlined, KeyOutlined, LockOutlined, + SafetyCertificateOutlined, UserOutlined, WarningOutlined, } from '@ant-design/icons'; import { clientFetch, getClientMode, type ClientMode } from '../api/client'; export type AuthUser = { - user_id: number; + user_id: string; username: string; fullname: string; tenant_uuid: string; @@ -34,13 +35,17 @@ export type LicenseInfo = { type AuthStatus = 'loading' | 'anonymous' | 'authenticated'; type LoginPayload = { - method: 'password' | 'phone'; - username?: string; - password?: string; - mobile?: string; - code?: string; + method: 'password'; + tenantCode: string; + username: string; + password: string; + captchaId?: string; + captchaCode?: string; }; +type CaptchaChallenge = { captchaId: string; image: string }; +type TenantOption = { code: string; name: string }; + type AuthContextValue = { status: AuthStatus; user: AuthUser | null; @@ -49,6 +54,7 @@ type AuthContextValue = { provider: string; error: string; login: (payload: LoginPayload) => Promise; + clearError: () => void; activateLicense: (code: string) => Promise; logout: () => Promise; refresh: () => Promise; @@ -80,6 +86,7 @@ export function AuthProvider(props: { children: ReactNode }) { const clientMode = getClientMode(); const [provider, setProvider] = useState('unconfigured'); const [error, setError] = useState(''); + const clearError = useCallback(() => setError(''), []); const refresh = useCallback(async () => { setStatus('loading'); @@ -180,8 +187,8 @@ export function AuthProvider(props: { children: ReactNode }) { }, []); const value = useMemo( - () => ({ status, user, license, clientMode, provider, error, login, activateLicense, logout, refresh }), - [status, user, license, clientMode, provider, error, login, activateLicense, logout, refresh], + () => ({ status, user, license, clientMode, provider, error, login, clearError, activateLicense, logout, refresh }), + [status, user, license, clientMode, provider, error, login, clearError, activateLicense, logout, refresh], ); return {props.children}; } @@ -194,20 +201,77 @@ export function useAuth(): AuthContextValue { export function LoginDialog(props: { open: boolean; onClose: () => void }) { const auth = useAuth(); - const [mode, setMode] = useState<'password' | 'phone'>('password'); - const [username, setUsername] = useState(auth.provider === 'mock' ? 'planner' : ''); + const [tenants, setTenants] = useState([]); + const [tenantName, setTenantName] = useState(() => localStorage.getItem('aps.auth.tenant-name.v1') || ''); + const [tenantLoading, setTenantLoading] = useState(false); + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [mobile, setMobile] = useState(auth.provider === 'mock' ? '13800000001' : ''); - const [code, setCode] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + const [captchaRequired, setCaptchaRequired] = useState(false); + const [captcha, setCaptcha] = useState(null); + const [captchaCode, setCaptchaCode] = useState(''); const [busy, setBusy] = useState(false); const [feedback, setFeedback] = useState(''); useEffect(() => { - if (auth.provider === 'mock') { - setUsername(value => value || 'planner'); - setMobile(value => value || '13800000001'); + if (!props.open) return; + auth.clearError(); + setFeedback(''); + setTenantLoading(true); + void clientFetch('/api/auth/tenants') + .then(async response => { + if (!response.ok) throw new Error(await readError(response)); + const body = await response.json() as { tenants?: TenantOption[] }; + const options = Array.isArray(body.tenants) ? body.tenants : []; + setTenants(options); + setTenantName(current => { + if (current.trim()) return current; + const previousCode = localStorage.getItem('aps.auth.tenant-code.v1') || ''; + const migratedName = options.find(option => option.code === previousCode)?.name || ''; + if (migratedName) localStorage.setItem('aps.auth.tenant-name.v1', migratedName); + return migratedName; + }); + if (!options.length) setFeedback('暂无可用企业,请联系系统管理员'); + }) + .catch(err => setFeedback(err instanceof Error ? err.message : '企业列表加载失败')) + .finally(() => setTenantLoading(false)); + }, [props.open, auth.clearError]); + + const resolveTenant = useCallback((companyName: string): TenantOption => { + const normalizedName = companyName.trim().toLocaleLowerCase(); + if (!normalizedName) throw new Error('请输入企业名称'); + if (tenantLoading) throw new Error('企业信息正在加载,请稍后重试'); + const matches = tenants.filter(tenant => tenant.name.trim().toLocaleLowerCase() === normalizedName); + if (!matches.length) throw new Error('未找到该企业,请输入完整的企业名称'); + if (matches.length > 1) throw new Error('企业名称不唯一,请联系系统管理员'); + localStorage.setItem('aps.auth.tenant-name.v1', matches[0].name); + localStorage.removeItem('aps.auth.tenant-code.v1'); + return matches[0]; + }, [tenantLoading, tenants]); + + const loadCaptcha = useCallback(async () => { + const response = await clientFetch('/api/auth/captcha'); + if (!response.ok) throw new Error(await readError(response)); + const challenge = await response.json() as CaptchaChallenge; + setCaptcha(challenge); + setCaptchaCode(''); + return challenge; + }, []); + + const runPrecheck = useCallback(async (account: string, companyCode: string) => { + const query = new URLSearchParams({ username: account.trim(), tenantCode: companyCode }); + const response = await clientFetch(`/api/auth/login/precheck?${query.toString()}`); + if (!response.ok) throw new Error(await readError(response)); + const result = await response.json() as { requireCaptcha?: boolean }; + const required = Boolean(result.requireCaptcha); + setCaptchaRequired(required); + if (required && !captcha) await loadCaptcha(); + if (!required) { + setCaptcha(null); + setCaptchaCode(''); } - }, [auth.provider]); + return required; + }, [captcha, loadCaptcha]); useEffect(() => { if (!props.open) return; @@ -222,31 +286,40 @@ export function LoginDialog(props: { open: boolean; onClose: () => void }) { event.preventDefault(); setFeedback(''); setBusy(true); + let loginAttempted = false; try { - await auth.login(mode === 'password' - ? { method: 'password', username: username.trim(), password } - : { method: 'phone', mobile: mobile.trim(), code }); + const tenant = resolveTenant(tenantName); + const required = await runPrecheck(username, tenant.code); + if (required && (!captcha || !captchaCode.trim())) { + setFeedback('请输入图形验证码'); + return; + } + loginAttempted = true; + await auth.login({ + method: 'password', + tenantCode: tenant.code, + username: username.trim(), + password, + captchaId: required ? captcha?.captchaId : undefined, + captchaCode: required ? captchaCode.trim() : undefined, + }); props.onClose(); } catch (err) { setFeedback(err instanceof Error ? err.message : '登录失败'); + if (loginAttempted) { + try { + const tenant = resolveTenant(tenantName); + const required = await runPrecheck(username, tenant.code); + if (required) await loadCaptcha(); + } catch { + // Keep the original login error visible when captcha refresh also fails. + } + } } finally { setBusy(false); } }; - const sendCode = () => { - if (!/^1[3-9]\d{9}$/.test(mobile)) { - setFeedback('请输入正确的手机号'); - return; - } - if (auth.provider === 'mock') { - setCode('123456'); - setFeedback('验证码已发送'); - return; - } - setFeedback('验证码接口尚未配置'); - }; - if (!props.open) return null; return ( @@ -264,35 +337,48 @@ export function LoginDialog(props: { open: boolean; onClose: () => void }) {

登录工业智核

APS Agent

-
- - -
- {mode === 'password' ? ( - <> - - - - ) : ( - <> - - - + + + + {captchaRequired && ( + )} -
- {feedback || auth.error || ' '} + {(feedback || auth.error) && <>{feedback || auth.error}}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index de77d81..4876393 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3105,26 +3105,6 @@ table.grid tr:last-child td { border-bottom: none; } } .login-brand h1 { margin: 0; font-size: 19px; line-height: 1.25; letter-spacing: 0; } .login-brand p { margin: 3px 0 0; color: #8a877e; font-size: 12px; } -.login-mode { - display: grid; - grid-template-columns: 1fr 1fr; - height: 38px; - padding: 3px; - margin-bottom: 20px; - border-radius: 7px; - background: #f1f1ee; -} -.login-mode button { - border: 0; - border-radius: 5px; - background: transparent; - color: #77746c; - cursor: pointer; - font: inherit; - font-size: 13px; - letter-spacing: 0; -} -.login-mode button.active { background: #fff; color: #1f1e1b; font-weight: 650; box-shadow: 0 1px 3px rgba(31, 30, 27, .08); } .login-form { display: grid; gap: 14px; } .login-form label > span { display: block; margin-bottom: 6px; color: #66635c; font-size: 12px; } .login-form label > div { @@ -3139,8 +3119,52 @@ table.grid tr:last-child td { border-bottom: none; } color: #969288; } .login-form label > div:focus-within { border-color: #f97316; box-shadow: 0 0 0 3px #fff0e6; } -.login-form input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: #1f1e1b; font: inherit; letter-spacing: 0; } -.login-code { flex: 0 0 auto; border: 0; background: transparent; color: #c6510b; cursor: pointer; font: inherit; font-size: 12px; } +.login-form input, +.login-form select { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: #1f1e1b; + font: inherit; + letter-spacing: 0; +} +.login-form select { width: 100%; cursor: pointer; } +.login-form select:disabled { color: #8a877e; cursor: default; } +.login-form input:disabled { color: #8a877e; cursor: default; } +.login-password-field { padding-right: 7px !important; } +.login-password-toggle { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #77746c; + cursor: pointer; + font-size: 16px; +} +.login-password-toggle:hover { background: #f4f3ef; color: #1f1e1b; } +.login-password-toggle:focus-visible { outline: 2px solid #f97316; outline-offset: 1px; } +.login-captcha-field { padding-right: 4px !important; } +.login-captcha-image { + width: 104px; + height: 36px; + flex: 0 0 104px; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 5px; + background: #f1f1ee; + cursor: pointer; +} +.login-captcha-image:disabled { cursor: default; opacity: .65; } +.login-captcha-image img { width: 100%; height: 100%; display: block; object-fit: contain; } .login-submit { height: 44px; margin-top: 3px; @@ -3155,8 +3179,26 @@ table.grid tr:last-child td { border-bottom: none; } } .login-submit:hover:not(:disabled) { background: #de5f0b; } .login-submit:disabled { background: #dedcd6; color: #9d9a91; cursor: default; } -.login-feedback { min-height: 20px; margin-top: 10px; color: #c2410c; font-size: 12px; text-align: center; visibility: hidden; } -.login-feedback.visible { visibility: visible; } +.login-feedback { + min-height: 0; + margin-top: 12px; + color: #b9380b; + font-size: 12px; + visibility: hidden; +} +.login-feedback.visible { + min-height: 38px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border: 1px solid #fed7aa; + border-radius: 7px; + background: #fff7ed; + visibility: visible; +} +.login-feedback.visible > span:last-child { min-width: 0; line-height: 17px; overflow-wrap: anywhere; } +.login-feedback.visible > .anticon { flex: 0 0 auto; margin-top: 1px; } .license-dialog { width: min(420px, calc(100vw - 32px)); } .license-brand-mark { font-size: 19px; } .license-summary { diff --git a/docs/architecture/auth-tenancy.md b/docs/architecture/auth-tenancy.md index c8115f1..b74140b 100644 --- a/docs/architecture/auth-tenancy.md +++ b/docs/architecture/auth-tenancy.md @@ -12,22 +12,29 @@ ## Authentication providers -`APS_AUTH_PROVIDER=unconfigured` is the production-safe default. Login returns -`AUTH_NOT_CONFIGURED` and every business API remains closed. +`APS_AUTH_PROVIDER=jms` enables the real JMS account/password flow. The server calls +tenant list, login precheck, captcha, login, profile, tenant, and logout endpoints at +`JMS_AUTH_BASE_URL`. The browser submits only a tenant code selected from the sanitized +server list; the server resolves its authoritative name and revalidates the authenticated +tenant before binding the identity. Optional server configuration can restrict login to +one tenant. `unconfigured` keeps every business API closed. -`APS_AUTH_PROVIDER=mock` is for local development only. It issues an HttpOnly -development cookie and exposes the users configured by `APS_MOCK_USERS`. - -The external user-management integration must implement these adapter operations: +The JMS authentication adapter implements these operations: ```text +login precheck / captcha +public tenant discovery login -authenticate / verify_token -refresh -logout -search_users +authenticate via profile + tenant revalidation +local session refresh +remote logout ``` +User-directory search remains closed with `AUTH_USER_DIRECTORY_NOT_CONFIGURED` until the +user-management scope is integrated. The current JMS OpenAPI has no self-registration or +unauthenticated self-service password-recovery endpoint. Account creation and password +reset remain enterprise-admin workflows. + The normalized identity is: ```text diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs index b39a566..dc8564f 100644 --- a/scripts/dev-desktop.mjs +++ b/scripts/dev-desktop.mjs @@ -20,7 +20,7 @@ function run(label, command, args, env = {}) { ...process.env, APS_MODE: 'desktop', APS_HOME: home, - APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'mock', + APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'jms', APS_LICENSE_PROVIDER: process.env.APS_LICENSE_PROVIDER || 'mock', ...env, }, diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs index f341b99..92f5c80 100644 --- a/scripts/dev-web.mjs +++ b/scripts/dev-web.mjs @@ -17,7 +17,7 @@ function run(label, command, args, env = {}) { env: { ...process.env, APS_MODE: 'web', - APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'mock', + APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'jms', ...env, }, stdio: 'inherit', diff --git a/server/auth/context.py b/server/auth/context.py index 4915030..50443b4 100644 --- a/server/auth/context.py +++ b/server/auth/context.py @@ -20,6 +20,7 @@ class IdentityContext: def to_dict(self) -> dict: data = asdict(self) + data["user_id"] = str(self.user_id) data["roles"] = list(self.roles) return data diff --git a/server/auth/middleware.py b/server/auth/middleware.py index 077a3c6..f8a82bf 100644 --- a/server/auth/middleware.py +++ b/server/auth/middleware.py @@ -11,6 +11,9 @@ from server.auth.providers import AuthError, get_auth_provider PUBLIC_API_PATHS = { "/api/health", "/api/auth/login", + "/api/auth/login/precheck", + "/api/auth/tenants", + "/api/auth/captcha", "/api/auth/refresh", "/api/auth/logout", "/api/auth/license/activate", diff --git a/server/auth/providers.py b/server/auth/providers.py index 9b769db..dc309ca 100644 --- a/server/auth/providers.py +++ b/server/auth/providers.py @@ -5,6 +5,7 @@ import hashlib import hmac import json import os +import re import time from abc import ABC, abstractmethod from typing import Any @@ -12,6 +13,7 @@ from typing import Any from fastapi import Request from server.auth.context import IdentityContext +from server.integrations.jms_auth_client import JmsAuthClient, JmsAuthClientError class AuthError(Exception): @@ -33,30 +35,31 @@ class AuthProvider(ABC): async def authenticate(self, request: Request) -> IdentityContext: raise NotImplementedError + async def tenants(self) -> list[dict[str, str]]: + raise AuthError("AUTH_NOT_CONFIGURED", "租户列表接口尚未配置", 503) + + async def precheck(self, username: str = "", tenant_code: str = "") -> dict[str, Any]: + raise AuthError("AUTH_NOT_CONFIGURED", "登录预检接口尚未配置", 503) + + async def captcha(self) -> dict[str, str]: + raise AuthError("AUTH_NOT_CONFIGURED", "验证码接口尚未配置", 503) + async def refresh(self, request: Request) -> tuple[IdentityContext, str]: - identity = await self.authenticate(request) + await self.authenticate(request) raise AuthError("AUTH_NOT_CONFIGURED", "刷新接口尚未配置", 503) async def logout(self, request: Request) -> None: return None async def search_users(self, query: str, identity: IdentityContext) -> list[dict[str, Any]]: - raise AuthError("AUTH_NOT_CONFIGURED", "租户用户查询接口尚未配置", 503) - + raise AuthError("AUTH_USER_DIRECTORY_NOT_CONFIGURED", "租户用户查询接口尚未接入", 503) class UnconfiguredAuthProvider(AuthProvider): async def login(self, payload: dict[str, Any]) -> tuple[IdentityContext, str]: - raise AuthError("AUTH_NOT_CONFIGURED", "用户管理接口尚未配置", 503) + raise AuthError("AUTH_NOT_CONFIGURED", "JMS 用户认证尚未配置", 503) async def authenticate(self, request: Request) -> IdentityContext: - raise AuthError("AUTH_NOT_CONFIGURED", "用户管理接口尚未配置", 503) - - -DEFAULT_MOCK_USERS: tuple[dict[str, Any], ...] = ( - {"id": 1001, "username": "planner", "fullname": "计划员", "mobile": "13800000001"}, - {"id": 1002, "username": "collaborator", "fullname": "协作成员", "mobile": "13800000002"}, - {"id": 1003, "username": "viewer", "fullname": "查看成员", "mobile": "13800000003"}, -) + raise AuthError("AUTH_NOT_CONFIGURED", "JMS 用户认证尚未配置", 503) def _b64encode(raw: bytes) -> str: @@ -67,122 +70,295 @@ def _b64decode(raw: str) -> bytes: return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)) -class MockAuthProvider(AuthProvider): - """Development-only provider. It is enabled only by APS_AUTH_PROVIDER=mock.""" +class JmsAuthProvider(AuthProvider): + mode = "jms" + _TOKEN_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") + _PROHIBITED_TOKEN_NAMES = { + "connection", "content-length", "cookie", "host", "proxy-authorization", "transfer-encoding", + } - mode = "mock" - - def __init__(self) -> None: - self.secret = (os.environ.get("APS_MOCK_AUTH_SECRET") or "aps-local-development-only").encode() - self.tenant_uuid = os.environ.get("APS_MOCK_TENANT_UUID") or "demo0000000000000000000000000001" - self.ttl_seconds = int(os.environ.get("APS_AUTH_TTL_SECONDS") or "28800") - - def _directory(self) -> list[dict[str, Any]]: - configured = os.environ.get("APS_MOCK_USERS") - if configured: + def __init__(self, client: JmsAuthClient | None = None) -> None: + self.client: JmsAuthClient | None = client + self.client_config_error: AuthError | None = None + if self.client is None: try: - rows = json.loads(configured) - if isinstance(rows, list): - return [row for row in rows if isinstance(row, dict)] - except json.JSONDecodeError: - pass - return [dict(user) for user in DEFAULT_MOCK_USERS] + self.client = JmsAuthClient() + except JmsAuthClientError as exc: + self.client_config_error = AuthError(exc.code, exc.message, exc.status_code) + self.tenant_code = (os.environ.get("JMS_AUTH_TENANT_CODE") or "").strip() + self.tenant_name = (os.environ.get("JMS_AUTH_TENANT_NAME") or "").strip() + self.expected_token_name = (os.environ.get("JMS_AUTH_TOKEN_NAME") or "").strip() + self.session_secret = ( + os.environ.get("JMS_AUTH_SESSION_SECRET") + or os.environ.get("APS_AUTH_SESSION_SECRET") + or "" + ).encode("utf-8") + try: + self.ttl_seconds = int(os.environ.get("APS_AUTH_TTL_SECONDS") or "28800") + except ValueError: + self.ttl_seconds = 0 + self._tenants_cache: tuple[float, list[dict[str, str]]] | None = None - def _identity_for(self, payload: dict[str, Any]) -> IdentityContext: - identifier = str( - payload.get("username") or payload.get("mobile") or payload.get("identifier") or "planner" - ).strip() - users = self._directory() - user = next( - (row for row in users if identifier in {str(row.get("username")), str(row.get("mobile")), str(row.get("id"))}), - users[0], - ) - return IdentityContext( - user_id=int(user.get("id") or 1001), - username=str(user.get("username") or identifier), - fullname=str(user.get("fullname") or user.get("username") or identifier), - tenant_uuid=self.tenant_uuid, - roles=tuple(user.get("roles") or ("planner",)), - expires_at=int(time.time()) + self.ttl_seconds, - ) + def _get_client(self) -> JmsAuthClient: + if self.client_config_error is not None: + raise self.client_config_error + if self.client is None: + raise AuthError("AUTH_CONFIG_INVALID", "JMS 认证客户端未正确配置", 503) + return self.client - def _issue(self, identity: IdentityContext) -> str: - payload = identity.to_dict() - raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + def _require_login_config(self) -> None: + missing = [] + if len(self.session_secret) < 32: + missing.append("JMS_AUTH_SESSION_SECRET(至少 32 个字符)") + if self.ttl_seconds <= 0: + missing.append("APS_AUTH_TTL_SECONDS(必须为正整数)") + if missing: + raise AuthError( + "AUTH_CONFIG_INVALID", + f"JMS 认证配置缺失:{', '.join(missing)}", + 503, + ) + self._get_client() + + def _validate_token_name(self, token_name: str) -> None: + if not self._TOKEN_NAME_RE.fullmatch(token_name): + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 登录凭证名称无效", 502) + if token_name.lower() in self._PROHIBITED_TOKEN_NAMES: + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 登录凭证名称不安全", 502) + if self.expected_token_name and token_name.lower() != self.expected_token_name.lower(): + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 登录凭证名称与本地配置不一致", 502) + + @staticmethod + def _request_token(request: Request) -> str: + authorization = request.headers.get("authorization") or "" + if authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return request.cookies.get("aps_session") or "" + + def _issue_session(self, token_name: str, token_value: str, tenant_code: str) -> str: + self._validate_token_name(token_name) + payload = { + "tokenName": token_name, + "tokenValue": token_value, + "tenantCode": tenant_code, + "expiresAt": int(time.time()) + self.ttl_seconds, + } + raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8") body = _b64encode(raw) - sig = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest()) - return f"{body}.{sig}" + signature = _b64encode(hmac.new(self.session_secret, body.encode("ascii"), hashlib.sha256).digest()) + return f"{body}.{signature}" - def _verify(self, token: str) -> IdentityContext: + def _read_session(self, request: Request) -> tuple[str, str, str]: + token = self._request_token(request) + if not token: + raise AuthError("AUTH_REQUIRED", "请先登录", 401) + if len(self.session_secret) < 32: + raise AuthError("AUTH_CONFIG_INVALID", "JMS 会话密钥未正确配置", 503) try: body, signature = token.split(".", 1) - expected = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest()) + expected = _b64encode( + hmac.new(self.session_secret, body.encode("ascii"), hashlib.sha256).digest() + ) if not hmac.compare_digest(signature, expected): raise ValueError("signature") payload = json.loads(_b64decode(body)) - expires_at = int(payload.get("expires_at") or 0) - if expires_at <= int(time.time()): + if int(payload.get("expiresAt") or 0) <= int(time.time()): raise AuthError("AUTH_EXPIRED", "登录状态已过期", 401) - return IdentityContext( - user_id=int(payload["user_id"]), - username=str(payload["username"]), - fullname=str(payload.get("fullname") or payload["username"]), - tenant_uuid=str(payload["tenant_uuid"]), - roles=tuple(payload.get("roles") or ()), - expires_at=expires_at, - ) + token_name = str(payload["tokenName"]) + token_value = str(payload["tokenValue"]) + tenant_code = str(payload["tenantCode"]) + if not token_value or not tenant_code: + raise ValueError("token") + self._validate_token_name(token_name) + return token_name, token_value, tenant_code except AuthError: raise except Exception as exc: raise AuthError("AUTH_INVALID", "登录凭证无效", 401) from exc @staticmethod - def _request_token(request: Request) -> str: - auth = request.headers.get("authorization") or "" - if auth.lower().startswith("bearer "): - return auth[7:].strip() - return request.cookies.get("aps_session") or "" + def _client_error(exc: JmsAuthClientError, *, login: bool = False) -> AuthError: + if login and exc.status_code in {400, 401, 403}: + return AuthError("AUTH_LOGIN_FAILED", exc.message, 401) + if exc.status_code in {400, 401, 403}: + return AuthError("AUTH_INVALID", exc.message, 401) + return AuthError(exc.code, exc.message, exc.status_code) + + def _identity( + self, + user: dict[str, Any], + tenant: dict[str, Any], + *, + expected_tenant_code: str, + ) -> IdentityContext: + if str(user.get("state", "0")) != "0": + raise AuthError("AUTH_USER_DISABLED", "当前用户已被停用", 403) + if str(tenant.get("state", "0")) != "0": + raise AuthError("AUTH_TENANT_DISABLED", "当前租户已被停用", 403) + raw_user_id = str(user.get("id") or "") + if not raw_user_id.isdigit() or int(raw_user_id) <= 0 or int(raw_user_id) > 2**63 - 1: + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 用户标识格式无效", 502) + tenant_uuid = str(tenant.get("uuid") or "").strip() + username = str(user.get("username") or "").strip() + if not tenant_uuid or len(tenant_uuid) > 32 or not username: + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 用户或租户信息不完整", 502) + returned_tenant_code = str(tenant.get("code") or "").strip() + if returned_tenant_code and returned_tenant_code != expected_tenant_code: + raise AuthError("AUTH_TENANT_MISMATCH", "JMS 登录租户与本地配置不一致", 403) + roles = tuple( + str(role.get("code")).strip() + for role in (user.get("roles") or []) + if isinstance(role, dict) and str(role.get("code") or "").strip() + ) + return IdentityContext( + user_id=int(raw_user_id), + username=username, + fullname=str(user.get("fullname") or username), + tenant_uuid=tenant_uuid, + roles=roles, + ) + + async def tenants(self) -> list[dict[str, str]]: + now = time.monotonic() + if self._tenants_cache and self._tenants_cache[0] > now: + return [dict(row) for row in self._tenants_cache[1]] + try: + data = await self._get_client().tenants() + except JmsAuthClientError as exc: + raise self._client_error(exc) from exc + rows: list[dict[str, str]] = [] + seen: set[str] = set() + for tenant in data: + code = str(tenant.get("code") or "").strip() + name = str(tenant.get("name") or "").strip() + if ( + not code + or not name + or code in seen + or str(tenant.get("state", "0")) != "0" + or str(tenant.get("deleted", "0")) != "0" + ): + continue + if self.tenant_code and code != self.tenant_code: + continue + seen.add(code) + display_name = self.tenant_name if self.tenant_code == code and self.tenant_name else name + rows.append({"code": code, "name": display_name}) + if (not self.tenant_code or self.tenant_code == "platform") and "platform" not in seen: + platform_name = self.tenant_name if self.tenant_code == "platform" and self.tenant_name else "平台" + rows.append({"code": "platform", "name": platform_name}) + if self.tenant_code and self.tenant_name and not rows: + rows.append({"code": self.tenant_code, "name": self.tenant_name}) + rows.sort(key=lambda row: (row["name"].lower(), row["code"].lower())) + self._tenants_cache = (now + 300, rows) + return [dict(row) for row in rows] + + async def _resolve_tenant(self, tenant_code: str) -> dict[str, str]: + selected_code = (self.tenant_code or tenant_code).strip() + if not selected_code: + raise AuthError("AUTH_TENANT_REQUIRED", "请选择所属企业", 400) + if self.tenant_code and tenant_code and tenant_code != self.tenant_code: + raise AuthError("AUTH_TENANT_INVALID", "所选企业与系统配置不一致", 400) + tenant = next((row for row in await self.tenants() if row["code"] == selected_code), None) + if tenant is None: + raise AuthError("AUTH_TENANT_INVALID", "所选企业不可用,请重新选择", 400) + return tenant + + async def precheck(self, username: str = "", tenant_code: str = "") -> dict[str, Any]: + tenant = await self._resolve_tenant(tenant_code) + try: + data = await self._get_client().precheck( + tenant_code=tenant["code"], username=username.strip(), + ) + except JmsAuthClientError as exc: + raise self._client_error(exc) from exc + return {"requireCaptcha": bool(data.get("requireCaptcha"))} + + async def captcha(self) -> dict[str, str]: + try: + data = await self._get_client().captcha() + except JmsAuthClientError as exc: + raise self._client_error(exc) from exc + captcha_id = str(data.get("captchaId") or "") + image = str(data.get("image") or "") + if not captcha_id or not image.startswith("data:image/png;base64,"): + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 验证码响应不完整", 502) + return {"captchaId": captcha_id, "image": image} async def login(self, payload: dict[str, Any]) -> tuple[IdentityContext, str]: - identity = self._identity_for(payload) - return identity, self._issue(identity) + self._require_login_config() + if str(payload.get("method") or "password") != "password": + raise AuthError("AUTH_METHOD_UNSUPPORTED", "当前仅支持账号密码登录", 400) + username = str(payload.get("username") or "").strip() + password = str(payload.get("password") or "") + selected_tenant = await self._resolve_tenant(str(payload.get("tenantCode") or "")) + captcha_id = str(payload.get("captchaId") or "").strip() + captcha_code = str(payload.get("captchaCode") or "").strip() + if not username or not password: + raise AuthError("AUTH_CREDENTIALS_REQUIRED", "请输入账号和密码", 400) + if bool(captcha_id) != bool(captcha_code): + raise AuthError("AUTH_CAPTCHA_REQUIRED", "请输入完整的图形验证码", 400) + try: + result = await self._get_client().login( + tenant_code=selected_tenant["code"], + tenant_name=selected_tenant["name"], + username=username, + password=password, + captcha_id=captcha_id, + captcha_code=captcha_code, + ) + except JmsAuthClientError as exc: + raise self._client_error(exc, login=True) from exc + + token_name = str(result.get("tokenName") or "").strip() + token_value = str(result.get("tokenValue") or "").strip() + if not token_name or not token_value: + raise AuthError("AUTH_UPSTREAM_INVALID", "JMS 登录响应缺少凭证", 502) + self._validate_token_name(token_name) + + action = str(result.get("action") or "").strip() + if action: + try: + await self._get_client().logout(token_name=token_name, token_value=token_value) + except JmsAuthClientError: + pass + raise AuthError("AUTH_ACTION_REQUIRED", f"账号需要先完成安全操作:{action}", 409) + + try: + client = self._get_client() + user = await client.profile(token_name=token_name, token_value=token_value) + remote_tenant = await client.tenant(token_name=token_name, token_value=token_value) + except JmsAuthClientError as exc: + raise self._client_error(exc) from exc + identity = self._identity( + user, remote_tenant, expected_tenant_code=selected_tenant["code"], + ) + return identity, self._issue_session( + token_name, token_value, selected_tenant["code"], + ) async def authenticate(self, request: Request) -> IdentityContext: - token = self._request_token(request) - if not token: - raise AuthError("AUTH_REQUIRED", "请先登录", 401) - return self._verify(token) + token_name, token_value, tenant_code = self._read_session(request) + try: + client = self._get_client() + user = await client.profile(token_name=token_name, token_value=token_value) + tenant = await client.tenant(token_name=token_name, token_value=token_value) + except JmsAuthClientError as exc: + raise self._client_error(exc) from exc + return self._identity(user, tenant, expected_tenant_code=tenant_code) async def refresh(self, request: Request) -> tuple[IdentityContext, str]: - current = await self.authenticate(request) - identity = IdentityContext( - user_id=current.user_id, - username=current.username, - fullname=current.fullname, - tenant_uuid=current.tenant_uuid, - roles=current.roles, - expires_at=int(time.time()) + self.ttl_seconds, - ) - return identity, self._issue(identity) - - async def search_users(self, query: str, identity: IdentityContext) -> list[dict[str, Any]]: - needle = query.strip().lower() - rows = [] - for user in self._directory(): - if int(user.get("id") or 0) == identity.user_id: - continue - haystack = " ".join(str(user.get(k) or "") for k in ("username", "fullname", "mobile")).lower() - if needle and needle not in haystack: - continue - rows.append({ - "id": int(user["id"]), - "username": str(user.get("username") or ""), - "fullname": str(user.get("fullname") or user.get("username") or ""), - "mobile": str(user.get("mobile") or ""), - "tenantUuid": identity.tenant_uuid, - }) - return rows[:20] + token_name, token_value, tenant_code = self._read_session(request) + identity = await self.authenticate(request) + return identity, self._issue_session(token_name, token_value, tenant_code) + async def logout(self, request: Request) -> None: + try: + token_name, token_value, _ = self._read_session(request) + await self._get_client().logout(token_name=token_name, token_value=token_value) + except (AuthError, JmsAuthClientError): + # Local logout must always succeed even when the remote session has expired. + return None _provider: AuthProvider | None = None _provider_mode: str | None = None @@ -192,6 +368,6 @@ def get_auth_provider() -> AuthProvider: global _provider, _provider_mode mode = (os.environ.get("APS_AUTH_PROVIDER") or "unconfigured").strip().lower() if _provider is None or _provider_mode != mode: - _provider = MockAuthProvider() if mode == "mock" else UnconfiguredAuthProvider() + _provider = JmsAuthProvider() if mode == "jms" else UnconfiguredAuthProvider() _provider_mode = mode return _provider diff --git a/server/gateway/app.py b/server/gateway/app.py index d27840f..3f7f550 100644 --- a/server/gateway/app.py +++ b/server/gateway/app.py @@ -73,12 +73,12 @@ class ChatRequest(BaseModel): class AuthLoginRequest(BaseModel): - method: str = "phone" - username: str | None = None - password: str | None = None - mobile: str | None = None - code: str | None = None - identifier: str | None = None + method: str = "password" + tenantCode: str | None = Field(default=None, max_length=128) + username: str | None = Field(default=None, max_length=128) + password: str | None = Field(default=None, max_length=512) + captchaId: str | None = Field(default=None, max_length=128) + captchaCode: str | None = Field(default=None, max_length=32) class LicenseActivationRequest(BaseModel): @@ -410,6 +410,40 @@ def create_app() -> FastAPI: set_session_cookie(response, token) return {"user": identity.to_dict(), "provider": get_auth_provider().mode} + @app.get("/api/auth/tenants") + async def auth_tenants(request: Request): + if is_desktop_request(request): + return auth_error_response(AuthError( + "DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400, + )) + try: + tenants = await get_auth_provider().tenants() + except AuthError as exc: + return auth_error_response(exc) + return {"tenants": tenants} + + @app.get("/api/auth/login/precheck") + async def auth_login_precheck(request: Request, username: str = "", tenantCode: str = ""): + if is_desktop_request(request): + return auth_error_response(AuthError( + "DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400, + )) + try: + return await get_auth_provider().precheck(username[:128], tenantCode[:128]) + except AuthError as exc: + return auth_error_response(exc) + + @app.get("/api/auth/captcha") + async def auth_captcha(request: Request): + if is_desktop_request(request): + return auth_error_response(AuthError( + "DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400, + )) + try: + return await get_auth_provider().captcha() + except AuthError as exc: + return auth_error_response(exc) + @app.post("/api/auth/license/activate") async def auth_license_activate(req: LicenseActivationRequest, request: Request, response: Response): provider = get_license_provider() @@ -464,7 +498,10 @@ def create_app() -> FastAPI: @app.get("/api/auth/users") async def auth_users(query: str = "") -> dict: identity = get_identity(required=True) - users = await get_auth_provider().search_users(query, identity) + try: + users = await get_auth_provider().search_users(query, identity) + except AuthError as exc: + return auth_error_response(exc) return {"users": users} # ---------------- 对话:SSE 会话流 ---------------- diff --git a/server/integrations/jms_auth_client.py b/server/integrations/jms_auth_client.py new file mode 100644 index 0000000..5cadb5e --- /dev/null +++ b/server/integrations/jms_auth_client.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import os +from typing import Any +from urllib.parse import urlparse + +import httpx + + +class JmsAuthClientError(Exception): + def __init__(self, code: str, message: str, status_code: int) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + + +class JmsAuthClient: + """Small typed boundary around the JMS authentication endpoints.""" + + def __init__( + self, + *, + base_url: str | None = None, + timeout_seconds: float | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.base_url = ( + base_url + or os.environ.get("JMS_AUTH_BASE_URL") + or "https://jms-mes-api.jzplat.com" + ).rstrip("/") + try: + self.timeout_seconds = timeout_seconds or float( + os.environ.get("JMS_AUTH_TIMEOUT_SECONDS") or "10" + ) + except ValueError as exc: + raise JmsAuthClientError( + "AUTH_CONFIG_INVALID", "JMS 认证超时时间配置无效", 503, + ) from exc + if self.timeout_seconds <= 0: + raise JmsAuthClientError( + "AUTH_CONFIG_INVALID", "JMS 认证超时时间必须大于 0", 503, + ) + self.transport = transport + parsed = urlparse(self.base_url) + if parsed.scheme != "https" or not parsed.netloc: + raise JmsAuthClientError( + "AUTH_CONFIG_INVALID", + "JMS 认证服务地址必须是有效的 HTTPS 地址", + 503, + ) + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, str] | None = None, + json_body: dict[str, Any] | None = None, + form_body: dict[str, str] | None = None, + token_name: str | None = None, + token_value: str | None = None, + ) -> Any: + headers: dict[str, str] = {"Accept": "application/json"} + if token_name and token_value: + headers[token_name] = token_value + try: + async with httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout_seconds, + follow_redirects=False, + transport=self.transport, + ) as client: + response = await client.request( + method, + path, + params=params, + json=json_body, + data=form_body, + headers=headers, + ) + except httpx.TimeoutException as exc: + raise JmsAuthClientError( + "AUTH_UPSTREAM_TIMEOUT", "JMS 认证服务响应超时", 504, + ) from exc + except httpx.RequestError as exc: + raise JmsAuthClientError( + "AUTH_UPSTREAM_UNAVAILABLE", "无法连接 JMS 认证服务", 502, + ) from exc + + try: + envelope = response.json() + except ValueError as exc: + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 认证服务返回了无效响应", 502, + ) from exc + if not isinstance(envelope, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 认证服务返回了无效响应", 502, + ) + + remote_code = envelope.get("code", 0) + success = response.is_success and remote_code in (0, "0", None) + if not success: + message = str(envelope.get("message") or envelope.get("msg") or "JMS 认证请求失败") + numeric_code = int(remote_code) if str(remote_code).isdigit() else response.status_code + if path == "/admin/index/login": + diagnostic = f"{message} {envelope.get('error') or ''}".lower() + if "验证码" in diagnostic or "captcha" in diagnostic: + raise JmsAuthClientError( + "AUTH_CAPTCHA_INVALID", "验证码错误,请重新输入", 400, + ) + if any(word in diagnostic for word in ("停用", "禁用", "锁定", "disabled", "locked")): + raise JmsAuthClientError( + "AUTH_USER_DISABLED", "账号已停用或锁定,请联系管理员", 403, + ) + if any(word in diagnostic for word in ("租户", "企业", "tenant")): + raise JmsAuthClientError( + "AUTH_TENANT_INVALID", "企业名称或账号不匹配", 400, + ) + credential_rejected = ( + response.status_code in {400, 401, 403} + or numeric_code in {400, 401, 403} + or any(word in diagnostic for word in ( + "validation failure", "bad_request", "bad request", + "用户名", "用户不存在", "账号", "密码", "credential", + )) + ) + if credential_rejected: + raise JmsAuthClientError( + "AUTH_INVALID_CREDENTIALS", "账号或密码错误", 401, + ) + raise JmsAuthClientError( + "AUTH_UPSTREAM_ERROR", "认证服务暂时不可用,请稍后重试", 502, + ) + if response.status_code >= 500 or numeric_code >= 500: + raise JmsAuthClientError("AUTH_UPSTREAM_ERROR", message, 502) + status_code = response.status_code if response.status_code in {400, 401, 403} else 400 + raise JmsAuthClientError("AUTH_UPSTREAM_REJECTED", message, status_code) + return envelope.get("data") + + async def precheck(self, *, tenant_code: str, username: str = "") -> dict[str, Any]: + data = await self._request( + "GET", + "/admin/index/login/precheck", + params={"tenantCode": tenant_code, "username": username}, + ) + if not isinstance(data, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 登录预检响应格式无效", 502, + ) + return data + + async def tenants(self) -> list[dict[str, Any]]: + data = await self._request("GET", "/admin/index/tenant") + if not isinstance(data, list): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 租户列表响应格式无效", 502, + ) + return [row for row in data if isinstance(row, dict)] + + async def captcha(self) -> dict[str, str]: + data = await self._request("GET", "/admin/index/captcha") + if not isinstance(data, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 验证码响应格式无效", 502, + ) + return {str(key): str(value) for key, value in data.items() if value is not None} + + async def login( + self, + *, + tenant_code: str, + tenant_name: str, + username: str, + password: str, + captcha_id: str = "", + captcha_code: str = "", + ) -> dict[str, Any]: + form_body = { + "tenantCode": tenant_code, + "tenantName": tenant_name, + "username": username, + "password": password, + "device": "PC", + } + if captcha_id: + form_body["captchaId"] = captcha_id + if captcha_code: + form_body["captchaCode"] = captcha_code + data = await self._request( + "POST", + "/admin/index/login", + form_body=form_body, + ) + if not isinstance(data, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 登录响应格式无效", 502, + ) + return data + + async def profile(self, *, token_name: str, token_value: str) -> dict[str, Any]: + data = await self._request( + "GET", "/admin/auth/profile", token_name=token_name, token_value=token_value, + ) + if not isinstance(data, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 用户信息响应格式无效", 502, + ) + return data + + async def tenant(self, *, token_name: str, token_value: str) -> dict[str, Any]: + data = await self._request( + "GET", "/admin/auth/tenant", token_name=token_name, token_value=token_value, + ) + if not isinstance(data, dict): + raise JmsAuthClientError( + "AUTH_UPSTREAM_INVALID", "JMS 租户信息响应格式无效", 502, + ) + return data + + async def logout(self, *, token_name: str, token_value: str) -> None: + await self._request( + "POST", "/admin/auth/logout", token_name=token_name, token_value=token_value, + ) diff --git a/tests/auth_provider.py b/tests/auth_provider.py new file mode 100644 index 0000000..419d7a8 --- /dev/null +++ b/tests/auth_provider.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from typing import Any + +from fastapi import Request + +from server.auth.context import IdentityContext +from server.auth.providers import AuthError, AuthProvider + + +TEST_USERS: tuple[dict[str, Any], ...] = ( + {"id": 1001, "username": "planner", "fullname": "计划员", "mobile": "13800000001"}, + {"id": 1002, "username": "collaborator", "fullname": "协作成员", "mobile": "13800000002"}, + {"id": 1003, "username": "viewer", "fullname": "查看成员", "mobile": "13800000003"}, +) + + +def _b64encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64decode(raw: str) -> bytes: + return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)) + + +class TestAuthProvider(AuthProvider): + __test__ = False + mode = "test" + + def __init__(self, tenant_uuid: str) -> None: + self.tenant_uuid = tenant_uuid + self.secret = b"aps-test-auth-provider" + + def _identity(self, username: str) -> IdentityContext: + user = next(row for row in TEST_USERS if row["username"] == username) + return IdentityContext( + user_id=int(user["id"]), + username=str(user["username"]), + fullname=str(user["fullname"]), + tenant_uuid=self.tenant_uuid, + roles=("planner",), + expires_at=int(time.time()) + 3600, + ) + + def _issue(self, identity: IdentityContext) -> str: + body = _b64encode(json.dumps(identity.to_dict(), separators=(",", ":")).encode()) + signature = _b64encode(hmac.new(self.secret, body.encode(), hashlib.sha256).digest()) + return f"{body}.{signature}" + + def _verify(self, token: str) -> IdentityContext: + try: + body, signature = token.split(".", 1) + expected = _b64encode(hmac.new(self.secret, body.encode(), hashlib.sha256).digest()) + if not hmac.compare_digest(signature, expected): + raise ValueError("signature") + payload = json.loads(_b64decode(body)) + return IdentityContext( + user_id=int(payload["user_id"]), + username=str(payload["username"]), + fullname=str(payload["fullname"]), + tenant_uuid=str(payload["tenant_uuid"]), + roles=tuple(payload.get("roles") or ()), + expires_at=int(payload["expires_at"]), + ) + except Exception as exc: + raise AuthError("AUTH_INVALID", "测试登录凭证无效", 401) from exc + + async def login(self, payload: dict[str, Any]) -> tuple[IdentityContext, str]: + identity = self._identity(str(payload.get("username") or "")) + return identity, self._issue(identity) + + async def authenticate(self, request: Request) -> IdentityContext: + token = request.cookies.get("aps_session") or "" + if not token: + raise AuthError("AUTH_REQUIRED", "请先登录", 401) + return self._verify(token) + + async def refresh(self, request: Request) -> tuple[IdentityContext, str]: + identity = await self.authenticate(request) + return identity, self._issue(identity) + + async def search_users(self, query: str, identity: IdentityContext) -> list[dict[str, Any]]: + needle = query.strip().lower() + return [ + { + "id": int(user["id"]), + "username": str(user["username"]), + "fullname": str(user["fullname"]), + "mobile": str(user["mobile"]), + "tenantUuid": identity.tenant_uuid, + } + for user in TEST_USERS + if int(user["id"]) != identity.user_id + and (not needle or needle in " ".join(str(value) for value in user.values()).lower()) + ] + + +def install_test_auth(monkeypatch, tenant_uuid: str) -> TestAuthProvider: + import server.auth.middleware as auth_middleware + import server.gateway.app as gateway_app + + provider = TestAuthProvider(tenant_uuid) + monkeypatch.setattr(auth_middleware, "get_auth_provider", lambda: provider) + monkeypatch.setattr(gateway_app, "get_auth_provider", lambda: provider) + return provider diff --git a/tests/golden/test_auth_tenant_isolation.py b/tests/golden/test_auth_tenant_isolation.py index c5ad37a..c1d7708 100644 --- a/tests/golden/test_auth_tenant_isolation.py +++ b/tests/golden/test_auth_tenant_isolation.py @@ -1,24 +1,19 @@ from __future__ import annotations -import asyncio - import pytest from fastapi.testclient import TestClient +from tests.auth_provider import TestAuthProvider, install_test_auth + @pytest.fixture() def secure_app(tmp_path, monkeypatch): - monkeypatch.setenv("APS_AUTH_PROVIDER", "mock") - monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-a-000000000000000000000001") - monkeypatch.setenv("APS_MOCK_AUTH_SECRET", "test-secret") monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) - from server.auth import providers from server.db.database import reset_engine from server.state import store as world_store - providers._provider = None - providers._provider_mode = None + install_test_auth(monkeypatch, "tenant-a-000000000000000000000001") world_store._stores.clear() reset_engine() from server.gateway.app import create_app @@ -26,8 +21,6 @@ def secure_app(tmp_path, monkeypatch): yield app reset_engine() world_store._stores.clear() - providers._provider = None - providers._provider_mode = None def login(client: TestClient, username: str) -> None: @@ -101,9 +94,10 @@ def test_cross_tenant_token_cannot_access_known_project_id(secure_app, monkeypat login(owner, "planner") owner.post("/api/projects", json={"id": "proj_tenant_a", "name": "租户 A"}) - monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-b-000000000000000000000001") - from server.auth.providers import MockAuthProvider - _, token = asyncio.run(MockAuthProvider().login({"username": "collaborator"})) + import asyncio + _, token = asyncio.run(TestAuthProvider( + "tenant-b-000000000000000000000001", + ).login({"username": "collaborator"})) tenant_b = TestClient(secure_app) tenant_b.cookies.set("aps_session", token) diff --git a/tests/golden/test_desktop_license_auth.py b/tests/golden/test_desktop_license_auth.py index 0215d0a..eaa1db4 100644 --- a/tests/golden/test_desktop_license_auth.py +++ b/tests/golden/test_desktop_license_auth.py @@ -6,23 +6,22 @@ import pytest from fastapi.testclient import TestClient from sqlalchemy import select +from tests.auth_provider import install_test_auth + @pytest.fixture() def license_app(tmp_path, monkeypatch): - monkeypatch.setenv("APS_AUTH_PROVIDER", "mock") monkeypatch.setenv("APS_LICENSE_PROVIDER", "mock") - monkeypatch.setenv("APS_MOCK_AUTH_SECRET", "user-test-secret") monkeypatch.setenv("APS_MOCK_LICENSE_SECRET", "license-test-secret") monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-license-00000000000000001") monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "license.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) - from server.auth import licenses, providers + from server.auth import licenses from server.db.database import reset_engine from server.state import store as world_store - providers._provider = None - providers._provider_mode = None + install_test_auth(monkeypatch, "tenant-license-00000000000000001") licenses._provider = None licenses._provider_mode = None world_store._stores.clear() @@ -35,8 +34,6 @@ def license_app(tmp_path, monkeypatch): reset_engine() world_store._stores.clear() - providers._provider = None - providers._provider_mode = None licenses._provider = None licenses._provider_mode = None diff --git a/tests/golden/test_jms_auth_provider.py b/tests/golden/test_jms_auth_provider.py new file mode 100644 index 0000000..1acf8fa --- /dev/null +++ b/tests/golden/test_jms_auth_provider.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from fastapi.testclient import TestClient + +from server.auth.providers import AuthError, JmsAuthProvider +from server.integrations.jms_auth_client import JmsAuthClient, JmsAuthClientError + + +class FakeJmsAuthClient: + def __init__(self) -> None: + self.login_payload: dict[str, Any] | None = None + self.logged_out = False + self.action = "" + self.user_state = "0" + self.tenant_state = "0" + + async def precheck(self, *, tenant_code: str, username: str = "") -> dict[str, Any]: + assert tenant_code == "tenant-code" + return {"requireCaptcha": username == "captcha-user"} + + async def tenants(self) -> list[dict[str, Any]]: + return [ + { + "code": "tenant-code", "name": "测试租户", "state": "0", "deleted": 0, + "contact": "不得透传", + }, + {"code": "disabled", "name": "停用租户", "state": "1", "deleted": 0}, + ] + + async def captcha(self) -> dict[str, str]: + return {"captchaId": "captcha-id", "image": "data:image/png;base64,AA=="} + + async def login(self, **payload: Any) -> dict[str, Any]: + self.login_payload = payload + return { + "tokenName": "X-JMS-Token", + "tokenValue": "remote-token", + "action": self.action, + } + + async def profile(self, *, token_name: str, token_value: str) -> dict[str, Any]: + assert (token_name, token_value) == ("X-JMS-Token", "remote-token") + return { + "id": "9007199254740001", + "username": "operator", + "fullname": "计划员", + "state": self.user_state, + "roles": [{"code": "planner"}, {"code": "scheduler"}], + } + + async def tenant(self, *, token_name: str, token_value: str) -> dict[str, Any]: + assert (token_name, token_value) == ("X-JMS-Token", "remote-token") + return {"uuid": "tenant-uuid-001", "code": "tenant-code", "state": self.tenant_state} + + async def logout(self, *, token_name: str, token_value: str) -> None: + assert (token_name, token_value) == ("X-JMS-Token", "remote-token") + self.logged_out = True + + +@pytest.fixture() +def jms_provider(monkeypatch) -> tuple[JmsAuthProvider, FakeJmsAuthClient]: + monkeypatch.delenv("JMS_AUTH_TENANT_CODE", raising=False) + monkeypatch.delenv("JMS_AUTH_TENANT_NAME", raising=False) + monkeypatch.setenv("JMS_AUTH_SESSION_SECRET", "test-session-secret-with-at-least-32-chars") + fake = FakeJmsAuthClient() + return JmsAuthProvider(client=fake), fake # type: ignore[arg-type] + + +def test_gateway_uses_real_login_contract_and_revalidates_session(jms_provider, monkeypatch): + provider, fake = jms_provider + import server.auth.middleware as auth_middleware + import server.gateway.app as gateway_app + + monkeypatch.setattr(auth_middleware, "get_auth_provider", lambda: provider) + monkeypatch.setattr(gateway_app, "get_auth_provider", lambda: provider) + client = TestClient(gateway_app.create_app()) + + assert client.get("/api/auth/tenants").json() == { + "tenants": [ + {"code": "platform", "name": "平台"}, + {"code": "tenant-code", "name": "测试租户"}, + ], + } + assert client.get("/api/auth/login/precheck", params={ + "username": "operator", "tenantCode": "tenant-code", + }).json() == { + "requireCaptcha": False, + } + assert client.get("/api/auth/captcha").json() == { + "captchaId": "captcha-id", + "image": "data:image/png;base64,AA==", + } + response = client.post("/api/auth/login", json={ + "method": "password", + "tenantCode": "tenant-code", + "username": "operator", + "password": "secret", + "captchaId": "captcha-id", + "captchaCode": "A7K9", + }) + assert response.status_code == 200 + assert response.json()["user"] == { + "user_id": "9007199254740001", + "username": "operator", + "fullname": "计划员", + "tenant_uuid": "tenant-uuid-001", + "roles": ["planner", "scheduler"], + "expires_at": None, + "auth_kind": "user", + "license_type": None, + "license_activated_at": None, + "license_expires_at": None, + "activation_id": None, + } + assert fake.login_payload == { + "tenant_code": "tenant-code", + "tenant_name": "测试租户", + "username": "operator", + "password": "secret", + "captcha_id": "captcha-id", + "captcha_code": "A7K9", + } + assert client.get("/api/auth/me").status_code == 200 + assert client.post("/api/auth/refresh").status_code == 200 + assert client.post("/api/auth/logout").status_code == 200 + assert fake.logged_out is True + assert client.get("/api/auth/me").status_code == 401 + + +@pytest.mark.asyncio +async def test_provider_rejects_disabled_user_and_required_security_action(jms_provider): + provider, fake = jms_provider + fake.user_state = "1" + with pytest.raises(AuthError, match="用户已被停用") as disabled: + await provider.login({ + "method": "password", "tenantCode": "tenant-code", + "username": "operator", "password": "secret", + }) + assert disabled.value.status_code == 403 + + fake.user_state = "0" + fake.action = "changePassword" + with pytest.raises(AuthError, match="changePassword") as action: + await provider.login({ + "method": "password", "tenantCode": "tenant-code", + "username": "operator", "password": "secret", + }) + assert action.value.status_code == 409 + assert fake.logged_out is True + + +@pytest.mark.asyncio +async def test_jms_http_client_sends_documented_query_body_and_token_header(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = request.url.path + if path == "/admin/index/tenant": + return httpx.Response(200, json={"code": 0, "data": [{ + "code": "tenant-code", "name": "测试租户", "state": 0, + }]}) + if path == "/admin/index/login/precheck": + return httpx.Response(200, json={"code": 0, "data": {"requireCaptcha": False}}) + if path == "/admin/index/captcha": + return httpx.Response(200, json={"code": 0, "data": { + "captchaId": "cid", "image": "data:image/png;base64,AA==", + }}) + if path == "/admin/index/login": + return httpx.Response(200, json={"code": 0, "data": { + "tokenName": "X-JMS-Token", "tokenValue": "remote-token", + }}) + if path == "/admin/auth/profile": + return httpx.Response(200, json={"code": 0, "data": {"id": "1"}}) + if path == "/admin/auth/tenant": + return httpx.Response(200, json={"code": 0, "data": {"uuid": "tenant"}}) + if path == "/admin/auth/logout": + return httpx.Response(200, json={"code": 0, "data": None}) + raise AssertionError(path) + + client = JmsAuthClient( + base_url="https://jms.example.test", + transport=httpx.MockTransport(handler), + ) + await client.tenants() + await client.precheck(tenant_code="tenant-code", username="operator") + await client.captcha() + await client.login( + tenant_code="tenant-code", + tenant_name="测试租户", + username="operator", + password="secret", + captcha_id="cid", + captcha_code="A7K9", + ) + await client.profile(token_name="X-JMS-Token", token_value="remote-token") + await client.tenant(token_name="X-JMS-Token", token_value="remote-token") + await client.logout(token_name="X-JMS-Token", token_value="remote-token") + + login_request = requests[3] + assert dict(login_request.url.params) == {} + assert login_request.headers["content-type"].startswith("application/x-www-form-urlencoded") + assert dict(httpx.QueryParams(login_request.content.decode("utf-8"))) == { + "tenantCode": "tenant-code", + "tenantName": "测试租户", + "username": "operator", + "password": "secret", + "device": "PC", + "captchaId": "cid", + "captchaCode": "A7K9", + } + assert all(request.headers["X-JMS-Token"] == "remote-token" for request in requests[4:]) + + +@pytest.mark.asyncio +async def test_provider_supports_platform_tenant_missing_from_public_list(jms_provider): + provider, _ = jms_provider + assert await provider._resolve_tenant("platform") == { + "code": "platform", + "name": "平台", + } + + +@pytest.mark.asyncio +async def test_jms_http_client_rejects_remote_errors(): + transport = httpx.MockTransport(lambda request: httpx.Response( + 401, json={"code": 401, "message": "未提供登录凭证"}, + )) + client = JmsAuthClient(base_url="https://jms.example.test", transport=transport) + with pytest.raises(JmsAuthClientError, match="未提供登录凭证") as exc: + await client.profile(token_name="X-JMS-Token", token_value="bad") + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("envelope", "expected_code", "expected_message", "expected_status"), + [ + ( + {"code": 500, "message": "系统异常,请稍后重试", "error": [ + '400 BAD_REQUEST "Validation failure"', + ]}, + "AUTH_INVALID_CREDENTIALS", "账号或密码错误", 401, + ), + ( + {"code": 400, "message": "图形验证码错误"}, + "AUTH_CAPTCHA_INVALID", "验证码错误,请重新输入", 400, + ), + ( + {"code": 500, "message": "系统异常,请稍后重试"}, + "AUTH_UPSTREAM_ERROR", "认证服务暂时不可用,请稍后重试", 502, + ), + ], +) +async def test_jms_http_client_translates_login_failures( + envelope, expected_code, expected_message, expected_status, +): + transport = httpx.MockTransport(lambda request: httpx.Response(500, json=envelope)) + client = JmsAuthClient(base_url="https://jms.example.test", transport=transport) + with pytest.raises(JmsAuthClientError, match=expected_message) as exc: + await client.login( + tenant_code="tenant-code", + tenant_name="测试租户", + username="operator", + password="wrong-password", + ) + assert exc.value.code == expected_code + assert exc.value.status_code == expected_status