Files
Agent-System/web/src/api/auth.ts
T
weichen 39f8c1d134 feat(auth): admin password reset API and credential guards
Implement T20-F4-B reset-password endpoint with one-time temp password,
audit table, frontend wiring, login error envelope parsing, and
CreateUser fail-closed for empty password hashes. Recovered test chenwei
via env write (password out of band).
2026-09-24 04:18:58 +00:00

509 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
UserAccount,
SystemAuthConfig,
LoginPayload,
LoginResult,
RegisterPayload,
RegisterResult,
CreateAdminUserPayload,
AccountStatus,
MeResult,
} from '../types/auth';
import { RoleDefinition } from '../types/rbac';
import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap';
import { apiErrorFromBody, apiFetch } from './client';
const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session';
/** Prefer domain error text over envelope SafeMessage ("无权访问"). */
export function authErrorMessage(
err: { code: string; message: string },
bodyText: string,
fallback: string,
): string {
try {
const parsed = JSON.parse(bodyText) as { error?: string; code?: string; message?: string };
const domain = typeof parsed.error === 'string' ? parsed.error.trim() : '';
if (domain && domain[0] !== '{' && domain !== parsed.code && domain !== err.code) {
return domain;
}
} catch {
// fall through
}
if (err.message && err.message !== '无权访问' && err.message[0] !== '{') {
return err.message;
}
return fallback;
}
export interface AuthClientOptions {
adminUsername?: string;
adminPassword?: string;
env?: string;
allowRegistration?: boolean;
requireApproval?: boolean;
/** 仅测试使用:启用内存/localStorage 本地鉴权 */
localFallback?: boolean;
}
async function authRequest(path: string, init?: RequestInit, withAuth = false): Promise<Response> {
if (typeof fetch !== 'function') {
throw new Error('后端服务不可用,请检查网络连接');
}
return apiFetch(path, init, { auth: withAuth, retry: false });
}
/**
* 生产 AuthClient:仅调用后端 API,fail closed。
*/
export class AuthClient {
constructor(_options?: AuthClientOptions) {}
async fetchProfile(): Promise<Record<string, unknown>> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/me/profile', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('无法加载个人资料');
}
return resp.json();
}
async updateMyProfile(patch: {
name?: string;
department?: string;
email?: string;
locale?: string;
timezone?: string;
}): Promise<UserAccount> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/me/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(patch),
}, true);
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '保存个人资料失败');
}
return mapUserAccount(await resp.json());
}
async fetchUserDirectory(): Promise<UserAccount[]> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/users/directory', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('获取用户目录失败');
}
const data = await resp.json();
return mapUserAccountList(data.users);
}
async adminUpdateUserProfile(
userId: number,
patch: { name?: string; department?: string; email?: string; employeeId?: string },
): Promise<UserAccount> {
const token = this.getSessionToken();
const body: Record<string, string> = {};
if (patch.name !== undefined) body.name = patch.name;
if (patch.department !== undefined) body.department = patch.department;
if (patch.email !== undefined) body.email = patch.email;
if (patch.employeeId !== undefined) body.employee_id = patch.employeeId;
const resp = await authRequest(`/api/v1/admin/users/${userId}/profile`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
}, true);
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '更新用户资料失败');
}
return mapUserAccount(await resp.json());
}
async savePreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/me/preferences', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(prefs),
}, true);
if (!resp.ok) {
throw new Error('保存偏好失败');
}
return resp.json();
}
async fetchMe(): Promise<MeResult> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/auth/me', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('会话已过期或未登录');
}
const data = mapMeResult(await resp.json());
const saved = this.loadSavedSession();
if (saved && data.user) {
this.saveSession({
...saved,
user: data.user,
env: data.env ?? saved.env,
mustChangePassword: data.mustChangePassword ?? saved.mustChangePassword,
capabilities: data.capabilities ?? [],
});
}
return data;
}
getCapabilities(): string[] {
return this.loadSavedSession()?.capabilities ?? [];
}
async login(payload: LoginPayload): Promise<LoginResult> {
const trimmedUser = payload.username.trim();
if (!trimmedUser || !payload.password) {
throw new Error('用户名和密码不能为空');
}
const resp = await authRequest('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const errText = await resp.text();
const apiErr = apiErrorFromBody(resp.status, errText, '用户名或密码错误');
throw new Error(authErrorMessage(apiErr, errText, '用户名或密码错误'));
}
const data = mapLoginResult(await resp.json());
this.saveSession(data);
try {
const me = await this.fetchMe();
data.capabilities = me.capabilities;
data.user = me.user;
data.mustChangePassword = me.mustChangePassword ?? data.mustChangePassword;
this.saveSession(data);
} catch {
// login 已成功;capabilities 可在后续 refresh 补齐
}
return data;
}
async register(payload: RegisterPayload): Promise<RegisterResult> {
const resp = await authRequest('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '注册失败');
}
return resp.json();
}
async changePassword(_userId: number, oldPass: string, newPass: string): Promise<boolean> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ old_password: oldPass, new_password: newPass }),
}, true);
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '修改密码失败');
}
return true;
}
async getAdminUsers(): Promise<UserAccount[]> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/admin/users', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('获取用户列表失败');
}
const data = await resp.json();
return mapUserAccountList(data.users);
}
async listAllUsers(): Promise<UserAccount[]> {
return this.getAdminUsers();
}
async adminResetPassword(userId: number): Promise<{ tempPassword: string; mustChangePassword: boolean }> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: '{}',
}, true);
if (!resp.ok) {
const errText = await resp.text();
const apiErr = apiErrorFromBody(resp.status, errText, '重置密码失败');
throw new Error(authErrorMessage(apiErr, errText, '重置密码失败'));
}
const data = (await resp.json()) as {
temp_password?: string;
must_change_password?: boolean;
};
const tempPassword = String(data.temp_password ?? '').trim();
if (!tempPassword) {
throw new Error('重置成功但未返回临时口令,请联系运维');
}
return {
tempPassword,
mustChangePassword: data.must_change_password !== false,
};
}
async createAdminUser(payload: CreateAdminUserPayload): Promise<UserAccount> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/admin/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
username: payload.username.trim(),
name: payload.name.trim(),
initial_password: payload.initialPassword,
role_id: payload.roleId,
position: payload.position,
department: payload.department,
employee_id: payload.employeeId,
}),
}, true);
if (!resp.ok) {
throw new Error('创建用户失败');
}
return mapUserAccount(await resp.json());
}
async updateUserRole(userId: number, roleId: string, position: string): Promise<UserAccount> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/admin/users/${userId}/role`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ role_id: roleId, position }),
}, true);
if (!resp.ok) throw new Error('更新角色失败');
return mapUserAccount(await resp.json());
}
async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise<UserAccount> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ status, role_id: roleId, position }),
}, true);
if (!resp.ok) throw new Error('更新状态失败');
return mapUserAccount(await resp.json());
}
async getSystemAuthConfig(): Promise<SystemAuthConfig> {
const resp = await authRequest('/api/v1/auth/public-config');
if (!resp.ok) {
return { allowRegistration: false, requireApproval: true };
}
const data = (await resp.json()) as {
allow_registration?: boolean;
require_approval?: boolean;
};
return {
allowRegistration: data.allow_registration ?? false,
requireApproval: data.require_approval ?? true,
};
}
async listRoles(): Promise<RoleDefinition[]> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/admin/roles', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('获取角色列表失败');
}
const data = await resp.json();
return (data.roles ?? []).map(mapApiRole);
}
async createRole(payload: {
code: string;
name: string;
description?: string;
permissions: string[];
}): Promise<RoleDefinition> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/admin/roles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
}, true);
if (!resp.ok) {
throw new Error('创建角色失败');
}
return mapApiRole(await resp.json());
}
async updateRole(
roleId: string,
payload: { name: string; description?: string; permissions: string[] }
): Promise<RoleDefinition> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
}, true);
if (!resp.ok) {
throw new Error('更新角色失败');
}
return mapApiRole(await resp.json());
}
async deleteRole(roleId: string): Promise<void> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('删除角色失败');
}
}
async listSessions(): Promise<
Array<{ id: string; created_at: string; last_seen_at: string; user_agent: string; is_current: boolean }>
> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/auth/sessions', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('获取会话列表失败');
}
const data = await resp.json();
return data.sessions ?? [];
}
async revokeSession(sessionId: string): Promise<void> {
const token = this.getSessionToken();
const resp = await authRequest(`/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('撤销会话失败');
}
}
async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise<SystemAuthConfig> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/admin/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(newConfig),
}, true);
if (!resp.ok) throw new Error('更新系统配置失败');
return resp.json();
}
async logout(): Promise<void> {
const token = this.getSessionToken();
try {
await authRequest('/api/v1/auth/logout', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
} catch {
// 网络失败仍清除本地态
}
this.clearLocalSession();
}
clearLocalSession() {
const win = (globalThis as { window?: Window }).window;
if (win?.localStorage) {
win.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION);
}
}
saveSession(session: LoginResult) {
const win = (globalThis as { window?: Window }).window;
if (win?.localStorage) {
win.localStorage.setItem(AUTH_STORAGE_KEY_SESSION, JSON.stringify(session));
}
}
loadSavedSession(): LoginResult | null {
const win = (globalThis as { window?: Window }).window;
if (!win?.localStorage) return null;
try {
const data = win.localStorage.getItem(AUTH_STORAGE_KEY_SESSION);
if (!data) return null;
const parsed = JSON.parse(data);
// Re-normalize in case a prior build persisted snake_case user fields.
return mapLoginResult(parsed);
} catch {
return null;
}
}
getSessionToken(): string | null {
const s = this.loadSavedSession();
return s?.token || null;
}
}
function mapApiRole(raw: Record<string, unknown>): RoleDefinition {
return {
id: String(raw.id ?? ''),
code: String(raw.code ?? ''),
name: String(raw.name ?? ''),
description: String(raw.description ?? ''),
isSystem: Boolean(raw.is_system),
permissions: Array.isArray(raw.permissions) ? (raw.permissions as RoleDefinition['permissions']) : [],
createdAt: String(raw.created_at ?? ''),
updatedAt: String(raw.updated_at ?? ''),
};
}
export const authClient = new AuthClient();