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).
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
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();
|