feat(M08-D): 补后台登录与可撤销会话
This commit is contained in:
@@ -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/);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user