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
@@ -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',