feat(auth): integrate JMS login

This commit is contained in:
z.zhang 2026-07-29 23:22:40 +08:00
parent 2a8160ee0d
commit 3a73fdd766
16 changed files with 1197 additions and 236 deletions

View File

@ -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(默认拒绝)。

View File

@ -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

View File

@ -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<void>;
clearError: () => void;
activateLicense: (code: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
@ -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<AuthContextValue>(
() => ({ 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 <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
}
@ -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<TenantOption[]>([]);
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<CaptchaChallenge | null>(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 }) {
<span className="login-brand-mark" aria-hidden="true">智</span>
<div><h1 id="login-title">登录工业智核</h1><p>APS Agent</p></div>
</header>
<div className="login-mode" role="tablist" aria-label="登录方式">
<button type="button" className={mode === 'password' ? 'active' : ''}
onClick={() => { setMode('password'); setFeedback(''); }}>账号登录</button>
<button type="button" className={mode === 'phone' ? 'active' : ''}
onClick={() => { setMode('phone'); setFeedback(''); }}>手机号登录</button>
</div>
<form className="login-form" onSubmit={submit}>
{mode === 'password' ? (
<>
<label><span>账号</span><div><UserOutlined /><input autoFocus autoComplete="username"
value={username} onChange={event => setUsername(event.target.value)} placeholder="用户名 / 手机号" /></div></label>
<label><span>密码</span><div><LockOutlined /><input type="password" autoComplete="current-password"
value={password} onChange={event => setPassword(event.target.value)} placeholder="请输入密码" /></div></label>
</>
) : (
<>
<label><span>手机号</span><div><MobileOutlined /><input autoFocus inputMode="numeric" maxLength={11}
value={mobile} onChange={event => setMobile(event.target.value.replace(/\D/g, ''))} placeholder="请输入手机号" /></div></label>
<label><span>验证码</span><div><SafetyCertificateOutlined /><input inputMode="numeric" maxLength={6}
value={code} onChange={event => setCode(event.target.value.replace(/\D/g, ''))} placeholder="6 位验证码" />
<button type="button" className="login-code" onClick={sendCode}>获取验证码</button></div></label>
</>
<label><span>企业</span><div><BankOutlined /><input autoFocus autoComplete="organization"
value={tenantName} disabled={busy || tenantLoading} onChange={event => {
setTenantName(event.target.value);
setFeedback('');
setCaptchaRequired(false);
setCaptcha(null);
setCaptchaCode('');
}} placeholder={tenantLoading ? '正在加载企业信息...' : '请输入完整企业名称'} /></div></label>
<label><span>账号</span><div><UserOutlined /><input autoComplete="username"
value={username} onChange={event => setUsername(event.target.value)} placeholder="请输入用户名" /></div></label>
<label><span>密码</span><div className="login-password-field"><LockOutlined />
<input type={passwordVisible ? 'text' : 'password'} autoComplete="current-password"
value={password} onChange={event => setPassword(event.target.value)} placeholder="请输入密码" />
<button type="button" className="login-password-toggle"
aria-label={passwordVisible ? '隐藏密码' : '显示密码'}
title={passwordVisible ? '隐藏密码' : '显示密码'}
onClick={() => setPasswordVisible(value => !value)}>
{passwordVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
</button>
</div></label>
{captchaRequired && (
<label><span>验证码</span><div className="login-captcha-field"><SafetyCertificateOutlined />
<input inputMode="text" maxLength={32} autoComplete="off"
value={captchaCode} onChange={event => setCaptchaCode(event.target.value)} placeholder="图形验证码" />
<button type="button" className="login-captcha-image" title="换一张验证码"
aria-label="换一张验证码" disabled={busy} onClick={() => {
setFeedback('');
void loadCaptcha().catch(err => setFeedback(err instanceof Error ? err.message : '验证码加载失败'));
}}>
{captcha && <img src={captcha.image} alt="图形验证码" />}
</button>
</div></label>
)}
<button className="login-submit" type="submit" disabled={busy || (mode === 'password' ? !username.trim() || !password : !mobile || !code)}>
<button className="login-submit" type="submit"
disabled={busy || tenantLoading || !tenantName.trim() || !username.trim() || !password
|| (captchaRequired && !captchaCode.trim())}>
{busy ? '登录中...' : '登录'}
</button>
</form>
<div className={`login-feedback${feedback || auth.error ? ' visible' : ''}`} role="status">
{feedback || auth.error || ' '}
{(feedback || auth.error) && <><WarningOutlined /><span>{feedback || auth.error}</span></>}
</div>
</section>
</div>

View File

@ -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 {

View File

@ -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

View File

@ -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,
},

View File

@ -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',

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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 会话流 ----------------

View File

@ -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,
)

110
tests/auth_provider.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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