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
+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 () => {