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).
This commit is contained in:
weichen
2026-09-24 04:18:58 +00:00
parent 3de5bc5c39
commit 39f8c1d134
20 changed files with 668 additions and 25 deletions
+10
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInMemoryAuthClient } from './auth.local';
import { authErrorMessage } from './auth';
test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => {
const client = createInMemoryAuthClient({
@@ -112,3 +113,12 @@ test('AuthClient: 管理员创建账号必须在首次登录修改密码', async
const updatedSession = await client.login({ username: 'lead_zhao', password: 'NewPermanentPassword888' });
assert.equal(updatedSession.mustChangePassword, false);
});
test('authErrorMessage: 优先信封 error 领域原因,避免裸 JSON / 无权访问误导', () => {
const body =
'{"code":"AUTHZ","message":"无权访问","request_id":"req_x","error":"用户名或密码错误"}';
const msg = authErrorMessage({ code: 'AUTHZ', message: '无权访问' }, body, '用户名或密码错误');
assert.equal(msg, '用户名或密码错误');
assert.ok(!msg.includes('{'));
assert.notEqual(msg, '无权访问');
});
+51 -4
View File
@@ -11,10 +11,31 @@ import {
} from '../types/auth';
import { RoleDefinition } from '../types/rbac';
import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap';
import { apiFetch } from './client';
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;
@@ -163,7 +184,8 @@ export class AuthClient {
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(errText.trim() || '用户名或密码错误');
const apiErr = apiErrorFromBody(resp.status, errText, '用户名或密码错误');
throw new Error(authErrorMessage(apiErr, errText, '用户名或密码错误'));
}
const data = mapLoginResult(await resp.json());
this.saveSession(data);
@@ -225,8 +247,33 @@ export class AuthClient {
return this.getAdminUsers();
}
async adminResetPassword(_userId: number, _tempPass: string): Promise<boolean> {
throw new Error('请通过后端管理员接口重置密码');
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> {
@@ -180,21 +180,33 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
}
};
// 4. 重置用户密码为初始并强制改密
// 4. 重置用户密码:服务端生成一次性临时口令并强制改密
const handleResetUserPassword = async (user: UserAccount) => {
const tempPass = 'ResetPass123';
if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return;
try {
await authClient.adminResetPassword(user.id, tempPass);
} catch (err: any) {
showNotification('error', err.message || '重置密码失败');
if (
!window.confirm(
`确定要重置【${user.name}】的密码吗?系统将生成一次性临时口令,该用户下次登录将被强制改密。临时口令仅展示一次。`,
)
) {
return;
}
try {
await refreshUsers();
showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`);
} catch {
showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试');
const result = await authClient.adminResetPassword(user.id);
try {
await refreshUsers();
} catch {
// password already reset; continue to show temp password
}
const copied = window.prompt(
`已重置【${user.name}】密码。请立即复制临时口令(关闭后不可再查看):`,
result.tempPassword,
);
if (copied === null) {
showNotification('success', '密码已重置;请手动记下上方弹出的临时口令');
} else {
showNotification('success', '密码已重置,已标记首次登录强制改密');
}
} catch (err: any) {
showNotification('error', err.message || '重置密码失败');
}
};