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:
@@ -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
@@ -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 || '重置密码失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user