feat(M08-D): 补后台登录与可撤销会话

This commit is contained in:
Codex
2026-08-10 13:54:03 +08:00
parent 93d83e81b9
commit 05110087d1
21 changed files with 1059 additions and 48 deletions
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { AdminAuthError } from '../dist/auth/admin-auth-repository.js';
import { hashPassword, verifyPassword } from '../dist/auth/password.js';
const secret = 'test-only-admin-password-auth-secret-32';
const user = { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
nickname: '租户管理员', avatarUrl: '', phone: '13800138000' };
let loginInput; let refreshInput; let credentialInput; let revokedSessionId;
const repository = {
async loginWithPassword(input) {
loginInput = input;
if (input.password === 'wrong-password') throw new AdminAuthError('ADMIN_LOGIN_INVALID');
if (input.password === 'locked-password') throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user, expiresAt: input.expiresAt };
},
async rotateRefreshToken(input) {
refreshInput = input;
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user,
expiresAt: new Date(Date.now() + 60000) };
},
async setCredential(actor, tenantId, input) {
credentialInput = { actor, tenantId, input };
return { userId: input.userId, configured: true };
}
};
const authRepository = {
async validateSession(sessionId) {
return { id: sessionId, tenantId: '7', platformAppId: '9', user,
expiresAt: new Date(Date.now() + 60000) };
},
async revokeSession(sessionId) { revokedSessionId = sessionId; return true; }
};
const access = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
const app = await buildApp({ adminAuth: { repository, authRepository,
accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret,
accessTokenTtlSeconds: 900, sessionTtlSeconds: 604800 } });
const login = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'Admin.User', ['password']: 'ValidPassword!123' } });
assert.equal(login.statusCode, 200);
assert.equal(loginInput.tenantCode, 'demo');
assert.equal(login.json().data.user.phone, undefined);
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
assert.ok(login.json().data.access.menus.includes('system'));
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
const successfulSessionId = loginInput.sessionId;
const bad = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'wrong-password' } });
assert.equal(bad.statusCode, 401);
const locked = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'locked-password' } });
assert.equal(locked.statusCode, 423);
const refreshed = await app.inject({ method: 'POST', url: '/admin-api/auth/refresh',
payload: { refreshToken: login.json().data.refreshToken } });
assert.equal(refreshed.statusCode, 200);
assert.equal(refreshInput.sessionId, successfulSessionId);
assert.notEqual(refreshed.json().data.refreshToken, login.json().data.refreshToken);
const auth = { authorization: `Bearer ${login.json().data.accessToken}` };
const me = await app.inject({ method: 'GET', url: '/admin-api/auth/me', headers: auth });
assert.equal(me.statusCode, 200);
assert.equal(me.json().data.user.nickname, '租户管理员');
const weakCredential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
headers: auth, payload: { loginName: 'operator', ['password']: 'too-weak' } });
assert.equal(weakCredential.statusCode, 400);
const credential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
headers: auth, payload: { loginName: 'Operator.22', ['password']: 'StrongPassword!2026' } });
assert.equal(credential.statusCode, 200);
assert.equal(credentialInput.tenantId, '7');
assert.equal(credentialInput.input.loginName, 'Operator.22');
assert.match(credentialInput.input.passwordHash, /^scrypt\$16384\$8\$1\$/);
const logout = await app.inject({ method: 'POST', url: '/admin-api/auth/logout', headers: auth });
assert.equal(logout.statusCode, 200);
assert.equal(revokedSessionId, successfulSessionId);
await app.close();
const passwordHash = await hashPassword('StrongPassword!2026');
assert.equal(await verifyPassword('StrongPassword!2026', passwordHash), true);
assert.equal(await verifyPassword('WrongPassword!2026', passwordHash), false);
assert.equal(await verifyPassword('anything', 'invalid-hash'), false);
console.log('PASS: M08-D admin password login, rotating refresh, session cleanup and credential setup are present.');
@@ -105,6 +105,9 @@ const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_con
const franchiseUpSql = read('database/migrations/2026081003_m08d_franchise_leads.up.sql');
const franchiseDownSql = read('database/migrations/2026081003_m08d_franchise_leads.down.sql');
const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_leads.verify.sql');
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -480,6 +483,12 @@ assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_applica
assert.match(franchiseUpSql, /CREATE TABLE IF NOT EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseUpSql, /uq_qipai_franchise_client_request/);
assert.match(franchiseUpSql, /'2026081003'/);
assert.match(adminAuthUpSql, /CREATE TABLE IF NOT EXISTS qipai_admin_credentials/);
assert.match(adminAuthUpSql, /refresh_token_hash CHAR\(64\)/);
assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
+4 -2
View File
@@ -42,14 +42,16 @@ assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql$/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
const calls = [];
const fakePool = {
@@ -43,6 +43,8 @@ import { DeviceControlService } from '../dist/devices/device-control-service.js'
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
import { hashPassword } from '../dist/auth/password.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -50,6 +52,7 @@ import {
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_admin_credentials',
'qipai_advertisements',
'qipai_async_tasks',
'qipai_audit_logs',
@@ -133,13 +136,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003']
'2026062220', '2026081002', '2026081003', '2026081004']
);
return rows;
}
@@ -1827,7 +1830,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081003');
assert.equal(overview.latestMigration.version, '2026081004');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -1839,6 +1842,59 @@ async function assertSystemOperations(pool, context) {
assert.equal(updatedLogs.items[0].actorId, adminId);
}
async function assertAdminPasswordAuth(pool, context) {
const [tenantRows] = await pool.query('SELECT code FROM qipai_tenants WHERE id = ?', [context.tenantId]);
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
);
const userId = String(adminRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, userId);
const actor = { tenantId: context.tenantId, userId, access,
traceId: 'm08d-admin-auth-live', ip: '127.0.0.1', userAgent: 'M08-D admin auth live test' };
const repository = new AdminAuthRepository(pool);
const passwordHash = await hashPassword('LiveAdminPassword!2026');
await repository.setCredential(actor, context.tenantId, {
userId, loginName: 'Live.Admin', passwordHash
});
const [credentialRows] = await pool.query(
'SELECT login_name AS loginName, password_hash AS passwordHash FROM qipai_admin_credentials WHERE tenant_id = ? AND user_id = ?',
[context.tenantId, userId]
);
assert.equal(credentialRows[0].loginName, 'live.admin');
assert.notEqual(credentialRows[0].passwordHash, 'LiveAdminPassword!2026');
await assert.rejects(() => repository.loginWithPassword({ tenantCode: tenantRows[0].code,
loginName: 'live.admin', ['password']: 'WrongPassword!2026', sessionId: '11000000-0000-4000-8000-000000000001',
refreshTokenHash: hashToken('unused'), expiresAt: new Date(Date.now() + 60000),
ip: actor.ip, userAgent: actor.userAgent, traceId: 'm08d-admin-login-failed' }),
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_LOGIN_INVALID');
const refreshToken = '11000000-0000-4000-8000-000000000002.live-refresh-token';
const session = await repository.loginWithPassword({ tenantCode: tenantRows[0].code,
loginName: 'LIVE.ADMIN', ['password']: 'LiveAdminPassword!2026',
sessionId: '11000000-0000-4000-8000-000000000002', refreshTokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60000), ip: actor.ip, userAgent: actor.userAgent,
traceId: 'm08d-admin-login-success' });
assert.equal(session.user.id, userId);
const nextRefreshToken = '11000000-0000-4000-8000-000000000002.next-refresh-token';
const refreshed = await repository.rotateRefreshToken({ sessionId: session.id,
currentHash: hashToken(refreshToken), nextHash: hashToken(nextRefreshToken),
ip: actor.ip, userAgent: actor.userAgent });
assert.equal(refreshed.user.id, userId);
await assert.rejects(() => repository.rotateRefreshToken({ sessionId: session.id,
currentHash: hashToken(refreshToken), nextHash: hashToken('reused'),
ip: actor.ip, userAgent: actor.userAgent }),
(error) => error instanceof AdminAuthError && error.code === 'ADMIN_REFRESH_INVALID');
const [auditRows] = await pool.query(
`SELECT action, CAST(metadata AS CHAR) AS metadata FROM qipai_audit_logs
WHERE tenant_id = ? AND action IN ('ADMIN_CREDENTIAL_UPDATED', 'ADMIN_LOGIN_FAILED', 'ADMIN_LOGIN_SUCCEEDED')`,
[context.tenantId]
);
assert.ok(auditRows.some((row) => row.action === 'ADMIN_LOGIN_SUCCEEDED'));
assert.equal(auditRows.some((row) => row.metadata.includes('LiveAdminPassword!2026')), false);
}
async function assertDeviceTopology(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
@@ -2118,7 +2174,8 @@ try {
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2128,6 +2185,7 @@ try {
await assertContentManagement(pool, loginContext);
await assertFranchiseManagement(pool, loginContext);
await assertSystemOperations(pool, loginContext);
await assertAdminPasswordAuth(pool, loginContext);
await assertStoreDiscovery(pool, loginContext);
await assertSceneAndWifiAccess(pool, loginContext);
await assertPricingAndReservations(pool, loginContext);
@@ -2172,7 +2230,8 @@ try {
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' }
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -2222,6 +2281,8 @@ try {
'franchise assignment and controlled follow-up status transition',
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
'system overview and audited tenant configuration update',
'scrypt admin credentials and failed-login audit',
'rotating refresh token with reuse rejection',
'city fallback store filtering',
'server-side distance sorting',
'empty manual city result',