217 lines
11 KiB
TypeScript
217 lines
11 KiB
TypeScript
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 };
|
|
}
|