feat(license): 授权码即用户身份(codes 模式)与超管发码面板
- 新增 CodeLicenseProvider:每个授权码恒定派生唯一 user_id,会话/项目/主数据按码隔离, 同一授权码跨浏览器/设备激活找回同一数据;内置超管码 aps@Z@Agent@Admin(永久+全角色) - APS_LICENSE_CLIENTS 控制作用端(web/desktop),JMS 配置存在时 JMS 优先 - 设置面板新增「用户与授权」(仅 admin 可见):登记姓名/手机号/公司,预设时长或 自定义到期时间,自动生成授权码,停用即时生效,可删除并查看激活状态 - Web 端未激活弹「请提供授权码」激活卡片;Web 会话使用独立 aps_license_session Cookie - /api/health 授权配置缺失时兜底返回 invalid 标记,不再 500 - aps_license_codes 表入库管理,SQLite 老库自动补列迁移 - 新增 21 项端到端用例;golden 回归 2398 通过
This commit is contained in:
parent
724f3ebc91
commit
9bce0c5f68
10
.env.example
10
.env.example
|
|
@ -82,6 +82,16 @@ APS_LICENSE_PROVIDER=unconfigured
|
||||||
# mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。
|
# mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。
|
||||||
# APS_MOCK_LICENSE_SECRET=
|
# APS_MOCK_LICENSE_SECRET=
|
||||||
# APS_MOCK_LICENSE_TENANT_UUID=
|
# APS_MOCK_LICENSE_TENANT_UUID=
|
||||||
|
#
|
||||||
|
# ---- 授权码模式(codes:授权码即用户身份,Web/桌面均可激活)----
|
||||||
|
# 每个授权码唯一对应一个用户身份,智能体会话、项目、主数据全部绑定到该授权码并互相隔离;
|
||||||
|
# 同一授权码可在任意浏览器/设备激活并找回同一份数据。
|
||||||
|
# 内置超级管理员授权码 aps@Z@Agent@Admin(永久授权 + 全功能角色),其余授权码经 APS_LICENSE_CODES 发放。
|
||||||
|
# APS_LICENSE_PROVIDER=codes
|
||||||
|
# APS_LICENSE_SECRET=至少32个随机字符(HMAC 签名密钥,生产必须独立随机生成)
|
||||||
|
# APS_LICENSE_TENANT_UUID=platform
|
||||||
|
# APS_LICENSE_CLIENTS=web,desktop
|
||||||
|
# APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}}
|
||||||
|
|
||||||
# ---- 现场数据目录(可选;未配置时不绑定任何机器目录)----
|
# ---- 现场数据目录(可选;未配置时不绑定任何机器目录)----
|
||||||
# KANGNI_DATA_DIR=
|
# KANGNI_DATA_DIR=
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -72,6 +72,25 @@ Web 端会从 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`。
|
||||||
|
|
||||||
|
### 授权码部署(codes 模式:授权码即用户身份)
|
||||||
|
|
||||||
|
独立 Web 交付(不走 JMS)时可改用授权码认证:用户打开系统即看到"请提供授权码",
|
||||||
|
激活后智能体会话、项目、主数据全部绑定到该授权码;每个授权码只能看到自己的数据,
|
||||||
|
同一授权码换浏览器/设备激活可找回同一份数据。配置示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APS_LICENSE_PROVIDER=codes
|
||||||
|
APS_LICENSE_SECRET=至少32个随机字符
|
||||||
|
APS_LICENSE_CLIENTS=web,desktop
|
||||||
|
# 发放给用户的一一对应的授权码(类型:hour/day/week/month/year/permanent)
|
||||||
|
APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
内置超级管理员授权码 `aps@Z@Agent@Admin`(永久授权 + 全功能角色),无需配置即可使用;
|
||||||
|
用管理员码登录后,可在「设置 → 用户与授权」面板直接发放/停用/删除用户授权码
|
||||||
|
(登记姓名、手机号、公司、有效时间,系统自动生成授权码),无需重启服务;
|
||||||
|
`APS_AUTH_PROVIDER=jms` 同时存在时 JMS 优先,授权码模式不生效。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Web(浏览器 http://localhost:5173 ,API :8000)
|
# Web(浏览器 http://localhost:5173 ,API :8000)
|
||||||
npm run dev:web
|
npm run dev:web
|
||||||
|
|
|
||||||
|
|
@ -1735,3 +1735,67 @@ export async function postMeshWatchdog(body: { stallTimeoutSec?: number; maxRetr
|
||||||
const resp = await apiFetch('/api/mesh/watchdog', { method: 'POST', body: JSON.stringify(body) });
|
const resp = await apiFetch('/api/mesh/watchdog', { method: 'POST', body: JSON.stringify(body) });
|
||||||
return (await resp.json()) as { error?: string };
|
return (await resp.json()) as { error?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- 超管 · 用户与授权码管理(codes 模式) ----------------
|
||||||
|
export type LicenseCodeActivation = {
|
||||||
|
status: string;
|
||||||
|
activatedAt: number;
|
||||||
|
lastSeenAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LicenseCodeItem = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
company: string;
|
||||||
|
licenseType: string;
|
||||||
|
licenseLabel: string;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
activation: LicenseCodeActivation | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readApiError(resp: Response): Promise<Error> {
|
||||||
|
try {
|
||||||
|
const body = await resp.json() as { detail?: { message?: string } | string; error?: { message?: string } };
|
||||||
|
if (typeof body.detail === 'object' && body.detail?.message) return new Error(body.detail.message);
|
||||||
|
if (typeof body.detail === 'string') return new Error(body.detail);
|
||||||
|
if (body.error?.message) return new Error(body.error.message);
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
return new Error(`请求失败(${resp.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLicenseCodes(): Promise<LicenseCodeItem[]> {
|
||||||
|
const resp = await apiFetch('/api/admin/license/codes');
|
||||||
|
if (!resp.ok) throw await readApiError(resp);
|
||||||
|
return ((await resp.json()) as { codes: LicenseCodeItem[] }).codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLicenseCode(payload: {
|
||||||
|
name: string; mobile: string; company: string; licenseType: string; code?: string; expiresAt?: number;
|
||||||
|
}): Promise<LicenseCodeItem> {
|
||||||
|
const resp = await apiFetch('/api/admin/license/codes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw await readApiError(resp);
|
||||||
|
return ((await resp.json()) as { code: LicenseCodeItem }).code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLicenseCodeStatus(id: string, status: 'active' | 'disabled'): Promise<void> {
|
||||||
|
const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw await readApiError(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLicenseCode(id: string): Promise<void> {
|
||||||
|
const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw await readApiError(resp);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default function AuthEntry(props: {
|
||||||
settings: 'Settings', result: props.hasVisualResult ? 'Open session result' : 'No session result', logout: 'Sign out',
|
settings: 'Settings', result: props.hasVisualResult ? 'Open session result' : 'No session result', logout: 'Sign out',
|
||||||
};
|
};
|
||||||
const displayName = user?.fullname || user?.username || '';
|
const displayName = user?.fullname || user?.username || '';
|
||||||
const desktop = auth.clientMode === 'desktop';
|
const licenseMode = auth.clientMode === 'desktop' || auth.licenseMode;
|
||||||
const licenseMeta = auth.license
|
const licenseMeta = auth.license
|
||||||
? (auth.license.permanent
|
? (auth.license.permanent
|
||||||
? auth.license.label
|
? auth.license.label
|
||||||
|
|
@ -65,12 +65,12 @@ export default function AuthEntry(props: {
|
||||||
className="account-trigger guest-login-trigger"
|
className="account-trigger guest-login-trigger"
|
||||||
disabled={checking}
|
disabled={checking}
|
||||||
onClick={props.onLogin}
|
onClick={props.onLogin}
|
||||||
title={checking ? '正在验证访问状态' : (desktop ? '客户端授权' : '登录')}
|
title={checking ? '正在验证访问状态' : (licenseMode ? '授权激活' : '登录')}
|
||||||
>
|
>
|
||||||
<span className="account-avatar">{desktop ? <SafetyCertificateOutlined /> : <UserOutlined />}</span>
|
<span className="account-avatar">{licenseMode ? <SafetyCertificateOutlined /> : <UserOutlined />}</span>
|
||||||
<span className="account-trigger-copy">
|
<span className="account-trigger-copy">
|
||||||
<b>{checking ? '正在验证' : (desktop ? '授权进入' : '登录')}</b>
|
<b>{checking ? '正在验证' : (licenseMode ? '授权进入' : '登录')}</b>
|
||||||
<small>{checking ? '请稍候' : (desktop ? '激活后使用本机项目与会话' : '登录后同步项目与会话')}</small>
|
<small>{checking ? '请稍候' : (licenseMode ? '激活后使用项目与会话' : '登录后同步项目与会话')}</small>
|
||||||
</span>
|
</span>
|
||||||
{!checking && <LoginOutlined className="guest-login-arrow" />}
|
{!checking && <LoginOutlined className="guest-login-arrow" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -111,7 +111,7 @@ export default function AuthEntry(props: {
|
||||||
</button>
|
</button>
|
||||||
<div className="account-menu-separator" />
|
<div className="account-menu-separator" />
|
||||||
<button type="button" role="menuitem" className="account-logout" onClick={() => void auth.logout()}>
|
<button type="button" role="menuitem" className="account-logout" onClick={() => void auth.logout()}>
|
||||||
<LogoutOutlined /><span>{desktop ? '退出授权' : labels.logout}</span><RightOutlined />
|
<LogoutOutlined /><span>{licenseMode ? '退出授权' : labels.logout}</span><RightOutlined />
|
||||||
</button>
|
</button>
|
||||||
{menuNote && <div className="account-menu-note" role="status">{menuNote}</div>}
|
{menuNote && <div className="account-menu-note" role="status">{menuNote}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function GuestSidebar(props: {
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
}) {
|
}) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const accessWord = auth.clientMode === 'desktop' ? '授权' : '登录';
|
const accessWord = (auth.clientMode === 'desktop' || auth.licenseMode) ? '授权' : '登录';
|
||||||
const action = props.checking ? undefined : props.onLogin;
|
const action = props.checking ? undefined : props.onLogin;
|
||||||
return (
|
return (
|
||||||
<aside className={`workspace-sidebar guest-sidebar${props.collapsed ? ' collapsed' : ''}`}>
|
<aside className={`workspace-sidebar guest-sidebar${props.collapsed ? ' collapsed' : ''}`}>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ type AuthContextValue = {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
license: LicenseInfo | null;
|
license: LicenseInfo | null;
|
||||||
clientMode: ClientMode;
|
clientMode: ClientMode;
|
||||||
|
licenseMode: boolean;
|
||||||
provider: string;
|
provider: string;
|
||||||
error: string;
|
error: string;
|
||||||
login: (payload: LoginPayload) => Promise<void>;
|
login: (payload: LoginPayload) => Promise<void>;
|
||||||
|
|
@ -84,6 +85,7 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [license, setLicense] = useState<LicenseInfo | null>(null);
|
const [license, setLicense] = useState<LicenseInfo | null>(null);
|
||||||
const clientMode = getClientMode();
|
const clientMode = getClientMode();
|
||||||
|
const [licenseMode, setLicenseMode] = useState(clientMode === 'desktop');
|
||||||
const [provider, setProvider] = useState('unconfigured');
|
const [provider, setProvider] = useState('unconfigured');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const clearError = useCallback(() => setError(''), []);
|
const clearError = useCallback(() => setError(''), []);
|
||||||
|
|
@ -94,8 +96,10 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
const health = await clientFetch('/api/health');
|
const health = await clientFetch('/api/health');
|
||||||
if (health.ok) {
|
if (health.ok) {
|
||||||
const info = await health.json() as { authProvider?: string; licenseProvider?: string };
|
const info = await health.json() as { authProvider?: string; licenseProvider?: string; licenseAuth?: boolean };
|
||||||
setProvider((clientMode === 'desktop' ? info.licenseProvider : info.authProvider) || 'unconfigured');
|
const useLicense = clientMode === 'desktop' || info.licenseAuth === true;
|
||||||
|
setLicenseMode(useLicense);
|
||||||
|
setProvider((useLicense ? info.licenseProvider : info.authProvider) || 'unconfigured');
|
||||||
}
|
}
|
||||||
const response = await clientFetch('/api/auth/me');
|
const response = await clientFetch('/api/auth/me');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -155,7 +159,7 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
}, [clientMode, provider]);
|
}, [clientMode, provider]);
|
||||||
|
|
||||||
const activateLicense = useCallback(async (code: string) => {
|
const activateLicense = useCallback(async (code: string) => {
|
||||||
if (clientMode !== 'desktop') throw new Error('授权码仅可用于桌面客户端');
|
if (clientMode !== 'desktop' && !licenseMode) throw new Error('授权码在当前部署不可用');
|
||||||
setError('');
|
setError('');
|
||||||
const response = await clientFetch('/api/auth/license/activate', {
|
const response = await clientFetch('/api/auth/license/activate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -173,7 +177,7 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
setLicense(body.license || null);
|
setLicense(body.license || null);
|
||||||
setProvider(body.provider || provider);
|
setProvider(body.provider || provider);
|
||||||
setStatus('authenticated');
|
setStatus('authenticated');
|
||||||
}, [clientMode, provider]);
|
}, [clientMode, licenseMode, provider]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -185,8 +189,8 @@ export function AuthProvider(props: { children: ReactNode }) {
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({ status, user, license, clientMode, provider, error, login, clearError, activateLicense, logout, refresh }),
|
() => ({ status, user, license, clientMode, licenseMode, provider, error, login, clearError, activateLicense, logout, refresh }),
|
||||||
[status, user, license, clientMode, provider, error, login, clearError, activateLicense, logout, refresh],
|
[status, user, license, clientMode, licenseMode, 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>;
|
||||||
}
|
}
|
||||||
|
|
@ -399,16 +403,16 @@ export function LicenseDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
disabled={busy} onClick={props.onClose}><CloseOutlined /></button>
|
disabled={busy} onClick={props.onClose}><CloseOutlined /></button>
|
||||||
<header className="login-brand">
|
<header className="login-brand">
|
||||||
<span className="login-brand-mark license-brand-mark" aria-hidden="true"><SafetyCertificateOutlined /></span>
|
<span className="login-brand-mark license-brand-mark" aria-hidden="true"><SafetyCertificateOutlined /></span>
|
||||||
<div><h1 id="license-title">激活工业智核</h1><p>APS Desktop</p></div>
|
<div><h1 id="license-title">激活工业智核</h1><p>{auth.clientMode === 'desktop' ? 'APS Desktop' : 'APS Agent'}</p></div>
|
||||||
</header>
|
</header>
|
||||||
<div className="license-summary">
|
<div className="license-summary">
|
||||||
<strong>客户端授权</strong>
|
<strong>授权码激活</strong>
|
||||||
<span>授权时长由授权码决定</span>
|
<span>数据将绑定到该授权码,授权时长由授权码决定</span>
|
||||||
</div>
|
</div>
|
||||||
<form className="login-form" onSubmit={submit}>
|
<form className="login-form" onSubmit={submit}>
|
||||||
<label><span>授权码</span><div><KeyOutlined /><input autoFocus autoComplete="off" spellCheck={false}
|
<label><span>授权码</span><div><KeyOutlined /><input autoFocus autoComplete="off" spellCheck={false}
|
||||||
value={code} onChange={event => setCode(event.target.value.toUpperCase())}
|
value={code} onChange={event => setCode(auth.provider === 'mock' ? event.target.value.toUpperCase() : event.target.value)}
|
||||||
placeholder="请输入客户端授权码" /></div></label>
|
placeholder="请提供授权码" /></div></label>
|
||||||
<button className="login-submit" type="submit" disabled={busy || !code.trim()}>
|
<button className="login-submit" type="submit" disabled={busy || !code.trim()}>
|
||||||
{busy ? '验证中...' : '授权进入'}
|
{busy ? '验证中...' : '授权进入'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -429,7 +433,7 @@ export function LicenseDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
|
|
||||||
export function AccessDialog(props: { open: boolean; onClose: () => void }) {
|
export function AccessDialog(props: { open: boolean; onClose: () => void }) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
return auth.clientMode === 'desktop'
|
return auth.clientMode === 'desktop' || auth.licenseMode
|
||||||
? <LicenseDialog {...props} />
|
? <LicenseDialog {...props} />
|
||||||
: <LoginDialog {...props} />;
|
: <LoginDialog {...props} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
type ConstraintItem, type ConstraintLibrary, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
|
type ConstraintItem, type ConstraintLibrary, type ConstraintProfile, type ScheduleParams, type SopCompilableItem,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import SkillConsole from '../skills/SkillConsole';
|
import SkillConsole from '../skills/SkillConsole';
|
||||||
|
import LicenseAdminView from './LicenseAdminView';
|
||||||
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
import KnowledgePanel from '../knowledge/KnowledgePanel';
|
import KnowledgePanel from '../knowledge/KnowledgePanel';
|
||||||
import { devToolsEnabled } from '../features/devtools';
|
import { devToolsEnabled } from '../features/devtools';
|
||||||
|
|
||||||
|
|
@ -31,10 +33,10 @@ export type GovSection =
|
||||||
| 'gate.pending' | 'gate.history' | 'gate.audit' | 'gate.stats' | 'gate.tools' | 'gate.policy'
|
| 'gate.pending' | 'gate.history' | 'gate.audit' | 'gate.stats' | 'gate.tools' | 'gate.policy'
|
||||||
| 'regen.modules' | 'regen.tests'
|
| 'regen.modules' | 'regen.tests'
|
||||||
| 'knowledge.assets'
|
| 'knowledge.assets'
|
||||||
| 'settings.personal' | 'settings.schedule' | 'settings.constraints' | 'settings.integration' | 'settings.skills';
|
| 'settings.personal' | 'settings.schedule' | 'settings.constraints' | 'settings.integration' | 'settings.skills' | 'settings.license';
|
||||||
|
|
||||||
// dev: true 的分组属开发/运维工具,计划员界面不出现(需 ?devtools=1 开启)
|
// dev: true 的分组属开发/运维工具,计划员界面不出现(需 ?devtools=1 开启)
|
||||||
const NAV: { group: string; dev?: boolean; items: { id: GovSection; label: string }[] }[] = [
|
const NAV: { group: string; dev?: boolean; items: { id: GovSection; label: string; admin?: boolean }[] }[] = [
|
||||||
{
|
{
|
||||||
group: '通用',
|
group: '通用',
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -43,6 +45,7 @@ const NAV: { group: string; dev?: boolean; items: { id: GovSection; label: strin
|
||||||
{ id: 'settings.constraints', label: '约束配置' },
|
{ id: 'settings.constraints', label: '约束配置' },
|
||||||
{ id: 'settings.integration', label: '模型与集成' },
|
{ id: 'settings.integration', label: '模型与集成' },
|
||||||
{ id: 'settings.skills', label: '算法 Skill' },
|
{ id: 'settings.skills', label: '算法 Skill' },
|
||||||
|
{ id: 'settings.license', label: '用户与授权', admin: true },
|
||||||
{ id: 'knowledge.assets', label: '知识平台' },
|
{ id: 'knowledge.assets', label: '知识平台' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -88,7 +91,12 @@ export default function GovConsole(props: {
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
const nav = useMemo(() => NAV.filter(g => !g.dev || devToolsEnabled()), []);
|
const auth = useAuth();
|
||||||
|
const isAdmin = Boolean(auth.user?.roles?.includes('admin'));
|
||||||
|
const nav = useMemo(() => NAV
|
||||||
|
.filter(g => !g.dev || devToolsEnabled())
|
||||||
|
.map(g => ({ ...g, items: g.items.filter(it => !it.admin || isAdmin) }))
|
||||||
|
.filter(g => g.items.length > 0), [isAdmin]);
|
||||||
|
|
||||||
if (!props.open) return null;
|
if (!props.open) return null;
|
||||||
|
|
||||||
|
|
@ -145,6 +153,7 @@ export default function GovConsole(props: {
|
||||||
onSendCommand={props.onSendCommand}
|
onSendCommand={props.onSendCommand}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{section === 'settings.license' && <LicenseAdminView />}
|
||||||
{section === 'knowledge.assets' && (
|
{section === 'knowledge.assets' && (
|
||||||
<KnowledgePanel sessionId={props.sessionId ?? 'web'} />
|
<KnowledgePanel sessionId={props.sessionId ?? 'web'} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
// ============================================================
|
||||||
|
// 用户与授权(moduleId: web-gov-license-admin, 可重生 ✅)
|
||||||
|
// 仅超级管理员可见:发放授权码(姓名/手机号/公司/有效期,自动生成码)、
|
||||||
|
// 停用/启用/删除,并查看每个码的激活状态。授权码即用户身份,数据按码隔离。
|
||||||
|
// ============================================================
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createLicenseCode,
|
||||||
|
deleteLicenseCode,
|
||||||
|
fetchLicenseCodes,
|
||||||
|
setLicenseCodeStatus,
|
||||||
|
type LicenseCodeItem,
|
||||||
|
} from '../api/client';
|
||||||
|
|
||||||
|
const LICENSE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'hour', label: '小时授权' },
|
||||||
|
{ value: 'day', label: '天授权' },
|
||||||
|
{ value: 'week', label: '周授权' },
|
||||||
|
{ value: 'month', label: '月授权' },
|
||||||
|
{ value: 'year', label: '年授权' },
|
||||||
|
{ value: 'permanent', label: '永久授权' },
|
||||||
|
{ value: 'custom', label: '自定义时间(选择到期时间)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtTime(epoch?: number | null): string {
|
||||||
|
if (!epoch) return '—';
|
||||||
|
return new Date(epoch * 1000).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function activationText(item: LicenseCodeItem): string {
|
||||||
|
if (!item.activation) return '未激活';
|
||||||
|
const act = item.activation;
|
||||||
|
if (act.status === 'expired') return `已到期(${fmtTime(act.expiresAt)})`;
|
||||||
|
return `已激活 · 最近使用 ${fmtTime(act.lastSeenAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicenseAdminView() {
|
||||||
|
const [items, setItems] = useState<LicenseCodeItem[] | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [mobile, setMobile] = useState('');
|
||||||
|
const [company, setCompany] = useState('');
|
||||||
|
const [licenseType, setLicenseType] = useState('year');
|
||||||
|
const [customExpiry, setCustomExpiry] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
void fetchLicenseCodes()
|
||||||
|
.then(setItems)
|
||||||
|
.catch(err => {
|
||||||
|
setItems([]);
|
||||||
|
setNotice(err instanceof Error ? err.message : '加载授权码列表失败');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!name.trim()) { setNotice('请填写用户名称'); return; }
|
||||||
|
if (licenseType === 'custom' && !customExpiry) { setNotice('请选择到期时间'); return; }
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const payload: Parameters<typeof createLicenseCode>[0] = {
|
||||||
|
name: name.trim(), mobile: mobile.trim(), company: company.trim(), licenseType,
|
||||||
|
};
|
||||||
|
if (licenseType === 'custom') {
|
||||||
|
payload.expiresAt = Math.floor(new Date(customExpiry).getTime() / 1000);
|
||||||
|
}
|
||||||
|
const created = await createLicenseCode(payload);
|
||||||
|
setNotice(`已为「${created.name}」生成授权码:${created.code}(请复制下发给用户)`);
|
||||||
|
setName(''); setMobile(''); setCompany('');
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (item: LicenseCodeItem) => {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const next = item.status === 'active' ? 'disabled' : 'active';
|
||||||
|
await setLicenseCodeStatus(item.id, next);
|
||||||
|
setNotice(next === 'disabled' ? `已停用「${item.name}」的授权码` : `已启用「${item.name}」的授权码`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: LicenseCodeItem) => {
|
||||||
|
if (!window.confirm(`确认删除「${item.name}」的授权码?删除后该码将无法激活。`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await deleteLicenseCode(item.id);
|
||||||
|
setNotice(`已删除「${item.name}」的授权码`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (code: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setNotice(`已复制授权码:${code}`);
|
||||||
|
} catch {
|
||||||
|
setNotice(`复制失败,请手动复制:${code}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="gov-card">
|
||||||
|
<div className="gov-card-title">发放授权码</div>
|
||||||
|
<p className="gov-kv" style={{ marginBottom: 10 }}>
|
||||||
|
授权码即用户身份:用户激活后,其会话、项目与数据全部绑定到该授权码,互相隔离。
|
||||||
|
有效期从用户首次激活起算。
|
||||||
|
</p>
|
||||||
|
<label className="params-slider-row">
|
||||||
|
<span className="params-slider-label">用户名称</span>
|
||||||
|
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="必填,如:张三" style={{ flex: 1, minWidth: 140 }} />
|
||||||
|
</label>
|
||||||
|
<label className="params-slider-row">
|
||||||
|
<span className="params-slider-label">手机号</span>
|
||||||
|
<input type="text" value={mobile} onChange={e => setMobile(e.target.value)}
|
||||||
|
placeholder="选填" style={{ flex: 1, minWidth: 140 }} />
|
||||||
|
</label>
|
||||||
|
<label className="params-slider-row">
|
||||||
|
<span className="params-slider-label">公司名称</span>
|
||||||
|
<input type="text" value={company} onChange={e => setCompany(e.target.value)}
|
||||||
|
placeholder="选填" style={{ flex: 1, minWidth: 140 }} />
|
||||||
|
</label>
|
||||||
|
<label className="params-slider-row">
|
||||||
|
<span className="params-slider-label">有效时间</span>
|
||||||
|
<select value={licenseType} onChange={e => setLicenseType(e.target.value)}
|
||||||
|
style={{ flex: 1, minWidth: 140 }}>
|
||||||
|
{LICENSE_TYPE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{licenseType === 'custom' && (
|
||||||
|
<label className="params-slider-row">
|
||||||
|
<span className="params-slider-label">到期时间</span>
|
||||||
|
<input type="datetime-local" value={customExpiry}
|
||||||
|
onChange={e => setCustomExpiry(e.target.value)} style={{ flex: 1, minWidth: 140 }} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button type="button" className="gov-action" disabled={busy || !name.trim()} onClick={() => void create()}>
|
||||||
|
{busy ? '处理中…' : '生成授权码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice && <div className="gov-notice">{notice}</div>}
|
||||||
|
|
||||||
|
<div className="gov-card">
|
||||||
|
<div className="gov-card-title">用户与授权码</div>
|
||||||
|
{items === null && <div className="gov-empty">加载中…</div>}
|
||||||
|
{items !== null && items.length === 0 && (
|
||||||
|
<div className="gov-empty">尚未发放任何授权码。使用上方表单为用户生成。</div>
|
||||||
|
)}
|
||||||
|
{items !== null && items.length > 0 && (
|
||||||
|
<table className="grid gov-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left' }}>用户</th><th>手机号</th><th>公司</th>
|
||||||
|
<th style={{ textAlign: 'left' }}>授权码</th><th>类型</th><th>状态</th>
|
||||||
|
<th style={{ textAlign: 'left' }}>激活情况</th><th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td style={{ textAlign: 'left' }}>{item.name}</td>
|
||||||
|
<td>{item.mobile || '—'}</td>
|
||||||
|
<td>{item.company || '—'}</td>
|
||||||
|
<td style={{ textAlign: 'left', fontFamily: 'monospace' }}>
|
||||||
|
{item.code}
|
||||||
|
<button type="button" className="sidebar-mini-btn" title="复制授权码"
|
||||||
|
style={{ marginLeft: 6 }} onClick={() => void copy(item.code)}>复制</button>
|
||||||
|
</td>
|
||||||
|
<td>{item.licenseLabel}{item.expiresAt ? `(至 ${fmtTime(item.expiresAt)})` : ''}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`risk ${item.status === 'active' ? 'r-low' : 'r-high'}`}>
|
||||||
|
{item.status === 'active' ? '有效' : '已停用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'left' }}>{activationText(item)}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="btn-ghost" disabled={busy}
|
||||||
|
onClick={() => void toggle(item)}>
|
||||||
|
{item.status === 'active' ? '停用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-ghost" disabled={busy}
|
||||||
|
onClick={() => void remove(item)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ from sqlalchemy import select
|
||||||
from server.auth.context import IdentityContext
|
from server.auth.context import IdentityContext
|
||||||
from server.auth.providers import AuthError
|
from server.auth.providers import AuthError
|
||||||
from server.db.database import get_session
|
from server.db.database import get_session
|
||||||
from server.db.models import LicenseActivation
|
from server.db.models import LicenseActivation, LicenseCode
|
||||||
|
|
||||||
|
|
||||||
LICENSE_DURATIONS: dict[str, int | None] = {
|
LICENSE_DURATIONS: dict[str, int | None] = {
|
||||||
|
|
@ -36,6 +36,7 @@ LICENSE_LABELS = {
|
||||||
"month": "月授权",
|
"month": "月授权",
|
||||||
"year": "年授权",
|
"year": "年授权",
|
||||||
"permanent": "永久授权",
|
"permanent": "永久授权",
|
||||||
|
"custom": "自定义授权",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_MOCK_CODES: dict[str, str] = {
|
DEFAULT_MOCK_CODES: dict[str, str] = {
|
||||||
|
|
@ -48,12 +49,37 @@ DEFAULT_MOCK_CODES: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
DESKTOP_COOKIE = "aps_desktop_session"
|
DESKTOP_COOKIE = "aps_desktop_session"
|
||||||
|
WEB_LICENSE_COOKIE = "aps_license_session"
|
||||||
|
|
||||||
|
|
||||||
def is_desktop_request(request: Request) -> bool:
|
def is_desktop_request(request: Request) -> bool:
|
||||||
return (request.headers.get("x-aps-client") or "").strip().lower() == "desktop"
|
return (request.headers.get("x-aps-client") or "").strip().lower() == "desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def license_clients() -> frozenset[str]:
|
||||||
|
"""授权码机制作用的客户端集合(APS_LICENSE_CLIENTS,默认仅 desktop,兼容既有部署)。"""
|
||||||
|
raw = (os.environ.get("APS_LICENSE_CLIENTS") or "desktop").strip().lower()
|
||||||
|
return frozenset(item.strip() for item in raw.split(",") if item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def use_license_auth(request: Request) -> bool:
|
||||||
|
"""判定当前请求是否应走授权码认证。
|
||||||
|
|
||||||
|
- 桌面客户端:始终走授权(与既有行为一致)。
|
||||||
|
- Web 客户端:仅当 APS_LICENSE_CLIENTS 包含 web、授权提供方为 codes(正式授权码模式)、
|
||||||
|
且未配置 JMS 认证(JMS 环境优先,避免与 MES 集成冲突)时走授权码。
|
||||||
|
"""
|
||||||
|
if is_desktop_request(request):
|
||||||
|
return True
|
||||||
|
if "web" not in license_clients():
|
||||||
|
return False
|
||||||
|
if (os.environ.get("APS_LICENSE_PROVIDER") or "").strip().lower() != "codes":
|
||||||
|
return False
|
||||||
|
from server.auth.providers import get_auth_provider
|
||||||
|
|
||||||
|
return get_auth_provider().mode != "jms"
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(raw: bytes) -> str:
|
def _b64encode(raw: bytes) -> str:
|
||||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
@ -294,6 +320,253 @@ class MockLicenseProvider(LicenseProvider):
|
||||||
return identity, self._issue(identity, device_hash)
|
return identity, self._issue(identity, device_hash)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_LICENSE_CODE = "aps@Z@Agent@Admin"
|
||||||
|
ADMIN_LICENSE_ROLES = ("system", "admin", "planner", "approver", "auditor", "scheduler", "license")
|
||||||
|
DEFAULT_CODE_ROLES = ("license", "planner")
|
||||||
|
|
||||||
|
|
||||||
|
class CodeLicenseProvider(LicenseProvider):
|
||||||
|
"""正式授权码提供方:授权码即用户身份。
|
||||||
|
|
||||||
|
每个授权码唯一对应一个稳定的 user_id(由授权码哈希派生),智能体会话、项目、
|
||||||
|
主数据等全部按 tenant_uuid + user_id 归属到该授权码;不同授权码之间数据天然隔离,
|
||||||
|
同一授权码可在任意浏览器/设备激活并找回同一份数据。
|
||||||
|
内置超级管理员授权码 aps@Z@Agent@Admin(永久授权 + 全功能角色),
|
||||||
|
其余授权码经 APS_LICENSE_CODES(JSON)发放。
|
||||||
|
"""
|
||||||
|
|
||||||
|
mode = "codes"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
secret = (os.environ.get("APS_LICENSE_SECRET") or "").strip()
|
||||||
|
if len(secret) < 32:
|
||||||
|
raise AuthError(
|
||||||
|
"LICENSE_CONFIG_INVALID",
|
||||||
|
"授权配置缺失:APS_LICENSE_SECRET(至少 32 个字符)",
|
||||||
|
503,
|
||||||
|
)
|
||||||
|
self.secret = secret.encode()
|
||||||
|
tenant = (os.environ.get("APS_LICENSE_TENANT_UUID") or "platform").strip()
|
||||||
|
self.tenant_uuid = tenant or "platform"
|
||||||
|
|
||||||
|
def _catalog(self) -> dict[str, dict[str, Any]]:
|
||||||
|
catalog: dict[str, dict[str, Any]] = {
|
||||||
|
ADMIN_LICENSE_CODE: {
|
||||||
|
"type": "permanent",
|
||||||
|
"roles": ADMIN_LICENSE_ROLES,
|
||||||
|
"name": "aps-admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
configured = (os.environ.get("APS_LICENSE_CODES") or "").strip()
|
||||||
|
if not configured:
|
||||||
|
return catalog
|
||||||
|
try:
|
||||||
|
data = json.loads(configured)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise AuthError("LICENSE_CONFIG_INVALID", "授权码配置无效(APS_LICENSE_CODES 须为 JSON)", 503) from exc
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for code, grant in data.items():
|
||||||
|
if not isinstance(code, str) or not code.strip():
|
||||||
|
continue
|
||||||
|
if isinstance(grant, dict):
|
||||||
|
license_type = str(grant.get("type") or "")
|
||||||
|
roles = grant.get("roles")
|
||||||
|
name = grant.get("name")
|
||||||
|
else:
|
||||||
|
license_type, roles, name = str(grant), None, None
|
||||||
|
if license_type not in LICENSE_DURATIONS:
|
||||||
|
continue
|
||||||
|
entry: dict[str, Any] = {"type": license_type}
|
||||||
|
if isinstance(roles, (list, tuple)) and roles:
|
||||||
|
entry["roles"] = tuple(str(role) for role in roles)
|
||||||
|
else:
|
||||||
|
entry["roles"] = DEFAULT_CODE_ROLES
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
entry["name"] = name.strip()[:64]
|
||||||
|
catalog[code.strip()] = entry
|
||||||
|
# 超管在设置面板发放的授权码(DB 登记,覆盖同名环境配置)
|
||||||
|
catalog.update(self._db_codes())
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
def _db_codes(self) -> dict[str, dict[str, Any]]:
|
||||||
|
with get_session() as session:
|
||||||
|
rows = session.scalars(select(LicenseCode).where(
|
||||||
|
LicenseCode.tenant_uuid == self.tenant_uuid,
|
||||||
|
LicenseCode.status == "active",
|
||||||
|
LicenseCode.deleted == 0,
|
||||||
|
)).all()
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.license_type not in LICENSE_DURATIONS and not row.expires_at:
|
||||||
|
continue
|
||||||
|
db_entry: dict[str, Any] = {"type": row.license_type, "roles": DEFAULT_CODE_ROLES}
|
||||||
|
if row.expires_at:
|
||||||
|
db_entry["expiresAt"] = int(row.expires_at)
|
||||||
|
if row.name:
|
||||||
|
db_entry["name"] = row.name
|
||||||
|
result[row.code] = db_entry
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scope(request: Request) -> str:
|
||||||
|
scope = "desktop" if is_desktop_request(request) else "web"
|
||||||
|
if scope not in license_clients():
|
||||||
|
raise AuthError("LICENSE_CLIENT_DISABLED", "授权码在当前部署的该客户端不可用", 403)
|
||||||
|
return scope
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _code_user_id(tenant_uuid: str, code: str) -> int:
|
||||||
|
# 授权码即身份:同一授权码恒定派生同一 user_id(保持在有符号 BIGINT 范围内)。
|
||||||
|
digest = _sha256(f"aps-license:{tenant_uuid}:{code}")
|
||||||
|
return int(digest[:15], 16)
|
||||||
|
|
||||||
|
def _issue(self, identity: IdentityContext) -> str:
|
||||||
|
payload = {**identity.to_dict(), "issuer": "codes"}
|
||||||
|
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
body = _b64encode(raw)
|
||||||
|
signature = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest())
|
||||||
|
return f"{body}.{signature}"
|
||||||
|
|
||||||
|
def _verify_token(self, token: str) -> IdentityContext:
|
||||||
|
try:
|
||||||
|
body, signature = token.split(".", 1)
|
||||||
|
expected = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest())
|
||||||
|
if not hmac.compare_digest(signature, expected):
|
||||||
|
raise ValueError("signature")
|
||||||
|
payload = json.loads(_b64decode(body))
|
||||||
|
activation_id = str(payload.get("activation_id") or "")
|
||||||
|
if not activation_id:
|
||||||
|
raise ValueError("activation")
|
||||||
|
except Exception as exc:
|
||||||
|
raise AuthError("LICENSE_INVALID", "授权凭证无效", 401) from exc
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.get(LicenseActivation, activation_id)
|
||||||
|
if row is None or row.deleted != 0:
|
||||||
|
raise AuthError("LICENSE_INVALID", "授权凭证无效", 401)
|
||||||
|
if row.status != "active" or row.revoked_at is not None:
|
||||||
|
raise AuthError("LICENSE_REVOKED", "授权已被停用", 401)
|
||||||
|
if row.expires_at is not None and row.expires_at <= now:
|
||||||
|
row.status = "expired"
|
||||||
|
session.commit()
|
||||||
|
raise AuthError("LICENSE_EXPIRED", "授权码已到期,请联系发放方续期", 401)
|
||||||
|
code_row = session.scalar(select(LicenseCode).where(
|
||||||
|
LicenseCode.code_hash == row.code_hash,
|
||||||
|
LicenseCode.deleted == 0,
|
||||||
|
))
|
||||||
|
if code_row is not None and code_row.status != "active":
|
||||||
|
raise AuthError("LICENSE_REVOKED", "授权码已被停用,请联系管理员", 401)
|
||||||
|
if now - row.last_seen_at >= 3600:
|
||||||
|
row.last_seen_at = now
|
||||||
|
session.commit()
|
||||||
|
roles = payload.get("roles")
|
||||||
|
return IdentityContext(
|
||||||
|
user_id=row.user_id,
|
||||||
|
username=str(payload.get("username") or "license-user"),
|
||||||
|
fullname=str(payload.get("fullname") or "授权用户"),
|
||||||
|
tenant_uuid=row.tenant_uuid,
|
||||||
|
roles=tuple(str(role) for role in roles) if isinstance(roles, list) and roles else DEFAULT_CODE_ROLES,
|
||||||
|
expires_at=row.expires_at or 4_102_444_800, # 2100-01-01;DB 校验仍支持停用/到期
|
||||||
|
auth_kind="license",
|
||||||
|
license_type=row.license_type,
|
||||||
|
license_activated_at=row.activated_at,
|
||||||
|
license_expires_at=row.expires_at,
|
||||||
|
activation_id=row.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def activate(self, request: Request, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
|
||||||
|
self._scope(request)
|
||||||
|
code = str(payload.get("code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
raise AuthError("LICENSE_CODE_REQUIRED", "请输入授权码", 400)
|
||||||
|
entry = self._catalog().get(code)
|
||||||
|
if entry is None:
|
||||||
|
raise AuthError("LICENSE_CODE_INVALID", "授权码无效", 401)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
code_hash = _sha256(f"codes:{self.tenant_uuid}:{code}")
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.scalar(select(LicenseActivation).where(
|
||||||
|
LicenseActivation.code_hash == code_hash,
|
||||||
|
LicenseActivation.deleted == 0,
|
||||||
|
))
|
||||||
|
if row is not None:
|
||||||
|
if row.revoked_at is not None:
|
||||||
|
raise AuthError("LICENSE_REVOKED", "授权已被停用", 401)
|
||||||
|
if row.expires_at is not None and row.expires_at <= now:
|
||||||
|
if row.status != "expired":
|
||||||
|
row.status = "expired"
|
||||||
|
session.commit()
|
||||||
|
raise AuthError("LICENSE_EXPIRED", "授权码已到期,请联系发放方续期", 401)
|
||||||
|
if row.status != "active":
|
||||||
|
raise AuthError("LICENSE_REVOKED", "授权已被停用", 401)
|
||||||
|
row.last_seen_at = now
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
else:
|
||||||
|
seconds = LICENSE_DURATIONS.get(entry["type"])
|
||||||
|
absolute_expiry = entry.get("expiresAt")
|
||||||
|
if absolute_expiry:
|
||||||
|
expires_at = int(absolute_expiry)
|
||||||
|
elif seconds is not None:
|
||||||
|
expires_at = now + seconds
|
||||||
|
else:
|
||||||
|
expires_at = None
|
||||||
|
if expires_at is not None and expires_at <= now:
|
||||||
|
raise AuthError("LICENSE_EXPIRED", "授权码已到期,请联系发放方续期", 401)
|
||||||
|
row = LicenseActivation(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
code_hash=code_hash,
|
||||||
|
code_hint=f"{code[:2]}***{code[-2:]}" if len(code) > 4 else "***",
|
||||||
|
device_hash=_sha256(f"codes-shared:{self.tenant_uuid}"),
|
||||||
|
user_id=self._code_user_id(self.tenant_uuid, code),
|
||||||
|
license_type=entry["type"],
|
||||||
|
status="active",
|
||||||
|
activated_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
last_seen_at=now,
|
||||||
|
revoked_at=None,
|
||||||
|
tenant_uuid=self.tenant_uuid,
|
||||||
|
creator_id=None,
|
||||||
|
updater_id=None,
|
||||||
|
updated_at=_utc_text(now),
|
||||||
|
deleted=0,
|
||||||
|
created_at=_utc_text(now),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
|
||||||
|
identity = IdentityContext(
|
||||||
|
user_id=row.user_id,
|
||||||
|
username=str(entry.get("name") or f"lic-{row.code_hint}"),
|
||||||
|
fullname="超级管理员" if code == ADMIN_LICENSE_CODE else str(entry.get("name") or "授权用户"),
|
||||||
|
tenant_uuid=row.tenant_uuid,
|
||||||
|
roles=tuple(entry.get("roles") or DEFAULT_CODE_ROLES),
|
||||||
|
expires_at=row.expires_at or 4_102_444_800,
|
||||||
|
auth_kind="license",
|
||||||
|
license_type=row.license_type,
|
||||||
|
license_activated_at=row.activated_at,
|
||||||
|
license_expires_at=row.expires_at,
|
||||||
|
activation_id=row.id,
|
||||||
|
)
|
||||||
|
return identity, self._issue(identity)
|
||||||
|
|
||||||
|
async def authenticate(self, request: Request) -> IdentityContext:
|
||||||
|
scope = self._scope(request)
|
||||||
|
cookie = DESKTOP_COOKIE if scope == "desktop" else WEB_LICENSE_COOKIE
|
||||||
|
token = request.cookies.get(cookie) or ""
|
||||||
|
if not token:
|
||||||
|
message = "请先使用授权码激活客户端" if scope == "desktop" else "请提供授权码激活后使用"
|
||||||
|
raise AuthError("LICENSE_REQUIRED", message, 401)
|
||||||
|
return self._verify_token(token)
|
||||||
|
|
||||||
|
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
|
||||||
|
identity = await self.authenticate(request)
|
||||||
|
return identity, self._issue(identity)
|
||||||
|
|
||||||
|
|
||||||
_provider: LicenseProvider | None = None
|
_provider: LicenseProvider | None = None
|
||||||
_provider_mode: str | None = None
|
_provider_mode: str | None = None
|
||||||
|
|
||||||
|
|
@ -302,6 +575,11 @@ def get_license_provider() -> LicenseProvider:
|
||||||
global _provider, _provider_mode
|
global _provider, _provider_mode
|
||||||
mode = (os.environ.get("APS_LICENSE_PROVIDER") or "unconfigured").strip().lower()
|
mode = (os.environ.get("APS_LICENSE_PROVIDER") or "unconfigured").strip().lower()
|
||||||
if _provider is None or _provider_mode != mode:
|
if _provider is None or _provider_mode != mode:
|
||||||
_provider = MockLicenseProvider() if mode == "mock" else UnconfiguredLicenseProvider()
|
if mode == "mock":
|
||||||
|
_provider = MockLicenseProvider()
|
||||||
|
elif mode == "codes":
|
||||||
|
_provider = CodeLicenseProvider()
|
||||||
|
else:
|
||||||
|
_provider = UnconfiguredLicenseProvider()
|
||||||
_provider_mode = mode
|
_provider_mode = mode
|
||||||
return _provider
|
return _provider
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from starlette.datastructures import MutableHeaders
|
||||||
|
|
||||||
from server.auth import agent_tokens
|
from server.auth import agent_tokens
|
||||||
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
||||||
from server.auth.licenses import get_license_provider, is_desktop_request
|
from server.auth.licenses import get_license_provider, use_license_auth
|
||||||
from server.auth.providers import AuthError, get_auth_provider
|
from server.auth.providers import AuthError, get_auth_provider
|
||||||
|
|
||||||
PUBLIC_API_PATHS = {
|
PUBLIC_API_PATHS = {
|
||||||
|
|
@ -160,7 +160,7 @@ class AuthenticationMiddleware:
|
||||||
try:
|
try:
|
||||||
provider = (
|
provider = (
|
||||||
get_license_provider()
|
get_license_provider()
|
||||||
if is_desktop_request(request)
|
if use_license_auth(request)
|
||||||
else get_auth_provider()
|
else get_auth_provider()
|
||||||
)
|
)
|
||||||
identity = await provider.authenticate(request)
|
identity = await provider.authenticate(request)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,9 @@ def _migrate_legacy_sqlite(engine: Engine) -> None:
|
||||||
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
"updated_at": "VARCHAR(32) NOT NULL DEFAULT ''",
|
||||||
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
"deleted": "BIGINT NOT NULL DEFAULT 0",
|
||||||
},
|
},
|
||||||
|
"aps_license_codes": {
|
||||||
|
"expires_at": "BIGINT",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
|
|
|
||||||
|
|
@ -363,3 +363,27 @@ class LicenseActivation(TenantAuditMixin, Base):
|
||||||
Index("ix_license_device_status", "device_hash", "status", "deleted"),
|
Index("ix_license_device_status", "device_hash", "status", "deleted"),
|
||||||
Index("ix_license_tenant_user", "tenant_uuid", "user_id", "deleted"),
|
Index("ix_license_tenant_user", "tenant_uuid", "user_id", "deleted"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseCode(TenantAuditMixin, Base):
|
||||||
|
"""授权码发放登记(codes 模式):超级管理员在设置面板维护。
|
||||||
|
|
||||||
|
code 明文存储——授权码本身就是发给用户的凭证,超管需要随时查看/复制下发;
|
||||||
|
激活侧一律按 code_hash 匹配(与 CodeLicenseProvider 的派生口径一致)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "aps_license_codes"
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
|
mobile: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
company: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
license_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||||
|
expires_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
created_at: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("code_hash", "deleted", name="uk_license_code_hash"),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,20 @@ from server.state.checkpoints import get_checkpoints # 成对快照仓(时
|
||||||
from server.state.store import get_store # 世界状态单例
|
from server.state.store import get_store # 世界状态单例
|
||||||
from server.auth.context import get_identity
|
from server.auth.context import get_identity
|
||||||
from server.auth.middleware import AuthenticationMiddleware
|
from server.auth.middleware import AuthenticationMiddleware
|
||||||
from server.auth.licenses import DESKTOP_COOKIE, get_license_provider, is_desktop_request
|
from server.auth.licenses import (
|
||||||
|
DESKTOP_COOKIE,
|
||||||
|
WEB_LICENSE_COOKIE,
|
||||||
|
get_license_provider,
|
||||||
|
is_desktop_request,
|
||||||
|
use_license_auth,
|
||||||
|
)
|
||||||
from server.auth.providers import AuthError, get_auth_provider
|
from server.auth.providers import AuthError, get_auth_provider
|
||||||
from server.gateway.plan_api import router as plan_router
|
from server.gateway.plan_api import router as plan_router
|
||||||
from server.gateway.masterdata_api import router as masterdata_router
|
from server.gateway.masterdata_api import router as masterdata_router
|
||||||
from server.gateway.governance_api import router as governance_router, gov_router as governance_audit_router
|
from server.gateway.governance_api import router as governance_router, gov_router as governance_audit_router
|
||||||
from server.gateway.mps_api import router as mps_router
|
from server.gateway.mps_api import router as mps_router
|
||||||
from server.gateway.agent_api import router as agent_router
|
from server.gateway.agent_api import router as agent_router
|
||||||
|
from server.gateway.license_admin_api import router as license_admin_router
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1022,6 +1029,7 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(governance_audit_router)
|
app.include_router(governance_audit_router)
|
||||||
app.include_router(mps_router)
|
app.include_router(mps_router)
|
||||||
app.include_router(agent_router)
|
app.include_router(agent_router)
|
||||||
|
app.include_router(license_admin_router)
|
||||||
|
|
||||||
@app.exception_handler(PermissionError)
|
@app.exception_handler(PermissionError)
|
||||||
async def permission_error_handler(_request: Request, exc: PermissionError) -> Response:
|
async def permission_error_handler(_request: Request, exc: PermissionError) -> Response:
|
||||||
|
|
@ -1047,11 +1055,12 @@ def create_app() -> FastAPI:
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_license_cookie(response: Response, token: str, expires_at: int | None) -> None:
|
def set_license_cookie(response: Response, token: str, expires_at: int | None, request: Request) -> None:
|
||||||
import time
|
import time
|
||||||
remaining = max(1, (expires_at - int(time.time()))) if expires_at else 10 * 365 * 24 * 60 * 60
|
remaining = max(1, (expires_at - int(time.time()))) if expires_at else 10 * 365 * 24 * 60 * 60
|
||||||
|
cookie_name = DESKTOP_COOKIE if is_desktop_request(request) else WEB_LICENSE_COOKIE
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
DESKTOP_COOKIE,
|
cookie_name,
|
||||||
token,
|
token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=(os.environ.get("APS_AUTH_COOKIE_SECURE") or "0") == "1",
|
secure=(os.environ.get("APS_AUTH_COOKIE_SECURE") or "0") == "1",
|
||||||
|
|
@ -1076,13 +1085,23 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health(request: Request) -> dict:
|
async def health(request: Request) -> dict:
|
||||||
|
try:
|
||||||
|
license_mode = get_license_provider().mode
|
||||||
|
except AuthError as exc:
|
||||||
|
# 授权配置错误不应打挂探活:明确标示,由部署方修复环境变量
|
||||||
|
license_mode = f"invalid:{exc.code}"
|
||||||
|
try:
|
||||||
|
license_auth = use_license_auth(request)
|
||||||
|
except AuthError:
|
||||||
|
license_auth = False
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": _app_version(),
|
"version": _app_version(),
|
||||||
"interfaceVersion": INTERFACE_VERSION,
|
"interfaceVersion": INTERFACE_VERSION,
|
||||||
"clientMode": "desktop" if is_desktop_request(request) else "web",
|
"clientMode": "desktop" if is_desktop_request(request) else "web",
|
||||||
"authProvider": get_auth_provider().mode,
|
"authProvider": get_auth_provider().mode,
|
||||||
"licenseProvider": get_license_provider().mode,
|
"licenseProvider": license_mode,
|
||||||
|
"licenseAuth": license_auth,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/features")
|
@app.get("/api/features")
|
||||||
|
|
@ -1097,9 +1116,9 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
async def auth_login(req: AuthLoginRequest, request: Request, response: Response):
|
async def auth_login(req: AuthLoginRequest, request: Request, response: Response):
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
return auth_error_response(AuthError(
|
return auth_error_response(AuthError(
|
||||||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
"LICENSE_ACTIVATION_REQUIRED", "当前部署请使用授权码激活", 400,
|
||||||
))
|
))
|
||||||
try:
|
try:
|
||||||
identity, token = await get_auth_provider().login(req.model_dump())
|
identity, token = await get_auth_provider().login(req.model_dump())
|
||||||
|
|
@ -1111,9 +1130,9 @@ def create_app() -> FastAPI:
|
||||||
@app.get("/api/auth/tenants")
|
@app.get("/api/auth/tenants")
|
||||||
async def auth_tenants(request: Request):
|
async def auth_tenants(request: Request):
|
||||||
"""公开租户列表已下线(避免暴露企业信息);请手填企业名称登录。"""
|
"""公开租户列表已下线(避免暴露企业信息);请手填企业名称登录。"""
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
return auth_error_response(AuthError(
|
return auth_error_response(AuthError(
|
||||||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
"LICENSE_ACTIVATION_REQUIRED", "当前部署请使用授权码激活", 400,
|
||||||
))
|
))
|
||||||
return auth_error_response(AuthError(
|
return auth_error_response(AuthError(
|
||||||
"AUTH_TENANT_LIST_REMOVED",
|
"AUTH_TENANT_LIST_REMOVED",
|
||||||
|
|
@ -1128,9 +1147,9 @@ def create_app() -> FastAPI:
|
||||||
tenantCode: str = "",
|
tenantCode: str = "",
|
||||||
tenantName: str = "",
|
tenantName: str = "",
|
||||||
):
|
):
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
return auth_error_response(AuthError(
|
return auth_error_response(AuthError(
|
||||||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
"LICENSE_ACTIVATION_REQUIRED", "当前部署请使用授权码激活", 400,
|
||||||
))
|
))
|
||||||
try:
|
try:
|
||||||
return await get_auth_provider().precheck(
|
return await get_auth_provider().precheck(
|
||||||
|
|
@ -1141,9 +1160,9 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
@app.get("/api/auth/captcha")
|
@app.get("/api/auth/captcha")
|
||||||
async def auth_captcha(request: Request):
|
async def auth_captcha(request: Request):
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
return auth_error_response(AuthError(
|
return auth_error_response(AuthError(
|
||||||
"DESKTOP_USES_LICENSE", "桌面客户端请使用授权码激活", 400,
|
"LICENSE_ACTIVATION_REQUIRED", "当前部署请使用授权码激活", 400,
|
||||||
))
|
))
|
||||||
try:
|
try:
|
||||||
return await get_auth_provider().captcha()
|
return await get_auth_provider().captcha()
|
||||||
|
|
@ -1157,7 +1176,7 @@ def create_app() -> FastAPI:
|
||||||
identity, token = await provider.activate(request, req.model_dump())
|
identity, token = await provider.activate(request, req.model_dump())
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
return auth_error_response(exc)
|
return auth_error_response(exc)
|
||||||
set_license_cookie(response, token, identity.license_expires_at)
|
set_license_cookie(response, token, identity.license_expires_at, request)
|
||||||
return {
|
return {
|
||||||
"user": identity.to_dict(),
|
"user": identity.to_dict(),
|
||||||
"provider": provider.mode,
|
"provider": provider.mode,
|
||||||
|
|
@ -1167,7 +1186,7 @@ def create_app() -> FastAPI:
|
||||||
@app.get("/api/auth/me")
|
@app.get("/api/auth/me")
|
||||||
async def auth_me(request: Request) -> dict:
|
async def auth_me(request: Request) -> dict:
|
||||||
identity = get_identity(required=True)
|
identity = get_identity(required=True)
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
provider = get_license_provider()
|
provider = get_license_provider()
|
||||||
return {
|
return {
|
||||||
"user": identity.to_dict(),
|
"user": identity.to_dict(),
|
||||||
|
|
@ -1178,27 +1197,28 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
@app.post("/api/auth/refresh")
|
@app.post("/api/auth/refresh")
|
||||||
async def auth_refresh(request: Request, response: Response):
|
async def auth_refresh(request: Request, response: Response):
|
||||||
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
|
provider = get_license_provider() if use_license_auth(request) else get_auth_provider()
|
||||||
try:
|
try:
|
||||||
identity, token = await provider.refresh(request)
|
identity, token = await provider.refresh(request)
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
return auth_error_response(exc)
|
return auth_error_response(exc)
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
set_license_cookie(response, token, identity.license_expires_at)
|
set_license_cookie(response, token, identity.license_expires_at, request)
|
||||||
else:
|
else:
|
||||||
set_session_cookie(response, token)
|
set_session_cookie(response, token)
|
||||||
return {
|
return {
|
||||||
"user": identity.to_dict(),
|
"user": identity.to_dict(),
|
||||||
"provider": provider.mode,
|
"provider": provider.mode,
|
||||||
"license": provider.describe(identity) if is_desktop_request(request) else None,
|
"license": provider.describe(identity) if use_license_auth(request) else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
async def auth_logout(request: Request, response: Response) -> dict:
|
async def auth_logout(request: Request, response: Response) -> dict:
|
||||||
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
|
provider = get_license_provider() if use_license_auth(request) else get_auth_provider()
|
||||||
await provider.logout(request)
|
await provider.logout(request)
|
||||||
response.delete_cookie("aps_session", path="/")
|
response.delete_cookie("aps_session", path="/")
|
||||||
response.delete_cookie(DESKTOP_COOKIE, path="/")
|
response.delete_cookie(DESKTOP_COOKIE, path="/")
|
||||||
|
response.delete_cookie(WEB_LICENSE_COOKIE, path="/")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@app.get("/api/auth/users")
|
@app.get("/api/auth/users")
|
||||||
|
|
@ -4371,7 +4391,7 @@ def create_app() -> FastAPI:
|
||||||
ingest_actor = actor or "desktop-updater" # 本机可信通道(updater 进程)
|
ingest_actor = actor or "desktop-updater" # 本机可信通道(updater 进程)
|
||||||
else: # web 模式:要求登录身份
|
else: # web 模式:要求登录身份
|
||||||
try:
|
try:
|
||||||
if is_desktop_request(request):
|
if use_license_auth(request):
|
||||||
identity = await get_license_provider().authenticate(request)
|
identity = await get_license_provider().authenticate(request)
|
||||||
else:
|
else:
|
||||||
identity = await get_auth_provider().authenticate(request)
|
identity = await get_auth_provider().authenticate(request)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
# ============================================================
|
||||||
|
# 超级管理员授权码管理 API(moduleId: gateway-license-admin-api, 可重生 ✅)
|
||||||
|
# codes 模式(APS_LICENSE_PROVIDER=codes)下,超管在设置面板「用户与授权」
|
||||||
|
# 发放/停用/删除授权码;每个授权码即一个用户身份,数据按码隔离。
|
||||||
|
# ============================================================
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from server.auth.context import IdentityContext, get_identity
|
||||||
|
from server.auth.licenses import ADMIN_LICENSE_CODE, LICENSE_DURATIONS, LICENSE_LABELS
|
||||||
|
from server.db.database import get_session
|
||||||
|
from server.db.models import LicenseActivation, LicenseCode
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/license", tags=["license-admin"])
|
||||||
|
|
||||||
|
# 去掉 0/O/1/I/L 等易混淆字符,便于人工抄录下发
|
||||||
|
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _raise(status_code: int, code: str, message: str) -> None:
|
||||||
|
raise HTTPException(status_code=status_code, detail={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin() -> IdentityContext:
|
||||||
|
identity = get_identity(required=True)
|
||||||
|
if "admin" not in identity.roles:
|
||||||
|
_raise(403, "LICENSE_ADMIN_REQUIRED", "仅超级管理员可管理用户与授权码")
|
||||||
|
return identity
|
||||||
|
|
||||||
|
|
||||||
|
def _codes_enabled() -> None:
|
||||||
|
if (os.environ.get("APS_LICENSE_PROVIDER") or "").strip().lower() != "codes":
|
||||||
|
_raise(409, "LICENSE_MODE_DISABLED", "当前部署未启用授权码模式(APS_LICENSE_PROVIDER=codes)")
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant() -> str:
|
||||||
|
tenant = (os.environ.get("APS_LICENSE_TENANT_UUID") or "platform").strip()
|
||||||
|
return tenant or "platform"
|
||||||
|
|
||||||
|
|
||||||
|
def _code_hash(tenant: str, code: str) -> str:
|
||||||
|
# 与 CodeLicenseProvider 的派生口径一致:codes:{tenant}:{code}
|
||||||
|
return hashlib.sha256(f"codes:{tenant}:{code}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code() -> str:
|
||||||
|
return "-".join("".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) for _ in range(4))
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_text(epoch: int) -> str:
|
||||||
|
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseCodeCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
mobile: str = Field(default="", max_length=32)
|
||||||
|
company: str = Field(default="", max_length=128)
|
||||||
|
licenseType: str = Field(min_length=1, max_length=16)
|
||||||
|
code: str = Field(default="", max_length=128)
|
||||||
|
expiresAt: int | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseCodeStatusRequest(BaseModel):
|
||||||
|
status: str = Field(min_length=1, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(row: LicenseCode, activation: LicenseActivation | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"code": row.code,
|
||||||
|
"name": row.name,
|
||||||
|
"mobile": row.mobile,
|
||||||
|
"company": row.company,
|
||||||
|
"licenseType": row.license_type,
|
||||||
|
"licenseLabel": LICENSE_LABELS.get(row.license_type, row.license_type),
|
||||||
|
"expiresAt": row.expires_at,
|
||||||
|
"status": row.status,
|
||||||
|
"createdAt": row.created_at,
|
||||||
|
"activation": None if activation is None else {
|
||||||
|
"status": activation.status,
|
||||||
|
"activatedAt": activation.activated_at,
|
||||||
|
"lastSeenAt": activation.last_seen_at,
|
||||||
|
"expiresAt": activation.expires_at,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/codes")
|
||||||
|
async def list_license_codes() -> dict:
|
||||||
|
_codes_enabled()
|
||||||
|
_require_admin()
|
||||||
|
tenant = _tenant()
|
||||||
|
with get_session() as session:
|
||||||
|
rows = list(session.scalars(select(LicenseCode).where(
|
||||||
|
LicenseCode.tenant_uuid == tenant,
|
||||||
|
LicenseCode.deleted == 0,
|
||||||
|
).order_by(LicenseCode.created_at.desc())).all())
|
||||||
|
activations: dict[str, LicenseActivation] = {}
|
||||||
|
hashes = [row.code_hash for row in rows]
|
||||||
|
if hashes:
|
||||||
|
for act in session.scalars(select(LicenseActivation).where(
|
||||||
|
LicenseActivation.code_hash.in_(hashes),
|
||||||
|
LicenseActivation.deleted == 0,
|
||||||
|
)).all():
|
||||||
|
activations[act.code_hash] = act
|
||||||
|
return {"codes": [_serialize(row, activations.get(row.code_hash)) for row in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codes")
|
||||||
|
async def create_license_code(req: LicenseCodeCreateRequest) -> dict:
|
||||||
|
_codes_enabled()
|
||||||
|
identity = _require_admin()
|
||||||
|
license_type = req.licenseType.strip().lower()
|
||||||
|
now = int(time.time())
|
||||||
|
expires_at = req.expiresAt
|
||||||
|
if license_type == "custom":
|
||||||
|
if not expires_at:
|
||||||
|
_raise(400, "LICENSE_EXPIRY_REQUIRED", "自定义授权必须选择到期时间")
|
||||||
|
elif license_type not in LICENSE_DURATIONS:
|
||||||
|
_raise(400, "LICENSE_TYPE_INVALID", "授权时长类型无效")
|
||||||
|
if expires_at is not None and expires_at <= now:
|
||||||
|
_raise(400, "LICENSE_EXPIRY_PAST", "到期时间必须晚于当前时间")
|
||||||
|
tenant = _tenant()
|
||||||
|
code = req.code.strip()
|
||||||
|
with get_session() as session:
|
||||||
|
if code:
|
||||||
|
if code == ADMIN_LICENSE_CODE:
|
||||||
|
_raise(409, "LICENSE_CODE_DUPLICATED", "该授权码为内置超级管理员码")
|
||||||
|
code_hash = _code_hash(tenant, code)
|
||||||
|
clash = session.scalar(select(LicenseCode).where(
|
||||||
|
LicenseCode.code_hash == code_hash,
|
||||||
|
LicenseCode.deleted == 0,
|
||||||
|
))
|
||||||
|
if clash is not None:
|
||||||
|
_raise(409, "LICENSE_CODE_DUPLICATED", "授权码已存在")
|
||||||
|
else:
|
||||||
|
code_hash = ""
|
||||||
|
for _ in range(20):
|
||||||
|
candidate = _generate_code()
|
||||||
|
if candidate == ADMIN_LICENSE_CODE:
|
||||||
|
continue
|
||||||
|
candidate_hash = _code_hash(tenant, candidate)
|
||||||
|
clash = session.scalar(select(LicenseCode).where(
|
||||||
|
LicenseCode.code_hash == candidate_hash,
|
||||||
|
LicenseCode.deleted == 0,
|
||||||
|
))
|
||||||
|
if clash is None:
|
||||||
|
code, code_hash = candidate, candidate_hash
|
||||||
|
break
|
||||||
|
if not code:
|
||||||
|
_raise(500, "LICENSE_CODE_GEN_FAILED", "授权码生成失败,请重试")
|
||||||
|
now = int(time.time())
|
||||||
|
row = LicenseCode(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
code=code,
|
||||||
|
code_hash=code_hash,
|
||||||
|
name=req.name.strip(),
|
||||||
|
mobile=req.mobile.strip(),
|
||||||
|
company=req.company.strip(),
|
||||||
|
license_type=license_type,
|
||||||
|
status="active",
|
||||||
|
expires_at=expires_at,
|
||||||
|
tenant_uuid=tenant,
|
||||||
|
creator_id=identity.user_id or None,
|
||||||
|
updater_id=identity.user_id or None,
|
||||||
|
updated_at=_utc_text(now),
|
||||||
|
deleted=0,
|
||||||
|
created_at=_utc_text(now),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return {"code": _serialize(row, None)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/codes/{code_id}/status")
|
||||||
|
async def set_license_code_status(code_id: str, req: LicenseCodeStatusRequest) -> dict:
|
||||||
|
_codes_enabled()
|
||||||
|
identity = _require_admin()
|
||||||
|
status = req.status.strip().lower()
|
||||||
|
if status not in {"active", "disabled"}:
|
||||||
|
_raise(400, "LICENSE_STATUS_INVALID", "状态无效(active/disabled)")
|
||||||
|
now = int(time.time())
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.get(LicenseCode, code_id)
|
||||||
|
if row is None or row.deleted != 0:
|
||||||
|
_raise(404, "LICENSE_CODE_NOT_FOUND", "授权码不存在")
|
||||||
|
row.status = status
|
||||||
|
row.updater_id = identity.user_id or None
|
||||||
|
row.updated_at = _utc_text(now)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True, "status": status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/codes/{code_id}")
|
||||||
|
async def delete_license_code(code_id: str) -> dict:
|
||||||
|
_codes_enabled()
|
||||||
|
identity = _require_admin()
|
||||||
|
now = int(time.time())
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.get(LicenseCode, code_id)
|
||||||
|
if row is None or row.deleted != 0:
|
||||||
|
_raise(404, "LICENSE_CODE_NOT_FOUND", "授权码不存在")
|
||||||
|
row.deleted = 1
|
||||||
|
row.updater_id = identity.user_id or None
|
||||||
|
row.updated_at = _utc_text(now)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
"""正式授权码(codes)模式:Web 端授权码即身份的端到端用例。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from tests.auth_provider import install_test_auth
|
||||||
|
|
||||||
|
TENANT = "tenant-codes-000000000000000001"
|
||||||
|
CODES = {
|
||||||
|
"USER-CODE-ALPHA": "year",
|
||||||
|
"USER-CODE-BETA": {"type": "month", "name": "beta-user"},
|
||||||
|
"USER-CODE-HOUR": "hour",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def codes_app(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("APS_LICENSE_PROVIDER", "codes")
|
||||||
|
monkeypatch.setenv("APS_LICENSE_SECRET", "codes-test-secret-0123456789abcdef")
|
||||||
|
monkeypatch.setenv("APS_LICENSE_TENANT_UUID", TENANT)
|
||||||
|
monkeypatch.setenv("APS_LICENSE_CLIENTS", "web,desktop")
|
||||||
|
monkeypatch.setenv("APS_LICENSE_CODES", json.dumps(CODES))
|
||||||
|
# 钉死认证提供方:套件中途 server.main 的 load_dotenv 可能把开发机 .env 的 jms 重新注入
|
||||||
|
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
|
||||||
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "codes.db"))
|
||||||
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||||
|
|
||||||
|
from server.auth import licenses
|
||||||
|
from server.auth import providers as auth_providers
|
||||||
|
from server.db.database import reset_engine
|
||||||
|
from server.state import store as world_store
|
||||||
|
|
||||||
|
install_test_auth(monkeypatch, TENANT)
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
world_store._stores.clear()
|
||||||
|
reset_engine()
|
||||||
|
|
||||||
|
from server.gateway.app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
yield app
|
||||||
|
|
||||||
|
reset_engine()
|
||||||
|
world_store._stores.clear()
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_license_auth(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
body = client.get("/api/health").json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["licenseProvider"] == "codes"
|
||||||
|
assert body["licenseAuth"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_survives_missing_secret(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("APS_LICENSE_PROVIDER", "codes")
|
||||||
|
monkeypatch.delenv("APS_LICENSE_SECRET", raising=False)
|
||||||
|
monkeypatch.setenv("APS_LICENSE_CLIENTS", "web")
|
||||||
|
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
|
||||||
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "bad.db"))
|
||||||
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||||
|
|
||||||
|
from server.auth import licenses
|
||||||
|
from server.auth import providers as auth_providers
|
||||||
|
from server.db.database import reset_engine
|
||||||
|
from server.state import store as world_store
|
||||||
|
|
||||||
|
install_test_auth(monkeypatch, TENANT)
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
world_store._stores.clear()
|
||||||
|
reset_engine()
|
||||||
|
|
||||||
|
from server.gateway.app import create_app
|
||||||
|
|
||||||
|
client = TestClient(create_app())
|
||||||
|
response = client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["licenseProvider"] == "invalid:LICENSE_CONFIG_INVALID"
|
||||||
|
|
||||||
|
reset_engine()
|
||||||
|
world_store._stores.clear()
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_requires_secret(monkeypatch):
|
||||||
|
from server.auth.licenses import CodeLicenseProvider
|
||||||
|
from server.auth.providers import AuthError
|
||||||
|
|
||||||
|
monkeypatch.delenv("APS_LICENSE_SECRET", raising=False)
|
||||||
|
with pytest.raises(AuthError, match="授权配置缺失") as exc:
|
||||||
|
CodeLicenseProvider()
|
||||||
|
assert exc.value.code == "LICENSE_CONFIG_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_activation_binds_identity_and_isolates_data(codes_app):
|
||||||
|
alpha = TestClient(codes_app)
|
||||||
|
beta = TestClient(codes_app)
|
||||||
|
|
||||||
|
response = alpha.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["user"]["auth_kind"] == "license"
|
||||||
|
assert body["user"]["tenant_uuid"] == TENANT
|
||||||
|
assert body["license"]["type"] == "year"
|
||||||
|
alpha_id = body["user"]["user_id"]
|
||||||
|
|
||||||
|
assert beta.post("/api/auth/license/activate", json={"code": "USER-CODE-BETA"}).status_code == 200
|
||||||
|
beta_me = beta.get("/api/auth/me").json()
|
||||||
|
assert beta_me["user"]["username"] == "beta-user"
|
||||||
|
assert beta_me["user"]["user_id"] != alpha_id
|
||||||
|
|
||||||
|
assert alpha.post(
|
||||||
|
"/api/projects", json={"id": "alpha_private", "name": "授权A的私有项目"},
|
||||||
|
).status_code == 200
|
||||||
|
workspace = beta.get("/api/workspace")
|
||||||
|
assert workspace.status_code == 200
|
||||||
|
assert workspace.json()["projects"] == []
|
||||||
|
assert beta.get("/api/projects/alpha_private/members").status_code == 404
|
||||||
|
|
||||||
|
# 同一授权码换浏览器激活:找回同一身份与同一份数据
|
||||||
|
alpha_second_browser = TestClient(codes_app)
|
||||||
|
assert alpha_second_browser.post(
|
||||||
|
"/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"},
|
||||||
|
).status_code == 200
|
||||||
|
me_again = alpha_second_browser.get("/api/auth/me").json()
|
||||||
|
assert me_again["user"]["user_id"] == alpha_id
|
||||||
|
projects = alpha_second_browser.get("/api/workspace").json()["projects"]
|
||||||
|
assert any(project["id"] == "alpha_private" for project in projects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_license_code_is_permanent_with_admin_roles(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
response = client.post("/api/auth/license/activate", json={"code": "aps@Z@Agent@Admin"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["user"]["fullname"] == "超级管理员"
|
||||||
|
assert "admin" in body["user"]["roles"]
|
||||||
|
assert body["license"]["type"] == "permanent"
|
||||||
|
assert body["license"]["expiresAt"] is None
|
||||||
|
assert body["license"]["permanent"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_codes_are_case_sensitive(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
response = client.post("/api/auth/license/activate", json={"code": "user-code-alpha"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_and_blank_code_rejected(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
response = client.post("/api/auth/license/activate", json={"code": "NO-SUCH-CODE"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
|
||||||
|
blank = client.post("/api/auth/license/activate", json={"code": " "})
|
||||||
|
assert blank.status_code == 400
|
||||||
|
assert blank.json()["error"]["code"] == "LICENSE_CODE_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unactivated_web_request_requires_license(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
response = client.get("/api/auth/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_REQUIRED"
|
||||||
|
assert "授权码" in response.json()["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_endpoint_redirects_to_license_under_codes(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
response = client.post("/api/auth/login", json={
|
||||||
|
"method": "password", "username": "planner", "password": "test",
|
||||||
|
})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_ACTIVATION_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_code_is_rejected_on_next_request(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
assert client.post(
|
||||||
|
"/api/auth/license/activate", json={"code": "USER-CODE-HOUR"},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
from server.db.database import get_session
|
||||||
|
from server.db.models import LicenseActivation
|
||||||
|
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.scalar(select(LicenseActivation))
|
||||||
|
assert row is not None
|
||||||
|
row.expires_at = int(time.time()) - 1
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
response = client.get("/api/auth/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_EXPIRED"
|
||||||
|
|
||||||
|
again = client.post("/api/auth/license/activate", json={"code": "USER-CODE-HOUR"})
|
||||||
|
assert again.status_code == 401
|
||||||
|
assert again.json()["error"]["code"] == "LICENSE_EXPIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_desktop_client_also_activates_with_codes(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
headers = {"X-APS-Client": "desktop", "X-APS-Device-ID": "device-codes-0001"}
|
||||||
|
response = client.post("/api/auth/license/activate", headers=headers, json={"code": "USER-CODE-ALPHA"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
me = client.get("/api/auth/me", headers=headers)
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["user"]["auth_kind"] == "license"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_web_license_session(codes_app):
|
||||||
|
client = TestClient(codes_app)
|
||||||
|
assert client.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"}).status_code == 200
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
assert client.post("/api/auth/logout").status_code == 200
|
||||||
|
assert client.get("/api/auth/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 超管授权码管理 API ----------------
|
||||||
|
|
||||||
|
def _admin_client(app) -> TestClient:
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.post("/api/auth/license/activate", json={"code": "aps@Z@Agent@Admin"}).status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_issues_code_and_user_activates_with_name(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
created = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "张三", "mobile": "13800001111", "company": "锐扬", "licenseType": "month",
|
||||||
|
})
|
||||||
|
assert created.status_code == 200
|
||||||
|
issued = created.json()["code"]
|
||||||
|
assert issued["name"] == "张三"
|
||||||
|
assert len(issued["code"]) == 19 # XXXX-XXXX-XXXX-XXXX
|
||||||
|
|
||||||
|
listed = admin.get("/api/admin/license/codes").json()["codes"]
|
||||||
|
assert any(item["id"] == issued["id"] for item in listed)
|
||||||
|
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["user"]["username"] == "张三"
|
||||||
|
assert body["user"]["fullname"] == "张三"
|
||||||
|
assert body["license"]["type"] == "month"
|
||||||
|
|
||||||
|
listed_after = admin.get("/api/admin/license/codes").json()["codes"]
|
||||||
|
entry = next(item for item in listed_after if item["id"] == issued["id"])
|
||||||
|
assert entry["activation"] is not None
|
||||||
|
assert entry["activation"]["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_manage_codes(codes_app):
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
assert user.post("/api/auth/license/activate", json={"code": "USER-CODE-ALPHA"}).status_code == 200
|
||||||
|
response = user.get("/api/admin/license/codes")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"]["code"] == "LICENSE_ADMIN_REQUIRED"
|
||||||
|
denied = user.post("/api/admin/license/codes", json={"name": "越权", "licenseType": "year"})
|
||||||
|
assert denied.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_code_blocks_existing_session_and_new_activation(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
issued = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "李四", "licenseType": "year",
|
||||||
|
}).json()["code"]
|
||||||
|
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
assert user.post("/api/auth/license/activate", json={"code": issued["code"]}).status_code == 200
|
||||||
|
assert user.get("/api/auth/me").status_code == 200
|
||||||
|
|
||||||
|
assert admin.post(
|
||||||
|
f"/api/admin/license/codes/{issued['id']}/status", json={"status": "disabled"},
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
denied = user.get("/api/auth/me")
|
||||||
|
assert denied.status_code == 401
|
||||||
|
assert denied.json()["error"]["code"] == "LICENSE_REVOKED"
|
||||||
|
reactivate = user.post("/api/auth/license/activate", json={"code": issued["code"]})
|
||||||
|
assert reactivate.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_code_removes_it_from_catalog(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
issued = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "王五", "licenseType": "day",
|
||||||
|
}).json()["code"]
|
||||||
|
assert admin.delete(f"/api/admin/license/codes/{issued['id']}").status_code == 200
|
||||||
|
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_CODE_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_and_admin_code_rejected(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
first = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "甲", "licenseType": "year", "code": "CUSTOM-CODE-1",
|
||||||
|
})
|
||||||
|
assert first.status_code == 200
|
||||||
|
dup = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "乙", "licenseType": "year", "code": "CUSTOM-CODE-1",
|
||||||
|
})
|
||||||
|
assert dup.status_code == 409
|
||||||
|
admin_code = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "丙", "licenseType": "year", "code": "aps@Z@Agent@Admin",
|
||||||
|
})
|
||||||
|
assert admin_code.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_license_app(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("APS_LICENSE_PROVIDER", "mock")
|
||||||
|
monkeypatch.setenv("APS_MOCK_LICENSE_SECRET", "license-test-secret-0123456789abcdef")
|
||||||
|
monkeypatch.setenv("APS_MOCK_TENANT_UUID", TENANT)
|
||||||
|
monkeypatch.setenv("APS_AUTH_PROVIDER", "unconfigured")
|
||||||
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "mock.db"))
|
||||||
|
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||||
|
|
||||||
|
from server.auth import licenses
|
||||||
|
from server.auth import providers as auth_providers
|
||||||
|
from server.db.database import reset_engine
|
||||||
|
from server.state import store as world_store
|
||||||
|
|
||||||
|
install_test_auth(monkeypatch, TENANT)
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
world_store._stores.clear()
|
||||||
|
reset_engine()
|
||||||
|
|
||||||
|
from server.gateway.app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
yield app
|
||||||
|
|
||||||
|
reset_engine()
|
||||||
|
world_store._stores.clear()
|
||||||
|
licenses._provider = None
|
||||||
|
licenses._provider_mode = None
|
||||||
|
auth_providers._provider = None
|
||||||
|
auth_providers._provider_mode = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_api_requires_codes_mode(mock_license_app):
|
||||||
|
# mock 模式下管理接口明确拒绝,避免误以为配置生效
|
||||||
|
client = TestClient(mock_license_app)
|
||||||
|
headers = {"X-APS-Client": "desktop", "X-APS-Device-ID": "device-admin-0001"}
|
||||||
|
assert client.post(
|
||||||
|
"/api/auth/license/activate", headers=headers, json={"code": "APS-PERP-DEMO"},
|
||||||
|
).status_code == 200
|
||||||
|
response = client.get("/api/admin/license/codes", headers=headers)
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"]["code"] == "LICENSE_MODE_DISABLED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_expiry_code_uses_absolute_deadline(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
deadline = int(time.time()) + 3 * 24 * 3600
|
||||||
|
created = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "自定义用户", "licenseType": "custom", "expiresAt": deadline,
|
||||||
|
})
|
||||||
|
assert created.status_code == 200
|
||||||
|
issued = created.json()["code"]
|
||||||
|
assert issued["licenseType"] == "custom"
|
||||||
|
assert issued["expiresAt"] == deadline
|
||||||
|
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
body = user.post("/api/auth/license/activate", json={"code": issued["code"]}).json()
|
||||||
|
assert body["license"]["type"] == "custom"
|
||||||
|
assert body["license"]["label"] == "自定义授权"
|
||||||
|
assert body["license"]["expiresAt"] == deadline
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_expiry_validation(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
missing = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "缺时间", "licenseType": "custom",
|
||||||
|
})
|
||||||
|
assert missing.status_code == 400
|
||||||
|
assert missing.json()["detail"]["code"] == "LICENSE_EXPIRY_REQUIRED"
|
||||||
|
|
||||||
|
past = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "过去时间", "licenseType": "custom", "expiresAt": int(time.time()) - 60,
|
||||||
|
})
|
||||||
|
assert past.status_code == 400
|
||||||
|
assert past.json()["detail"]["code"] == "LICENSE_EXPIRY_PAST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_custom_code_cannot_activate(codes_app):
|
||||||
|
admin = _admin_client(codes_app)
|
||||||
|
deadline = int(time.time()) + 3600
|
||||||
|
issued = admin.post("/api/admin/license/codes", json={
|
||||||
|
"name": "将到期", "licenseType": "custom", "expiresAt": deadline,
|
||||||
|
}).json()["code"]
|
||||||
|
|
||||||
|
# 直接把码的截止时间改到过去,模拟到期后激活
|
||||||
|
from server.db.database import get_session
|
||||||
|
from server.db.models import LicenseCode
|
||||||
|
|
||||||
|
with get_session() as session:
|
||||||
|
row = session.get(LicenseCode, issued["id"])
|
||||||
|
row.expires_at = int(time.time()) - 1
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
user = TestClient(codes_app)
|
||||||
|
response = user.post("/api/auth/license/activate", json={"code": issued["code"]})
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "LICENSE_EXPIRED"
|
||||||
Loading…
Reference in New Issue