feat(auth): integrate JMS login
This commit is contained in:
parent
2a8160ee0d
commit
3a73fdd766
21
.env.example
21
.env.example
|
|
@ -31,15 +31,22 @@ APS_MODE=web
|
||||||
APS_PORT=8000
|
APS_PORT=8000
|
||||||
APS_WORLD_PATH=server/data/world.json
|
APS_WORLD_PATH=server/data/world.json
|
||||||
|
|
||||||
# ---- 用户认证与租户隔离 ----
|
# ---- 用户认证与租户隔离(JMS) ----
|
||||||
# unconfigured:认证接口未接入,所有业务 API 默认拒绝访问
|
# jms:调用真实 JMS 登录接口;unconfigured:关闭 Web 认证和业务 API
|
||||||
# mock:仅本地开发;生产环境禁止使用
|
APS_AUTH_PROVIDER=jms
|
||||||
APS_AUTH_PROVIDER=unconfigured
|
|
||||||
APS_AUTH_COOKIE_SECURE=0
|
APS_AUTH_COOKIE_SECURE=0
|
||||||
APS_AUTH_TTL_SECONDS=28800
|
APS_AUTH_TTL_SECONDS=28800
|
||||||
# Mock 模式下的固定租户;可通过 APS_MOCK_USERS JSON 配置测试成员
|
JMS_AUTH_BASE_URL=https://jms-mes-api.jzplat.com
|
||||||
APS_MOCK_TENANT_UUID=demo0000000000000000000000000001
|
# 可选:固定为单一租户;留空时登录页从 JMS 公开租户接口加载企业列表
|
||||||
# APS_MOCK_USERS=[{"id":1001,"username":"planner","fullname":"计划员","mobile":"13800000001"}]
|
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(默认拒绝)。
|
# Web 用户认证与桌面授权相互独立。正式环境接入授权中心前保持 unconfigured(默认拒绝)。
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -54,17 +54,20 @@ npm run bootstrap # 安装 apps/web + apps/desktop 依赖
|
||||||
|
|
||||||
### 2. 开发运行
|
### 2. 开发运行
|
||||||
|
|
||||||
本地联调用户与项目分享功能前,在 `.env` 中显式设置:
|
联调 Web 用户登录前,在 `.env` 中设置 JMS 地址与本地会话密钥:
|
||||||
|
|
||||||
```bash
|
```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_LICENSE_PROVIDER=mock
|
||||||
APS_MOCK_TENANT_UUID=demo0000000000000000000000000001
|
|
||||||
```
|
```
|
||||||
|
|
||||||
默认 `APS_AUTH_PROVIDER=unconfigured` 会关闭全部业务 API,直到正式用户管理接口完成配置。
|
Web 端会从 JMS 读取启用企业,用户选择所属企业后使用真实账号密码登录;如需限制为单一企业,
|
||||||
Web 开发账号可使用 `planner` / `collaborator` / `viewer`(密码任意非空);桌面端不使用账号,
|
可配置 `JMS_AUTH_TENANT_CODE` 与 `JMS_AUTH_TENANT_NAME`。JMS 当前没有自助注册或未登录自助
|
||||||
开发授权码为 `APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、
|
找回密码接口,账号创建与密码重置由所属企业管理员处理。
|
||||||
|
桌面端不使用账号,开发授权码为
|
||||||
|
`APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、
|
||||||
`APS-YEAR-DEMO`、`APS-PERP-DEMO`。
|
`APS-YEAR-DEMO`、`APS-PERP-DEMO`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import {
|
||||||
createContext, type FormEvent, type ReactNode, useCallback, useContext, useEffect, useMemo, useState,
|
createContext, type FormEvent, type ReactNode, useCallback, useContext, useEffect, useMemo, useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
CloseOutlined, KeyOutlined, LockOutlined, MobileOutlined, SafetyCertificateOutlined, UserOutlined,
|
BankOutlined, CloseOutlined, EyeInvisibleOutlined, EyeOutlined, KeyOutlined, LockOutlined,
|
||||||
|
SafetyCertificateOutlined, UserOutlined, WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { clientFetch, getClientMode, type ClientMode } from '../api/client';
|
import { clientFetch, getClientMode, type ClientMode } from '../api/client';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
user_id: number;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
tenant_uuid: string;
|
tenant_uuid: string;
|
||||||
|
|
@ -34,13 +35,17 @@ export type LicenseInfo = {
|
||||||
type AuthStatus = 'loading' | 'anonymous' | 'authenticated';
|
type AuthStatus = 'loading' | 'anonymous' | 'authenticated';
|
||||||
|
|
||||||
type LoginPayload = {
|
type LoginPayload = {
|
||||||
method: 'password' | 'phone';
|
method: 'password';
|
||||||
username?: string;
|
tenantCode: string;
|
||||||
password?: string;
|
username: string;
|
||||||
mobile?: string;
|
password: string;
|
||||||
code?: string;
|
captchaId?: string;
|
||||||
|
captchaCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CaptchaChallenge = { captchaId: string; image: string };
|
||||||
|
type TenantOption = { code: string; name: string };
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
|
@ -49,6 +54,7 @@ type AuthContextValue = {
|
||||||
provider: string;
|
provider: string;
|
||||||
error: string;
|
error: string;
|
||||||
login: (payload: LoginPayload) => Promise<void>;
|
login: (payload: LoginPayload) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
activateLicense: (code: string) => Promise<void>;
|
activateLicense: (code: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
|
@ -80,6 +86,7 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
const clientMode = getClientMode();
|
const clientMode = getClientMode();
|
||||||
const [provider, setProvider] = useState('unconfigured');
|
const [provider, setProvider] = useState('unconfigured');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const clearError = useCallback(() => setError(''), []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
|
@ -180,8 +187,8 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({ 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, activateLicense, logout, refresh],
|
[status, user, license, clientMode, provider, error, login, clearError, activateLicense, logout, refresh],
|
||||||
);
|
);
|
||||||
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
|
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 }) {
|
export function LoginDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [mode, setMode] = useState<'password' | 'phone'>('password');
|
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||||
const [username, setUsername] = useState(auth.provider === 'mock' ? 'planner' : '');
|
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 [password, setPassword] = useState('');
|
||||||
const [mobile, setMobile] = useState(auth.provider === 'mock' ? '13800000001' : '');
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [captchaRequired, setCaptchaRequired] = useState(false);
|
||||||
|
const [captcha, setCaptcha] = useState<CaptchaChallenge | null>(null);
|
||||||
|
const [captchaCode, setCaptchaCode] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.provider === 'mock') {
|
if (!props.open) return;
|
||||||
setUsername(value => value || 'planner');
|
auth.clearError();
|
||||||
setMobile(value => value || '13800000001');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!props.open) return;
|
if (!props.open) return;
|
||||||
|
|
@ -222,31 +286,40 @@ export function LoginDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setFeedback('');
|
setFeedback('');
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let loginAttempted = false;
|
||||||
try {
|
try {
|
||||||
await auth.login(mode === 'password'
|
const tenant = resolveTenant(tenantName);
|
||||||
? { method: 'password', username: username.trim(), password }
|
const required = await runPrecheck(username, tenant.code);
|
||||||
: { method: 'phone', mobile: mobile.trim(), 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();
|
props.onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFeedback(err instanceof Error ? err.message : '登录失败');
|
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 {
|
} finally {
|
||||||
setBusy(false);
|
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;
|
if (!props.open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -264,35 +337,48 @@ export function LoginDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
<span className="login-brand-mark" aria-hidden="true">智</span>
|
<span className="login-brand-mark" aria-hidden="true">智</span>
|
||||||
<div><h1 id="login-title">登录工业智核</h1><p>APS Agent</p></div>
|
<div><h1 id="login-title">登录工业智核</h1><p>APS Agent</p></div>
|
||||||
</header>
|
</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}>
|
<form className="login-form" onSubmit={submit}>
|
||||||
{mode === 'password' ? (
|
<label><span>企业</span><div><BankOutlined /><input autoFocus autoComplete="organization"
|
||||||
<>
|
value={tenantName} disabled={busy || tenantLoading} onChange={event => {
|
||||||
<label><span>账号</span><div><UserOutlined /><input autoFocus autoComplete="username"
|
setTenantName(event.target.value);
|
||||||
value={username} onChange={event => setUsername(event.target.value)} placeholder="用户名 / 手机号" /></div></label>
|
setFeedback('');
|
||||||
<label><span>密码</span><div><LockOutlined /><input type="password" autoComplete="current-password"
|
setCaptchaRequired(false);
|
||||||
value={password} onChange={event => setPassword(event.target.value)} placeholder="请输入密码" /></div></label>
|
setCaptcha(null);
|
||||||
</>
|
setCaptchaCode('');
|
||||||
) : (
|
}} placeholder={tenantLoading ? '正在加载企业信息...' : '请输入完整企业名称'} /></div></label>
|
||||||
<>
|
<label><span>账号</span><div><UserOutlined /><input autoComplete="username"
|
||||||
<label><span>手机号</span><div><MobileOutlined /><input autoFocus inputMode="numeric" maxLength={11}
|
value={username} onChange={event => setUsername(event.target.value)} placeholder="请输入用户名" /></div></label>
|
||||||
value={mobile} onChange={event => setMobile(event.target.value.replace(/\D/g, ''))} placeholder="请输入手机号" /></div></label>
|
<label><span>密码</span><div className="login-password-field"><LockOutlined />
|
||||||
<label><span>验证码</span><div><SafetyCertificateOutlined /><input inputMode="numeric" maxLength={6}
|
<input type={passwordVisible ? 'text' : 'password'} autoComplete="current-password"
|
||||||
value={code} onChange={event => setCode(event.target.value.replace(/\D/g, ''))} placeholder="6 位验证码" />
|
value={password} onChange={event => setPassword(event.target.value)} placeholder="请输入密码" />
|
||||||
<button type="button" className="login-code" onClick={sendCode}>获取验证码</button></div></label>
|
<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 ? '登录中...' : '登录'}
|
{busy ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className={`login-feedback${feedback || auth.error ? ' visible' : ''}`} role="status">
|
<div className={`login-feedback${feedback || auth.error ? ' visible' : ''}`} role="status">
|
||||||
{feedback || auth.error || ' '}
|
{(feedback || auth.error) && <><WarningOutlined /><span>{feedback || auth.error}</span></>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 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-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 { display: grid; gap: 14px; }
|
||||||
.login-form label > span { display: block; margin-bottom: 6px; color: #66635c; font-size: 12px; }
|
.login-form label > span { display: block; margin-bottom: 6px; color: #66635c; font-size: 12px; }
|
||||||
.login-form label > div {
|
.login-form label > div {
|
||||||
|
|
@ -3139,8 +3119,52 @@ table.grid tr:last-child td { border-bottom: none; }
|
||||||
color: #969288;
|
color: #969288;
|
||||||
}
|
}
|
||||||
.login-form label > div:focus-within { border-color: #f97316; box-shadow: 0 0 0 3px #fff0e6; }
|
.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-form input,
|
||||||
.login-code { flex: 0 0 auto; border: 0; background: transparent; color: #c6510b; cursor: pointer; font: inherit; font-size: 12px; }
|
.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 {
|
.login-submit {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
margin-top: 3px;
|
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:hover:not(:disabled) { background: #de5f0b; }
|
||||||
.login-submit:disabled { background: #dedcd6; color: #9d9a91; cursor: default; }
|
.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 {
|
||||||
.login-feedback.visible { visibility: visible; }
|
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-dialog { width: min(420px, calc(100vw - 32px)); }
|
||||||
.license-brand-mark { font-size: 19px; }
|
.license-brand-mark { font-size: 19px; }
|
||||||
.license-summary {
|
.license-summary {
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,29 @@
|
||||||
|
|
||||||
## Authentication providers
|
## Authentication providers
|
||||||
|
|
||||||
`APS_AUTH_PROVIDER=unconfigured` is the production-safe default. Login returns
|
`APS_AUTH_PROVIDER=jms` enables the real JMS account/password flow. The server calls
|
||||||
`AUTH_NOT_CONFIGURED` and every business API remains closed.
|
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
|
The JMS authentication adapter implements these operations:
|
||||||
development cookie and exposes the users configured by `APS_MOCK_USERS`.
|
|
||||||
|
|
||||||
The external user-management integration must implement these adapter operations:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
login precheck / captcha
|
||||||
|
public tenant discovery
|
||||||
login
|
login
|
||||||
authenticate / verify_token
|
authenticate via profile + tenant revalidation
|
||||||
refresh
|
local session refresh
|
||||||
logout
|
remote logout
|
||||||
search_users
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
The normalized identity is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ function run(label, command, args, env = {}) {
|
||||||
...process.env,
|
...process.env,
|
||||||
APS_MODE: 'desktop',
|
APS_MODE: 'desktop',
|
||||||
APS_HOME: home,
|
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',
|
APS_LICENSE_PROVIDER: process.env.APS_LICENSE_PROVIDER || 'mock',
|
||||||
...env,
|
...env,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ function run(label, command, args, env = {}) {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
APS_MODE: 'web',
|
APS_MODE: 'web',
|
||||||
APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'mock',
|
APS_AUTH_PROVIDER: process.env.APS_AUTH_PROVIDER || 'jms',
|
||||||
...env,
|
...env,
|
||||||
},
|
},
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class IdentityContext:
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
|
data["user_id"] = str(self.user_id)
|
||||||
data["roles"] = list(self.roles)
|
data["roles"] = list(self.roles)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ from server.auth.providers import AuthError, get_auth_provider
|
||||||
PUBLIC_API_PATHS = {
|
PUBLIC_API_PATHS = {
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
|
"/api/auth/login/precheck",
|
||||||
|
"/api/auth/tenants",
|
||||||
|
"/api/auth/captcha",
|
||||||
"/api/auth/refresh",
|
"/api/auth/refresh",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/api/auth/license/activate",
|
"/api/auth/license/activate",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -12,6 +13,7 @@ from typing import Any
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from server.auth.context import IdentityContext
|
from server.auth.context import IdentityContext
|
||||||
|
from server.integrations.jms_auth_client import JmsAuthClient, JmsAuthClientError
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception):
|
class AuthError(Exception):
|
||||||
|
|
@ -33,30 +35,31 @@ class AuthProvider(ABC):
|
||||||
async def authenticate(self, request: Request) -> IdentityContext:
|
async def authenticate(self, request: Request) -> IdentityContext:
|
||||||
raise NotImplementedError
|
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]:
|
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
|
||||||
identity = await self.authenticate(request)
|
await self.authenticate(request)
|
||||||
raise AuthError("AUTH_NOT_CONFIGURED", "刷新接口尚未配置", 503)
|
raise AuthError("AUTH_NOT_CONFIGURED", "刷新接口尚未配置", 503)
|
||||||
|
|
||||||
async def logout(self, request: Request) -> None:
|
async def logout(self, request: Request) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def search_users(self, query: str, identity: IdentityContext) -> list[dict[str, Any]]:
|
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):
|
class UnconfiguredAuthProvider(AuthProvider):
|
||||||
async def login(self, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
|
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:
|
async def authenticate(self, request: Request) -> IdentityContext:
|
||||||
raise AuthError("AUTH_NOT_CONFIGURED", "用户管理接口尚未配置", 503)
|
raise AuthError("AUTH_NOT_CONFIGURED", "JMS 用户认证尚未配置", 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"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(raw: bytes) -> str:
|
def _b64encode(raw: bytes) -> str:
|
||||||
|
|
@ -67,122 +70,295 @@ def _b64decode(raw: str) -> bytes:
|
||||||
return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
|
return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
|
||||||
|
|
||||||
|
|
||||||
class MockAuthProvider(AuthProvider):
|
class JmsAuthProvider(AuthProvider):
|
||||||
"""Development-only provider. It is enabled only by APS_AUTH_PROVIDER=mock."""
|
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, client: JmsAuthClient | None = None) -> None:
|
||||||
|
self.client: JmsAuthClient | None = client
|
||||||
def __init__(self) -> None:
|
self.client_config_error: AuthError | None = None
|
||||||
self.secret = (os.environ.get("APS_MOCK_AUTH_SECRET") or "aps-local-development-only").encode()
|
if self.client is None:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
rows = json.loads(configured)
|
self.client = JmsAuthClient()
|
||||||
if isinstance(rows, list):
|
except JmsAuthClientError as exc:
|
||||||
return [row for row in rows if isinstance(row, dict)]
|
self.client_config_error = AuthError(exc.code, exc.message, exc.status_code)
|
||||||
except json.JSONDecodeError:
|
self.tenant_code = (os.environ.get("JMS_AUTH_TENANT_CODE") or "").strip()
|
||||||
pass
|
self.tenant_name = (os.environ.get("JMS_AUTH_TENANT_NAME") or "").strip()
|
||||||
return [dict(user) for user in DEFAULT_MOCK_USERS]
|
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:
|
def _get_client(self) -> JmsAuthClient:
|
||||||
identifier = str(
|
if self.client_config_error is not None:
|
||||||
payload.get("username") or payload.get("mobile") or payload.get("identifier") or "planner"
|
raise self.client_config_error
|
||||||
).strip()
|
if self.client is None:
|
||||||
users = self._directory()
|
raise AuthError("AUTH_CONFIG_INVALID", "JMS 认证客户端未正确配置", 503)
|
||||||
user = next(
|
return self.client
|
||||||
(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 _issue(self, identity: IdentityContext) -> str:
|
def _require_login_config(self) -> None:
|
||||||
payload = identity.to_dict()
|
missing = []
|
||||||
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
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)
|
body = _b64encode(raw)
|
||||||
sig = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest())
|
signature = _b64encode(hmac.new(self.session_secret, body.encode("ascii"), hashlib.sha256).digest())
|
||||||
return f"{body}.{sig}"
|
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:
|
try:
|
||||||
body, signature = token.split(".", 1)
|
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):
|
if not hmac.compare_digest(signature, expected):
|
||||||
raise ValueError("signature")
|
raise ValueError("signature")
|
||||||
payload = json.loads(_b64decode(body))
|
payload = json.loads(_b64decode(body))
|
||||||
expires_at = int(payload.get("expires_at") or 0)
|
if int(payload.get("expiresAt") or 0) <= int(time.time()):
|
||||||
if expires_at <= int(time.time()):
|
|
||||||
raise AuthError("AUTH_EXPIRED", "登录状态已过期", 401)
|
raise AuthError("AUTH_EXPIRED", "登录状态已过期", 401)
|
||||||
return IdentityContext(
|
token_name = str(payload["tokenName"])
|
||||||
user_id=int(payload["user_id"]),
|
token_value = str(payload["tokenValue"])
|
||||||
username=str(payload["username"]),
|
tenant_code = str(payload["tenantCode"])
|
||||||
fullname=str(payload.get("fullname") or payload["username"]),
|
if not token_value or not tenant_code:
|
||||||
tenant_uuid=str(payload["tenant_uuid"]),
|
raise ValueError("token")
|
||||||
roles=tuple(payload.get("roles") or ()),
|
self._validate_token_name(token_name)
|
||||||
expires_at=expires_at,
|
return token_name, token_value, tenant_code
|
||||||
)
|
|
||||||
except AuthError:
|
except AuthError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AuthError("AUTH_INVALID", "登录凭证无效", 401) from exc
|
raise AuthError("AUTH_INVALID", "登录凭证无效", 401) from exc
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _request_token(request: Request) -> str:
|
def _client_error(exc: JmsAuthClientError, *, login: bool = False) -> AuthError:
|
||||||
auth = request.headers.get("authorization") or ""
|
if login and exc.status_code in {400, 401, 403}:
|
||||||
if auth.lower().startswith("bearer "):
|
return AuthError("AUTH_LOGIN_FAILED", exc.message, 401)
|
||||||
return auth[7:].strip()
|
if exc.status_code in {400, 401, 403}:
|
||||||
return request.cookies.get("aps_session") or ""
|
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]:
|
async def login(self, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
|
||||||
identity = self._identity_for(payload)
|
self._require_login_config()
|
||||||
return identity, self._issue(identity)
|
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:
|
async def authenticate(self, request: Request) -> IdentityContext:
|
||||||
token = self._request_token(request)
|
token_name, token_value, tenant_code = self._read_session(request)
|
||||||
if not token:
|
try:
|
||||||
raise AuthError("AUTH_REQUIRED", "请先登录", 401)
|
client = self._get_client()
|
||||||
return self._verify(token)
|
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]:
|
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
|
||||||
current = await self.authenticate(request)
|
token_name, token_value, tenant_code = self._read_session(request)
|
||||||
identity = IdentityContext(
|
identity = await self.authenticate(request)
|
||||||
user_id=current.user_id,
|
return identity, self._issue_session(token_name, token_value, tenant_code)
|
||||||
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]
|
|
||||||
|
|
||||||
|
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: AuthProvider | None = None
|
||||||
_provider_mode: str | None = None
|
_provider_mode: str | None = None
|
||||||
|
|
@ -192,6 +368,6 @@ def get_auth_provider() -> AuthProvider:
|
||||||
global _provider, _provider_mode
|
global _provider, _provider_mode
|
||||||
mode = (os.environ.get("APS_AUTH_PROVIDER") or "unconfigured").strip().lower()
|
mode = (os.environ.get("APS_AUTH_PROVIDER") or "unconfigured").strip().lower()
|
||||||
if _provider is None or _provider_mode != mode:
|
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
|
_provider_mode = mode
|
||||||
return _provider
|
return _provider
|
||||||
|
|
|
||||||
|
|
@ -73,12 +73,12 @@ class ChatRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginRequest(BaseModel):
|
class AuthLoginRequest(BaseModel):
|
||||||
method: str = "phone"
|
method: str = "password"
|
||||||
username: str | None = None
|
tenantCode: str | None = Field(default=None, max_length=128)
|
||||||
password: str | None = None
|
username: str | None = Field(default=None, max_length=128)
|
||||||
mobile: str | None = None
|
password: str | None = Field(default=None, max_length=512)
|
||||||
code: str | None = None
|
captchaId: str | None = Field(default=None, max_length=128)
|
||||||
identifier: str | None = None
|
captchaCode: str | None = Field(default=None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
class LicenseActivationRequest(BaseModel):
|
class LicenseActivationRequest(BaseModel):
|
||||||
|
|
@ -410,6 +410,40 @@ def create_app() -> FastAPI:
|
||||||
set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
return {"user": identity.to_dict(), "provider": get_auth_provider().mode}
|
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")
|
@app.post("/api/auth/license/activate")
|
||||||
async def auth_license_activate(req: LicenseActivationRequest, request: Request, response: Response):
|
async def auth_license_activate(req: LicenseActivationRequest, request: Request, response: Response):
|
||||||
provider = get_license_provider()
|
provider = get_license_provider()
|
||||||
|
|
@ -464,7 +498,10 @@ def create_app() -> FastAPI:
|
||||||
@app.get("/api/auth/users")
|
@app.get("/api/auth/users")
|
||||||
async def auth_users(query: str = "") -> dict:
|
async def auth_users(query: str = "") -> dict:
|
||||||
identity = get_identity(required=True)
|
identity = get_identity(required=True)
|
||||||
|
try:
|
||||||
users = await get_auth_provider().search_users(query, identity)
|
users = await get_auth_provider().search_users(query, identity)
|
||||||
|
except AuthError as exc:
|
||||||
|
return auth_error_response(exc)
|
||||||
return {"users": users}
|
return {"users": users}
|
||||||
|
|
||||||
# ---------------- 对话:SSE 会话流 ----------------
|
# ---------------- 对话:SSE 会话流 ----------------
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.auth_provider import TestAuthProvider, install_test_auth
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def secure_app(tmp_path, monkeypatch):
|
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_DB_PATH", str(tmp_path / "tenant.db"))
|
||||||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||||
from server.auth import providers
|
|
||||||
from server.db.database import reset_engine
|
from server.db.database import reset_engine
|
||||||
from server.state import store as world_store
|
from server.state import store as world_store
|
||||||
|
|
||||||
providers._provider = None
|
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
|
||||||
providers._provider_mode = None
|
|
||||||
world_store._stores.clear()
|
world_store._stores.clear()
|
||||||
reset_engine()
|
reset_engine()
|
||||||
from server.gateway.app import create_app
|
from server.gateway.app import create_app
|
||||||
|
|
@ -26,8 +21,6 @@ def secure_app(tmp_path, monkeypatch):
|
||||||
yield app
|
yield app
|
||||||
reset_engine()
|
reset_engine()
|
||||||
world_store._stores.clear()
|
world_store._stores.clear()
|
||||||
providers._provider = None
|
|
||||||
providers._provider_mode = None
|
|
||||||
|
|
||||||
|
|
||||||
def login(client: TestClient, username: str) -> 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")
|
login(owner, "planner")
|
||||||
owner.post("/api/projects", json={"id": "proj_tenant_a", "name": "租户 A"})
|
owner.post("/api/projects", json={"id": "proj_tenant_a", "name": "租户 A"})
|
||||||
|
|
||||||
monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-b-000000000000000000000001")
|
import asyncio
|
||||||
from server.auth.providers import MockAuthProvider
|
_, token = asyncio.run(TestAuthProvider(
|
||||||
_, token = asyncio.run(MockAuthProvider().login({"username": "collaborator"}))
|
"tenant-b-000000000000000000000001",
|
||||||
|
).login({"username": "collaborator"}))
|
||||||
tenant_b = TestClient(secure_app)
|
tenant_b = TestClient(secure_app)
|
||||||
tenant_b.cookies.set("aps_session", token)
|
tenant_b.cookies.set("aps_session", token)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,22 @@ import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from tests.auth_provider import install_test_auth
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def license_app(tmp_path, monkeypatch):
|
def license_app(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("APS_AUTH_PROVIDER", "mock")
|
|
||||||
monkeypatch.setenv("APS_LICENSE_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_LICENSE_SECRET", "license-test-secret")
|
||||||
monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-license-00000000000000001")
|
monkeypatch.setenv("APS_MOCK_TENANT_UUID", "tenant-license-00000000000000001")
|
||||||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "license.db"))
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "license.db"))
|
||||||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
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.db.database import reset_engine
|
||||||
from server.state import store as world_store
|
from server.state import store as world_store
|
||||||
|
|
||||||
providers._provider = None
|
install_test_auth(monkeypatch, "tenant-license-00000000000000001")
|
||||||
providers._provider_mode = None
|
|
||||||
licenses._provider = None
|
licenses._provider = None
|
||||||
licenses._provider_mode = None
|
licenses._provider_mode = None
|
||||||
world_store._stores.clear()
|
world_store._stores.clear()
|
||||||
|
|
@ -35,8 +34,6 @@ def license_app(tmp_path, monkeypatch):
|
||||||
|
|
||||||
reset_engine()
|
reset_engine()
|
||||||
world_store._stores.clear()
|
world_store._stores.clear()
|
||||||
providers._provider = None
|
|
||||||
providers._provider_mode = None
|
|
||||||
licenses._provider = None
|
licenses._provider = None
|
||||||
licenses._provider_mode = None
|
licenses._provider_mode = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue