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
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+5
View File
@@ -61,6 +61,7 @@ import {
registerSystemOperationsRoutes,
type SystemOperationsRouteOptions
} from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -88,6 +89,7 @@ export interface BuildAppOptions {
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
}
declare module 'fastify' {
@@ -201,6 +203,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.systemOperations) {
await registerSystemOperationsRoutes(app, options.systemOperations);
}
if (options.adminAuth) {
await registerAdminAuthRoutes(app, options.adminAuth);
}
return app;
}
+216
View File
@@ -0,0 +1,216 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { ManagementActor } from './user-management-repository.js';
import { verifyPassword } from './password.js';
import type { AuthSession, AuthUser } from './auth-repository.js';
interface CredentialRow extends RowDataPacket {
credentialId: string; tenantId: string; platformAppId: string; passwordHash: string;
credentialStatus: string; failedAttempts: number; lockedUntil: Date | null;
id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
interface RefreshRow extends RowDataPacket {
sessionId: string; tenantId: string; platformAppId: string; refreshTokenHash: string;
refreshExpiresAt: Date; id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
export class AdminAuthError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class AdminAuthRepository {
constructor(private readonly pool: MySqlPool) {}
async loginWithPassword(input: {
tenantCode: string; loginName: string; password: string; sessionId: string;
refreshTokenHash: string; expiresAt: Date; ip: string; userAgent: string; traceId: string;
}): Promise<AuthSession> {
const loginName = normalizeLoginName(input.loginName);
const [rows] = await this.pool.execute<CredentialRow[]>(
`SELECT c.id AS credentialId, c.tenant_id AS tenantId,
ta.platform_app_id AS platformAppId, c.password_hash AS passwordHash,
c.status AS credentialStatus, c.failed_attempts AS failedAttempts,
c.locked_until AS lockedUntil, u.id, u.user_type AS userType, u.status,
u.role_version AS roleVersion, u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_admin_credentials c
INNER JOIN qipai_tenants t ON t.id = c.tenant_id AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
INNER JOIN qipai_users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id
AND u.user_type = 'STAFF' AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
INNER JOIN qipai_tenant_apps ta ON ta.tenant_id = c.tenant_id
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
WHERE t.code = ? AND c.login_name = ? AND c.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM qipai_user_roles ur
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
AND r.status = 'ACTIVE' AND r.code IN ('STAFF', 'CLEANER', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
WHERE ur.tenant_id = c.tenant_id AND ur.user_id = c.user_id)
ORDER BY ta.is_default DESC, ta.id ASC LIMIT 1`,
[input.tenantCode.trim(), loginName]
);
const row = rows[0];
const validPassword = await verifyPassword(input.password, row?.passwordHash);
if (!row || row.credentialStatus !== 'ACTIVE' || !validPassword) {
if (row) await this.recordFailure(row, loginName, input);
throw new AdminAuthError('ADMIN_LOGIN_INVALID');
}
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) {
throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
}
const user = mapUser(row);
await this.transaction(async (connection) => {
await connection.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = 0, locked_until = NULL,
last_login_at = UTC_TIMESTAMP(3) WHERE id = ?`, [row.credentialId]
);
await connection.execute(
`INSERT INTO qipai_auth_sessions
(id, tenant_id, platform_app_id, user_id, role_version, refresh_token_hash,
expires_at, refresh_expires_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.sessionId, row.tenantId, row.platformAppId, user.id, user.roleVersion,
input.refreshTokenHash, input.expiresAt, input.expiresAt, input.ip,
input.userAgent.slice(0, 255)]
);
await connection.execute(
'UPDATE qipai_users SET last_login_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?',
[row.tenantId, user.id]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_LOGIN_SUCCEEDED', 'USER', ?, ?, ?, ?, '{}')`,
[row.tenantId, user.id, user.id, input.traceId, input.ip, input.userAgent.slice(0, 255)]
);
});
return { id: input.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user, expiresAt: input.expiresAt };
}
async rotateRefreshToken(input: {
sessionId: string; currentHash: string; nextHash: string; ip: string; userAgent: string;
}): Promise<AuthSession> {
const [rows] = await this.pool.execute<RefreshRow[]>(
`SELECT s.id AS sessionId, s.tenant_id AS tenantId, s.platform_app_id AS platformAppId,
s.refresh_token_hash AS refreshTokenHash, s.refresh_expires_at AS refreshExpiresAt,
u.id, u.user_type AS userType, u.status, u.role_version AS roleVersion,
u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_auth_sessions s INNER JOIN qipai_users u
ON u.id = s.user_id AND u.tenant_id = s.tenant_id AND u.deleted_at IS NULL
WHERE s.id = ? AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
AND s.refresh_token_hash IS NOT NULL AND s.refresh_expires_at > UTC_TIMESTAMP(3)
AND u.status = 'ACTIVE' AND u.role_version = s.role_version LIMIT 1`, [input.sessionId]
);
const row = rows[0];
if (!row || !safeHashEqual(row.refreshTokenHash, input.currentHash)) {
throw new AdminAuthError('ADMIN_REFRESH_INVALID');
}
const [updated] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions SET refresh_token_hash = ?, last_seen_at = UTC_TIMESTAMP(3),
ip = ?, user_agent = ? WHERE id = ? AND refresh_token_hash = ? AND status = 'ACTIVE'`,
[input.nextHash, input.ip, input.userAgent.slice(0, 255), input.sessionId, input.currentHash]
);
if (updated.affectedRows !== 1) throw new AdminAuthError('ADMIN_REFRESH_REUSED');
return { id: row.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user: mapUser(row), expiresAt: row.refreshExpiresAt };
}
async setCredential(actor: ManagementActor, tenantId: string, input: {
userId: string; loginName: string; passwordHash: string;
}) {
const loginName = normalizeLoginName(input.loginName);
try {
return await this.transaction(async (connection) => {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE id = ? AND tenant_id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL FOR UPDATE`, [input.userId, tenantId]
);
if (!users[0]) throw new AdminAuthError('ADMIN_CREDENTIAL_USER_INVALID');
const [conflicts] = await connection.execute<RowDataPacket[]>(
`SELECT user_id AS userId FROM qipai_admin_credentials
WHERE tenant_id = ? AND login_name = ? AND user_id <> ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, loginName, input.userId]
);
if (conflicts[0]) throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
const [current] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_admin_credentials
WHERE tenant_id = ? AND user_id = ? FOR UPDATE`, [tenantId, input.userId]
);
if (current[0]) {
await connection.execute(
`UPDATE qipai_admin_credentials SET login_name = ?, password_hash = ?, status = 'ACTIVE',
failed_attempts = 0, locked_until = NULL, deleted_at = NULL,
password_changed_at = UTC_TIMESTAMP(3) WHERE id = ?`,
[loginName, input.passwordHash, current[0].id]
);
} else {
await connection.execute(
`INSERT INTO qipai_admin_credentials
(tenant_id, user_id, login_name, password_hash) VALUES (?, ?, ?, ?)`,
[tenantId, input.userId, loginName, input.passwordHash]
);
}
await connection.execute(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'PASSWORD_CHANGED' WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[tenantId, input.userId]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_CREDENTIAL_UPDATED', 'USER', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, input.userId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
return { userId: input.userId, configured: true };
});
} catch (error) {
if ((error as { code?: string }).code === 'ER_DUP_ENTRY') {
throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
}
throw error;
}
}
private async recordFailure(row: CredentialRow, loginName: string, input: {
traceId: string; ip: string; userAgent: string;
}) {
await this.pool.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = failed_attempts + 1,
locked_until = CASE WHEN failed_attempts + 1 >= 5
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END
WHERE id = ?`, [row.credentialId]
);
await this.pool.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'SYSTEM', NULL, 'ADMIN_LOGIN_FAILED', 'ADMIN_CREDENTIAL', ?, ?, ?, ?, ?)`,
[row.tenantId, row.credentialId, input.traceId, input.ip, input.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
}
}
export function normalizeLoginName(value: string) { return value.normalize('NFKC').trim().toLowerCase(); }
export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex'); }
function safeHashEqual(left: string, right: string) {
const a = Buffer.from(left); const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
function mapUser(row: { id: string; tenantId: string; userType: string; status: string;
roleVersion: number; nickname: string; avatarUrl: string; phone: string }): AuthUser {
return { id: String(row.id), tenantId: String(row.tenantId), userType: row.userType,
status: row.status, roleVersion: row.roleVersion, nickname: row.nickname,
avatarUrl: row.avatarUrl, phone: row.phone };
}
+46
View File
@@ -0,0 +1,46 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const algorithm = 'scrypt';
const cost = 16384;
const blockSize = 8;
const parallelization = 1;
const keyLength = 64;
const maxmem = 64 * 1024 * 1024;
const dummyHash = 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$4fXWGR0BUXE6uC3v9sPYUjV_QlLQaq9EOrlgkxMVXngIWJ2unUaOv9OnZ47Z0RFKmK0LLBeMdNNB6W1RgPBplA';
export async function hashPassword(plainTextPassword: string): Promise<string> {
const salt = randomBytes(16);
const derived = await derive(plainTextPassword, salt, cost, blockSize, parallelization);
return [algorithm, cost, blockSize, parallelization,
salt.toString('base64url'), derived.toString('base64url')].join('$');
}
export async function verifyPassword(plainTextPassword: string, encoded = dummyHash): Promise<boolean> {
const parts = encoded.split('$');
if (parts.length !== 6 || parts[0] !== algorithm) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
const n = Number(nValue); const r = Number(rValue); const p = Number(pValue);
if (n !== cost || r !== blockSize || p !== parallelization) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const salt = Buffer.from(saltValue, 'base64url');
const expected = Buffer.from(hashValue, 'base64url');
if (salt.length !== 16 || expected.length !== keyLength) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const actual = await derive(plainTextPassword, salt, n, r, p);
return timingSafeEqual(actual, expected);
}
function derive(plainTextPassword: string, salt: Buffer, N: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(plainTextPassword, salt, keyLength, { N, r, p, maxmem }, (error, derivedKey) => {
if (error) reject(error); else resolve(derivedKey);
});
});
}
+6 -2
View File
@@ -52,7 +52,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql'
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -86,9 +87,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql'
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
],
down: [
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
@@ -267,6 +270,7 @@ export async function executeMigrationPlan(
4, 1, 1, 1,
2, 1, 1,
1, 1, 1,
1, 2, 3, 1,
1, 2, 3, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
+142
View File
@@ -0,0 +1,142 @@
import { randomBytes, randomUUID } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import { AdminAuthError, hashToken, type AdminAuthRepository } from '../auth/admin-auth-repository.js';
import { signAccessToken } from '../auth/jwt.js';
import { hashPassword } from '../auth/password.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const loginName = z.string().trim().min(3).max(64).regex(/^[\p{L}\p{N}._@+-]+$/u);
const strongPassword = z.string().min(12).max(128)
.refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value));
const loginSchema = z.object({ tenantCode: z.string().trim().min(2).max(64), loginName,
['password']: z.string().min(1).max(128) }).strict();
const refreshSchema = z.object({ refreshToken: z.string().min(60).max(160) }).strict();
const credentialSchema = z.object({ tenantId: id.optional(), loginName, ['password']: strongPassword }).strict();
export interface AdminAuthRouteOptions {
repository: Pick<AdminAuthRepository, 'loginWithPassword' | 'rotateRefreshToken' | 'setCredential'>;
authRepository: Pick<AuthRepository, 'validateSession' | 'revokeSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
accessTokenTtlSeconds: number;
sessionTtlSeconds: number;
}
export async function registerAdminAuthRoutes(app: FastifyInstance, options: AdminAuthRouteOptions) {
app.post('/admin-api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = loginSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = randomUUID(); const refreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.loginWithPassword({ ...body.data, sessionId,
refreshTokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + options.sessionTtlSeconds * 1000),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '', traceId: request.traceId });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, refreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.post('/admin-api/auth/refresh', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = refreshSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = body.data.refreshToken.split('.', 1)[0];
if (!z.string().uuid().safeParse(sessionId).success) return invalid(reply, request.traceId);
const nextRefreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.rotateRefreshToken({ sessionId,
currentHash: hashToken(body.data.refreshToken), nextHash: hashToken(nextRefreshToken),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '' });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, nextRefreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.get('/admin-api/auth/me', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
return { code: 0, data: { user: safeUser(auth.session.user), access: adminAccess(access) }, traceId: request.traceId };
});
app.post('/admin-api/auth/logout', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
await options.authRepository.revokeSession(auth.sessionId, 'ADMIN_LOGOUT');
return { code: 0, data: { revoked: true }, traceId: request.traceId };
});
app.put('/admin-api/auth/credentials/:userId', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ userId: id }).safeParse(request.params);
const body = credentialSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const tenantId = resolveTenant(actor, body.data.tenantId);
if (!tenantId) return reply.status(403).send({ code: 'ADMIN_AUTH_TENANT_FORBIDDEN',
message: 'The tenant is outside the allowed scope.', traceId: request.traceId });
try {
const passwordHash = await hashPassword(body.data.password);
return { code: 0, data: await options.repository.setCredential(actor, tenantId,
{ userId: params.data.userId, loginName: body.data.loginName, passwordHash }), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: AdminAuthRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { unauthorized(reply, request.traceId); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'ADMIN_CREDENTIAL_FORBIDDEN',
message: 'Tenant management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function createRefreshToken(sessionId: string) { return `${sessionId}.${randomBytes(32).toString('base64url')}`; }
function sessionResponse(session: { id: string; tenantId: string; platformAppId: string; user: Parameters<typeof safeUser>[0] },
access: AccessProfile, refreshToken: string, options: AdminAuthRouteOptions) {
return { accessToken: signAccessToken({ sub: session.user.id, sid: session.id, tid: session.tenantId,
aid: session.platformAppId, rv: session.user.roleVersion }, options.jwtSecret, options.accessTokenTtlSeconds),
refreshToken, expiresIn: options.accessTokenTtlSeconds, user: safeUser(session.user),
access: adminAccess(access) };
}
function adminAccess(access: AccessProfile) {
const tenant = access.capabilities.includes('tenant.manage') || isPlatform(access);
const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
];
return { ...access, menus: [...new Set(menus)] };
}
function safeUser(user: { id: string; tenantId: string; userType: string; nickname: string; avatarUrl: string; roleVersion: number }) {
return { id: user.id, tenantId: user.tenantId, userType: user.userType,
nickname: user.nickname, avatarUrl: user.avatarUrl, roleVersion: user.roleVersion };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({
code: 'INVALID_ADMIN_AUTH_REQUEST', message: 'The admin authentication request is invalid.', traceId }); }
function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'The admin session is invalid or expired.', traceId }); }
function authError(reply: FastifyReply, traceId: string, error: unknown) {
if (!(error instanceof AdminAuthError)) throw error;
const status = error.code === 'ADMIN_LOGIN_LOCKED' ? 423
: error.code.includes('CONFLICT') ? 409
: error.code.includes('USER_INVALID') ? 404 : 401;
return reply.status(status).send({ code: error.code,
message: 'The admin authentication request was rejected.', traceId });
}
+9
View File
@@ -43,6 +43,7 @@ import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -232,6 +233,14 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
adminAuth: {
repository: new AdminAuthRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds
}
});
app.addHook('onClose', async () => {
+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',