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_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(默认拒绝)。
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -54,17 +54,20 @@ npm run bootstrap # 安装 apps/web + apps/desktop 依赖
|
|||
|
||||
### 2. 开发运行
|
||||
|
||||
本地联调用户与项目分享功能前,在 `.env` 中显式设置:
|
||||
联调 Web 用户登录前,在 `.env` 中设置 JMS 地址与本地会话密钥:
|
||||
|
||||
```bash
|
||||
APS_AUTH_PROVIDER=mock
|
||||
APS_AUTH_PROVIDER=jms
|
||||
JMS_AUTH_BASE_URL=https://jms-mes-api.jzplat.com
|
||||
JMS_AUTH_SESSION_SECRET=至少32个随机字符
|
||||
APS_LICENSE_PROVIDER=mock
|
||||
APS_MOCK_TENANT_UUID=demo0000000000000000000000000001
|
||||
```
|
||||
|
||||
默认 `APS_AUTH_PROVIDER=unconfigured` 会关闭全部业务 API,直到正式用户管理接口完成配置。
|
||||
Web 开发账号可使用 `planner` / `collaborator` / `viewer`(密码任意非空);桌面端不使用账号,
|
||||
开发授权码为 `APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、
|
||||
Web 端会从 JMS 读取启用企业,用户选择所属企业后使用真实账号密码登录;如需限制为单一企业,
|
||||
可配置 `JMS_AUTH_TENANT_CODE` 与 `JMS_AUTH_TENANT_NAME`。JMS 当前没有自助注册或未登录自助
|
||||
找回密码接口,账号创建与密码重置由所属企业管理员处理。
|
||||
桌面端不使用账号,开发授权码为
|
||||
`APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、
|
||||
`APS-YEAR-DEMO`、`APS-PERP-DEMO`。
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 会话流 ----------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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