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 { const loginName = normalizeLoginName(input.loginName); const [rows] = await this.pool.execute( `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 { const [rows] = await this.pool.execute( `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( `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( `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( `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( `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(work: (connection: PoolConnection) => Promise) { 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 }; }