807 lines
31 KiB
TypeScript
807 lines
31 KiB
TypeScript
import argon2 from 'argon2';
|
|
import type { AppConfig } from './config.js';
|
|
import { getPool, withTransaction } from './db.js';
|
|
import { hashToken, randomToken, sameTokenHash } from './crypto.js';
|
|
import {
|
|
BUSINESS_ROUTES,
|
|
businessRouteById,
|
|
type BusinessRouteId
|
|
} from './business-routes.js';
|
|
import {
|
|
diagnosticMetadataKeys,
|
|
noopDiagnosticLogger,
|
|
type DiagnosticLogger
|
|
} from './diagnostics.js';
|
|
|
|
export type AuthRole = 'admin' | 'team_lead' | 'user';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
organizationId: string;
|
|
username: string;
|
|
role: AuthRole;
|
|
erpAccount: string | null;
|
|
}
|
|
|
|
export interface PublicAccount {
|
|
id: string;
|
|
username: string;
|
|
role: AuthRole;
|
|
erp_account: string | null;
|
|
is_active: boolean;
|
|
authorized_business_route_ids: BusinessRouteId[];
|
|
business_authorization_revision: number;
|
|
last_login_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AuthSession {
|
|
id: string;
|
|
token: string;
|
|
csrfToken: string;
|
|
user: AuthUser;
|
|
}
|
|
|
|
export interface ActiveSession {
|
|
id: string;
|
|
user: AuthUser;
|
|
csrfTokenHash: Buffer;
|
|
}
|
|
|
|
export class AuthError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
public readonly statusCode = 401
|
|
) {
|
|
super(message);
|
|
this.name = 'AuthError';
|
|
}
|
|
}
|
|
|
|
function normalizeUsername(value: string): string {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
function validateUsername(value: string): string {
|
|
const normalized = normalizeUsername(value);
|
|
if (!normalized || normalized.length > 160) {
|
|
throw new AuthError('username_invalid', '账号必须为 1—160 个字符。', 400);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function validatePassword(value: string): string {
|
|
const password = String(value || '');
|
|
if (!password) {
|
|
throw new AuthError('password_invalid', '密码不能为空。', 400);
|
|
}
|
|
return password;
|
|
}
|
|
|
|
function normalizeErpAccount(value: unknown): string | null {
|
|
const normalized = String(value ?? '').trim();
|
|
if (!normalized) return null;
|
|
if (normalized.length > 200) {
|
|
throw new AuthError('erp_account_invalid', 'ERP 账号必须为 1—200 个字符。', 400);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function validateAccountRouting(role: AuthRole, value: unknown): string | null {
|
|
const erpAccount = normalizeErpAccount(value);
|
|
if (role === 'admin') {
|
|
if (erpAccount) throw new AuthError('admin_erp_account_forbidden', '管理员账号不能绑定员工 ERP 账号。', 409);
|
|
return null;
|
|
}
|
|
if (!erpAccount) {
|
|
throw new AuthError('erp_account_required', '普通用户或组长必须绑定 ERP 账号。', 400);
|
|
}
|
|
return erpAccount;
|
|
}
|
|
|
|
function normalizeRole(value: unknown): AuthRole {
|
|
if (value === 'admin' || value === 'team_lead') return value;
|
|
return 'user';
|
|
}
|
|
|
|
function isoOrNull(value: unknown): string | null {
|
|
if (!value) return null;
|
|
const date = new Date(String(value));
|
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
}
|
|
|
|
const ALL_BUSINESS_ROUTE_IDS = BUSINESS_ROUTES.map((route) => route.routeId);
|
|
const BUSINESS_ROUTE_DISPLAY_ORDER = new Map(
|
|
ALL_BUSINESS_ROUTE_IDS.map((routeId, index) => [routeId, index])
|
|
);
|
|
|
|
function normalizeBusinessRouteIds(values: readonly unknown[] | undefined): BusinessRouteId[] {
|
|
const normalized = [...new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean))];
|
|
const invalid = normalized.find((routeId) => !businessRouteById(routeId));
|
|
if (invalid) throw new AuthError('business_route_invalid', `业务类型 ${invalid} 不存在。`, 400);
|
|
return (normalized as BusinessRouteId[]).sort((left, right) => (
|
|
(BUSINESS_ROUTE_DISPLAY_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER)
|
|
- (BUSINESS_ROUTE_DISPLAY_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
|
|
));
|
|
}
|
|
|
|
function mapUser(row: Record<string, unknown>): AuthUser {
|
|
return {
|
|
id: String(row.id),
|
|
organizationId: String(row.organization_id),
|
|
username: String(row.username),
|
|
role: normalizeRole(row.role),
|
|
erpAccount: normalizeErpAccount(row.erp_account)
|
|
};
|
|
}
|
|
|
|
function mapAccount(row: Record<string, unknown>): PublicAccount {
|
|
const role = normalizeRole(row.role);
|
|
const storedRouteIds = normalizeBusinessRouteIds(
|
|
Array.isArray(row.authorized_business_route_ids) ? row.authorized_business_route_ids : []
|
|
);
|
|
return {
|
|
id: String(row.id),
|
|
username: String(row.username),
|
|
role,
|
|
erp_account: normalizeErpAccount(row.erp_account),
|
|
is_active: row.is_active === true || String(row.is_active) === 'true',
|
|
authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds,
|
|
business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)),
|
|
last_login_at: isoOrNull(row.last_login_at),
|
|
created_at: isoOrNull(row.created_at) || new Date(0).toISOString(),
|
|
updated_at: isoOrNull(row.updated_at) || new Date(0).toISOString()
|
|
};
|
|
}
|
|
|
|
async function loadPublicAccount(
|
|
client: import('pg').PoolClient,
|
|
organizationId: string,
|
|
userId: string
|
|
): Promise<PublicAccount | null> {
|
|
const result = await client.query(
|
|
`SELECT u.id, u.username, u.role, u.erp_account, u.is_active,
|
|
u.business_authorization_revision,
|
|
u.last_login_at, u.created_at, u.updated_at,
|
|
COALESCE(ARRAY(
|
|
SELECT route_grant.route_id
|
|
FROM user_business_route_authorizations route_grant
|
|
WHERE route_grant.organization_id = u.organization_id
|
|
AND route_grant.user_id = u.id
|
|
), ARRAY[]::text[]) AS authorized_business_route_ids
|
|
FROM users u
|
|
WHERE u.organization_id = $1 AND u.id = $2`,
|
|
[organizationId, userId]
|
|
);
|
|
return result.rowCount ? mapAccount(result.rows[0] as Record<string, unknown>) : null;
|
|
}
|
|
|
|
export class AuthService {
|
|
private readonly dummyHashPromise = argon2.hash('ltjt-dummy-password', {
|
|
type: argon2.argon2id,
|
|
memoryCost: 19_456,
|
|
timeCost: 2,
|
|
parallelism: 1
|
|
});
|
|
|
|
constructor(
|
|
private readonly config: AppConfig,
|
|
private readonly logger: DiagnosticLogger = noopDiagnosticLogger
|
|
) {}
|
|
|
|
private log(metadata: Record<string, unknown>, message: string): void {
|
|
try {
|
|
this.logger.info(metadata, message);
|
|
} catch {
|
|
// Authentication state and audit persistence never depend on logging.
|
|
}
|
|
}
|
|
|
|
async ensureOrganization(): Promise<{ id: string; slug: string; name: string }> {
|
|
const result = await getPool(this.config).query(
|
|
`INSERT INTO organizations (slug, name)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
|
RETURNING id, slug, name`,
|
|
[this.config.ORG_SLUG, this.config.ORG_NAME]
|
|
);
|
|
return result.rows[0] as { id: string; slug: string; name: string };
|
|
}
|
|
|
|
async getOrganization(): Promise<{ id: string; slug: string; name: string } | null> {
|
|
const result = await getPool(this.config).query(
|
|
'SELECT id, slug, name FROM organizations WHERE slug = $1',
|
|
[this.config.ORG_SLUG]
|
|
);
|
|
return result.rowCount ? result.rows[0] as { id: string; slug: string; name: string } : null;
|
|
}
|
|
|
|
async bootstrapAdmin(username: string, password: string, { force = false } = {}): Promise<AuthUser> {
|
|
const normalized = validateUsername(username);
|
|
const validatedPassword = validatePassword(password);
|
|
const organization = await this.ensureOrganization();
|
|
const passwordHash = await argon2.hash(validatedPassword, { type: argon2.argon2id });
|
|
return withTransaction(this.config, async (client) => {
|
|
const existing = await client.query(
|
|
'SELECT id FROM users WHERE organization_id = $1 AND username = $2 FOR UPDATE',
|
|
[organization.id, normalized]
|
|
);
|
|
if (existing.rowCount && !force) {
|
|
throw new Error(`administrator ${normalized} already exists; use --force to reset it.`);
|
|
}
|
|
const result = existing.rowCount
|
|
? await client.query(
|
|
`UPDATE users
|
|
SET password_hash = $1, role = 'admin', erp_account = NULL, is_active = true,
|
|
must_change_password = false, password_changed_at = now(),
|
|
failed_login_count = 0, locked_until = NULL, updated_at = now()
|
|
WHERE id = $2
|
|
RETURNING id, organization_id, username, role, erp_account`,
|
|
[passwordHash, existing.rows[0].id]
|
|
)
|
|
: await client.query(
|
|
`INSERT INTO users (organization_id, username, password_hash, role)
|
|
VALUES ($1, $2, $3, 'admin')
|
|
RETURNING id, organization_id, username, role, erp_account`,
|
|
[organization.id, normalized, passwordHash]
|
|
);
|
|
const user = mapUser(result.rows[0]);
|
|
if (existing.rowCount && force) {
|
|
await client.query(
|
|
`UPDATE user_channels
|
|
SET owner_user_id = NULL, enabled = false, status = 'disabled',
|
|
last_error = '绑定账号已重置为管理员,渠道已解除绑定。', updated_at = now()
|
|
WHERE organization_id = $1 AND owner_user_id = $2`,
|
|
[organization.id, user.id]
|
|
);
|
|
await client.query(
|
|
`UPDATE browser_connections
|
|
SET status = 'superseded', erp_account_verified = false
|
|
WHERE organization_id = $1 AND user_id = $2 AND status = 'connected'`,
|
|
[organization.id, user.id]
|
|
);
|
|
await client.query(
|
|
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
|
[user.id]
|
|
);
|
|
}
|
|
return user;
|
|
});
|
|
}
|
|
|
|
async authenticate(username: string, password: string, ipAddress: string, userAgent: string): Promise<AuthSession> {
|
|
const normalized = normalizeUsername(username);
|
|
const pool = getPool(this.config);
|
|
const lookup = await pool.query(
|
|
`SELECT id, organization_id, username, password_hash, role, erp_account, is_active,
|
|
failed_login_count, locked_until
|
|
FROM users
|
|
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1)
|
|
AND username = $2`,
|
|
[this.config.ORG_SLUG, normalized]
|
|
);
|
|
const row = lookup.rows[0] as Record<string, unknown> | undefined;
|
|
const lockedUntil = row?.locked_until ? new Date(String(row.locked_until)) : null;
|
|
if (lockedUntil && lockedUntil.getTime() > Date.now()) {
|
|
throw new AuthError('account_locked', '账号暂时锁定,请稍后再试。', 429);
|
|
}
|
|
|
|
const hash = String(row?.password_hash || await this.dummyHashPromise);
|
|
const valid = await argon2.verify(hash, password || '');
|
|
if (!row || !valid || row.is_active === false) {
|
|
if (row) {
|
|
await pool.query(
|
|
`UPDATE users
|
|
SET failed_login_count = failed_login_count + 1,
|
|
locked_until = CASE
|
|
WHEN failed_login_count + 1 >= 5 THEN now() + interval '15 minutes'
|
|
ELSE locked_until
|
|
END,
|
|
updated_at = now()
|
|
WHERE id = $1`,
|
|
[row.id]
|
|
);
|
|
}
|
|
throw new AuthError('invalid_credentials', '账号或密码错误。');
|
|
}
|
|
|
|
const user = mapUser(row);
|
|
await pool.query(
|
|
`UPDATE users
|
|
SET failed_login_count = 0, locked_until = NULL, last_login_at = now(), updated_at = now()
|
|
WHERE id = $1`,
|
|
[user.id]
|
|
);
|
|
return this.createSession(user, ipAddress, userAgent);
|
|
}
|
|
|
|
async resetPassword(username: string, password: string): Promise<void> {
|
|
const normalized = validateUsername(username);
|
|
const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id });
|
|
const result = await getPool(this.config).query(
|
|
`UPDATE users
|
|
SET password_hash = $1, must_change_password = false, password_changed_at = now(),
|
|
failed_login_count = 0, locked_until = NULL, updated_at = now()
|
|
WHERE organization_id = (SELECT id FROM organizations WHERE slug = $2)
|
|
AND username = $3
|
|
RETURNING id`,
|
|
[passwordHash, this.config.ORG_SLUG, normalized]
|
|
);
|
|
if (!result.rowCount) throw new Error(`administrator ${normalized} was not found.`);
|
|
await getPool(this.config).query(
|
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
|
[result.rows[0].id]
|
|
);
|
|
}
|
|
|
|
private requireAdmin(actor: AuthUser): void {
|
|
if (actor.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403);
|
|
}
|
|
|
|
private async accountAudit(
|
|
client: import('pg').PoolClient,
|
|
actor: AuthUser,
|
|
eventType: string,
|
|
targetUserId: string,
|
|
requestId: string,
|
|
metadata: Record<string, unknown> = {}
|
|
): Promise<void> {
|
|
await client.query(
|
|
`INSERT INTO audit_events
|
|
(organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata)
|
|
VALUES ($1, $2, $3, 'user', $4, $5, $6)`,
|
|
[actor.organizationId, actor.id, eventType, targetUserId, requestId, metadata]
|
|
);
|
|
this.log({
|
|
diagnostic_event: 'audit.event.staged',
|
|
diagnostic_stage: 'account_audit',
|
|
request_id: requestId,
|
|
domain_event: eventType,
|
|
entity_type: 'user',
|
|
actor_present: true,
|
|
metadata_keys: diagnosticMetadataKeys(metadata)
|
|
}, 'account audit event persisted');
|
|
}
|
|
|
|
async listAccounts(actor: AuthUser): Promise<PublicAccount[]> {
|
|
this.requireAdmin(actor);
|
|
const result = await getPool(this.config).query(
|
|
`SELECT u.id, u.username, u.role, u.erp_account, u.is_active,
|
|
u.business_authorization_revision,
|
|
u.last_login_at, u.created_at, u.updated_at,
|
|
COALESCE(ARRAY(
|
|
SELECT route_grant.route_id
|
|
FROM user_business_route_authorizations route_grant
|
|
WHERE route_grant.organization_id = u.organization_id
|
|
AND route_grant.user_id = u.id
|
|
), ARRAY[]::text[]) AS authorized_business_route_ids
|
|
FROM users u
|
|
WHERE u.organization_id = $1
|
|
ORDER BY u.username ASC`,
|
|
[actor.organizationId]
|
|
);
|
|
return (result.rows as Record<string, unknown>[]).map(mapAccount);
|
|
}
|
|
|
|
async createAccount(
|
|
actor: AuthUser,
|
|
input: {
|
|
username: string;
|
|
password: string;
|
|
role: AuthRole;
|
|
erpAccount?: string;
|
|
businessRouteIds?: readonly string[];
|
|
},
|
|
requestId: string
|
|
): Promise<PublicAccount> {
|
|
this.requireAdmin(actor);
|
|
const username = validateUsername(input.username);
|
|
const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id });
|
|
const role = normalizeRole(input.role);
|
|
const erpAccount = validateAccountRouting(role, input.erpAccount);
|
|
const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds);
|
|
try {
|
|
return await withTransaction(this.config, async (client) => {
|
|
await client.query(
|
|
`SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))`,
|
|
[actor.organizationId, username]
|
|
);
|
|
const existing = await client.query(
|
|
'SELECT id FROM users WHERE organization_id = $1 AND username = $2',
|
|
[actor.organizationId, username]
|
|
);
|
|
if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409);
|
|
const created = await client.query(
|
|
`INSERT INTO users
|
|
(organization_id, username, password_hash, role, erp_account, password_changed_at)
|
|
VALUES ($1, $2, $3, $4, $5, now())
|
|
RETURNING id`,
|
|
[actor.organizationId, username, passwordHash, role, erpAccount]
|
|
);
|
|
const accountId = String(created.rows[0].id);
|
|
if (businessRouteIds.length) {
|
|
await client.query(
|
|
`INSERT INTO user_business_route_authorizations
|
|
(organization_id, user_id, route_id, granted_by)
|
|
SELECT $1, $2, route_id, $3
|
|
FROM unnest($4::text[]) AS route_id`,
|
|
[actor.organizationId, accountId, actor.id, businessRouteIds]
|
|
);
|
|
}
|
|
const account = await loadPublicAccount(client, actor.organizationId, accountId);
|
|
if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500);
|
|
await this.accountAudit(client, actor, 'account.created', account.id, requestId, {
|
|
role,
|
|
erp_account_configured: Boolean(erpAccount),
|
|
authorized_business_route_ids: account.authorized_business_route_ids
|
|
});
|
|
return account;
|
|
});
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') {
|
|
const constraint = String((error as { constraint?: unknown }).constraint || '');
|
|
if (constraint.includes('erp_account')) {
|
|
throw new AuthError('erp_account_conflict', '该 ERP 账号已经绑定另一个平台账号。', 409);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateAccount(
|
|
actor: AuthUser,
|
|
targetUserId: string,
|
|
input: { role?: AuthRole; isActive?: boolean; erpAccount?: string | null },
|
|
requestId: string
|
|
): Promise<PublicAccount> {
|
|
this.requireAdmin(actor);
|
|
if (input.role === undefined && input.isActive === undefined && input.erpAccount === undefined) {
|
|
throw new AuthError('account_update_empty', '没有需要更新的账号字段。', 400);
|
|
}
|
|
try {
|
|
return await withTransaction(this.config, async (client) => {
|
|
const target = await client.query(
|
|
`SELECT id, username, role, erp_account, is_active,
|
|
last_login_at, created_at, updated_at
|
|
FROM users
|
|
WHERE organization_id = $1 AND id = $2
|
|
FOR UPDATE`,
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
|
const before = mapAccount(target.rows[0] as Record<string, unknown>);
|
|
const role = input.role === undefined ? before.role : normalizeRole(input.role);
|
|
const isActive = input.isActive === undefined ? before.is_active : input.isActive;
|
|
const erpAccount = validateAccountRouting(
|
|
role,
|
|
role === 'admin'
|
|
? null
|
|
: input.erpAccount === undefined ? before.erp_account : input.erpAccount
|
|
);
|
|
const removesActiveAdmin = before.role === 'admin' && before.is_active && (role !== 'admin' || !isActive);
|
|
if (actor.id === before.id && (role !== 'admin' || !isActive)) {
|
|
throw new AuthError('self_lockout_forbidden', '不能停用或降级当前登录的管理员账号。', 409);
|
|
}
|
|
if (removesActiveAdmin) {
|
|
const activeAdmins = await client.query(
|
|
`SELECT id FROM users
|
|
WHERE organization_id = $1 AND role = 'admin' AND is_active = true
|
|
FOR UPDATE`,
|
|
[actor.organizationId]
|
|
);
|
|
if (activeAdmins.rows.filter((row: Record<string, unknown>) => String(row.id) !== before.id).length === 0) {
|
|
throw new AuthError('last_admin_protected', '必须至少保留一个有效管理员账号。', 409);
|
|
}
|
|
}
|
|
const updated = await client.query(
|
|
`UPDATE users
|
|
SET role = $1, is_active = $2, erp_account = $3, updated_at = now()
|
|
WHERE organization_id = $4 AND id = $5
|
|
RETURNING id`,
|
|
[role, isActive, erpAccount, actor.organizationId, before.id]
|
|
);
|
|
const routingIdentityChanged = before.erp_account !== erpAccount;
|
|
if (before.role !== role || before.is_active !== isActive || routingIdentityChanged) {
|
|
await client.query(
|
|
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
|
[before.id]
|
|
);
|
|
}
|
|
if (!isActive || role === 'admin') {
|
|
await client.query(
|
|
`UPDATE user_channels
|
|
SET owner_user_id = NULL, enabled = false, status = 'disabled',
|
|
last_error = $1, updated_at = now()
|
|
WHERE organization_id = $2 AND owner_user_id = $3`,
|
|
[role === 'admin' ? '绑定账号已变更为管理员,渠道已解除绑定。' : '绑定账号已停用,渠道已解除绑定。', actor.organizationId, before.id]
|
|
);
|
|
}
|
|
if (!isActive || routingIdentityChanged || before.role !== role) {
|
|
await client.query(
|
|
`UPDATE browser_connections
|
|
SET status = 'superseded', erp_account_verified = false
|
|
WHERE organization_id = $1 AND user_id = $2 AND status = 'connected'`,
|
|
[actor.organizationId, before.id]
|
|
);
|
|
}
|
|
await this.accountAudit(client, actor, 'account.updated', before.id, requestId, {
|
|
previous_role: before.role,
|
|
role,
|
|
previous_active: before.is_active,
|
|
active: isActive,
|
|
erp_account_changed: routingIdentityChanged,
|
|
sessions_revoked: before.role !== role || before.is_active !== isActive || routingIdentityChanged
|
|
});
|
|
const account = await loadPublicAccount(client, actor.organizationId, String(updated.rows[0].id));
|
|
if (!account) throw new AuthError('account_not_found', '账号更新后未能读取。', 500);
|
|
return account;
|
|
});
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') {
|
|
const constraint = String((error as { constraint?: unknown }).constraint || '');
|
|
if (constraint.includes('erp_account')) {
|
|
throw new AuthError('erp_account_conflict', '该 ERP 账号已经绑定另一个平台账号。', 409);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async setBusinessRouteAuthorizations(
|
|
actor: AuthUser,
|
|
targetUserId: string,
|
|
routeIds: readonly string[],
|
|
expectedRevision: number,
|
|
requestId: string
|
|
): Promise<PublicAccount> {
|
|
this.requireAdmin(actor);
|
|
const authorizedRouteIds = normalizeBusinessRouteIds(routeIds);
|
|
return withTransaction(this.config, async (client) => {
|
|
const target = await client.query(
|
|
`SELECT id, role, business_authorization_revision
|
|
FROM users
|
|
WHERE organization_id = $1 AND id = $2
|
|
FOR UPDATE`,
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
|
const row = target.rows[0] as Record<string, unknown>;
|
|
if (normalizeRole(row.role) === 'admin') {
|
|
throw new AuthError('admin_business_authorization_fixed', '管理员固定拥有全部业务权限,无需单独授权。', 409);
|
|
}
|
|
const currentRevision = Math.max(0, Number(row.business_authorization_revision || 0));
|
|
if (currentRevision !== expectedRevision) {
|
|
throw new AuthError('business_authorization_revision_conflict', '该账号的业务权限已被其他管理员修改,请刷新后重试。', 409);
|
|
}
|
|
const previous = await client.query(
|
|
`SELECT route_id
|
|
FROM user_business_route_authorizations
|
|
WHERE organization_id = $1 AND user_id = $2`,
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
const previousRouteIds = normalizeBusinessRouteIds(
|
|
(previous.rows as Record<string, unknown>[]).map((item) => item.route_id)
|
|
);
|
|
await client.query(
|
|
`DELETE FROM user_business_route_authorizations
|
|
WHERE organization_id = $1 AND user_id = $2`,
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
if (authorizedRouteIds.length) {
|
|
await client.query(
|
|
`INSERT INTO user_business_route_authorizations
|
|
(organization_id, user_id, route_id, granted_by)
|
|
SELECT $1, $2, route_id, $3
|
|
FROM unnest($4::text[]) AS route_id`,
|
|
[actor.organizationId, targetUserId, actor.id, authorizedRouteIds]
|
|
);
|
|
}
|
|
await client.query(
|
|
`UPDATE users
|
|
SET business_authorization_revision = business_authorization_revision + 1,
|
|
updated_at = now()
|
|
WHERE organization_id = $1 AND id = $2`,
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
await this.accountAudit(
|
|
client,
|
|
actor,
|
|
'account.business_authorizations_updated',
|
|
targetUserId,
|
|
requestId,
|
|
{
|
|
previous_business_route_ids: previousRouteIds,
|
|
business_route_ids: authorizedRouteIds,
|
|
previous_revision: currentRevision,
|
|
revision: currentRevision + 1
|
|
}
|
|
);
|
|
const account = await loadPublicAccount(client, actor.organizationId, targetUserId);
|
|
if (!account) throw new AuthError('account_not_found', '账号权限更新后未能读取。', 500);
|
|
return account;
|
|
});
|
|
}
|
|
|
|
async resetAccountPassword(
|
|
actor: AuthUser,
|
|
targetUserId: string,
|
|
password: string,
|
|
requestId: string
|
|
): Promise<void> {
|
|
this.requireAdmin(actor);
|
|
const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id });
|
|
await withTransaction(this.config, async (client) => {
|
|
const target = await client.query(
|
|
'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE',
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
|
await client.query(
|
|
`UPDATE users
|
|
SET password_hash = $1, must_change_password = false,
|
|
password_changed_at = now(), failed_login_count = 0,
|
|
locked_until = NULL, updated_at = now()
|
|
WHERE id = $2`,
|
|
[passwordHash, targetUserId]
|
|
);
|
|
const revoked = await client.query(
|
|
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
|
[targetUserId]
|
|
);
|
|
await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, {
|
|
sessions_revoked: revoked.rowCount || 0
|
|
});
|
|
});
|
|
}
|
|
|
|
async revokeAccountSessions(actor: AuthUser, targetUserId: string, requestId: string): Promise<number> {
|
|
this.requireAdmin(actor);
|
|
return withTransaction(this.config, async (client) => {
|
|
const target = await client.query(
|
|
'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE',
|
|
[actor.organizationId, targetUserId]
|
|
);
|
|
if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404);
|
|
const revoked = await client.query(
|
|
'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL',
|
|
[targetUserId]
|
|
);
|
|
const count = revoked.rowCount || 0;
|
|
await this.accountAudit(client, actor, 'account.sessions_revoked', targetUserId, requestId, {
|
|
sessions_revoked: count
|
|
});
|
|
return count;
|
|
});
|
|
}
|
|
|
|
async changeOwnPassword(
|
|
session: ActiveSession,
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
requestId: string
|
|
): Promise<void> {
|
|
const validatedNewPassword = validatePassword(newPassword);
|
|
const newHash = await argon2.hash(validatedNewPassword, { type: argon2.argon2id });
|
|
await withTransaction(this.config, async (client) => {
|
|
const target = await client.query(
|
|
`SELECT password_hash, is_active
|
|
FROM users
|
|
WHERE organization_id = $1 AND id = $2
|
|
FOR UPDATE`,
|
|
[session.user.organizationId, session.user.id]
|
|
);
|
|
const row = target.rows[0] as Record<string, unknown> | undefined;
|
|
if (!row || row.is_active === false || !(await argon2.verify(String(row.password_hash), currentPassword || ''))) {
|
|
throw new AuthError('current_password_invalid', '当前密码不正确。', 403);
|
|
}
|
|
if (await argon2.verify(String(row.password_hash), validatedNewPassword)) {
|
|
throw new AuthError('password_unchanged', '新密码不能与当前密码相同。', 409);
|
|
}
|
|
await client.query(
|
|
`UPDATE users
|
|
SET password_hash = $1, must_change_password = false,
|
|
password_changed_at = now(), failed_login_count = 0,
|
|
locked_until = NULL, updated_at = now()
|
|
WHERE id = $2`,
|
|
[newHash, session.user.id]
|
|
);
|
|
const revoked = await client.query(
|
|
`UPDATE sessions SET revoked_at = now()
|
|
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL`,
|
|
[session.user.id, session.id]
|
|
);
|
|
await this.accountAudit(client, session.user, 'account.password_changed', session.user.id, requestId, {
|
|
other_sessions_revoked: revoked.rowCount || 0
|
|
});
|
|
});
|
|
}
|
|
|
|
async createSession(user: AuthUser, ipAddress: string, userAgent: string): Promise<AuthSession> {
|
|
const token = randomToken(32);
|
|
const csrfToken = randomToken(24);
|
|
const result = await getPool(this.config).query(
|
|
`INSERT INTO sessions
|
|
(user_id, token_hash, csrf_token_hash, expires_at, idle_expires_at, ip_address, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id`,
|
|
[user.id, hashToken(token), hashToken(csrfToken), null, null, ipAddress || null, userAgent || null]
|
|
);
|
|
return { id: String(result.rows[0].id), token, csrfToken, user };
|
|
}
|
|
|
|
async getActiveSession(token: string | undefined): Promise<ActiveSession | null> {
|
|
if (!token) return null;
|
|
const result = await getPool(this.config).query(
|
|
`SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role, u.erp_account
|
|
FROM sessions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.token_hash = $1
|
|
AND s.revoked_at IS NULL
|
|
AND u.is_active = true`,
|
|
[hashToken(token)]
|
|
);
|
|
if (!result.rowCount) return null;
|
|
const row = result.rows[0] as Record<string, unknown>;
|
|
await getPool(this.config).query(
|
|
'UPDATE sessions SET last_seen_at = now() WHERE id = $1',
|
|
[row.session_id]
|
|
);
|
|
return {
|
|
id: String(row.session_id),
|
|
csrfTokenHash: Buffer.isBuffer(row.csrf_token_hash)
|
|
? row.csrf_token_hash
|
|
: Buffer.from(String(row.csrf_token_hash || ''), 'base64'),
|
|
user: mapUser({
|
|
id: row.id,
|
|
organization_id: row.organization_id,
|
|
username: row.username,
|
|
role: row.role,
|
|
erp_account: row.erp_account
|
|
})
|
|
};
|
|
}
|
|
|
|
async rotateCsrf(sessionId: string): Promise<string> {
|
|
const token = randomToken(24);
|
|
await getPool(this.config).query(
|
|
'UPDATE sessions SET csrf_token_hash = $1 WHERE id = $2 AND revoked_at IS NULL',
|
|
[hashToken(token), sessionId]
|
|
);
|
|
return token;
|
|
}
|
|
|
|
async verifyCsrf(session: ActiveSession, candidate: string | undefined): Promise<boolean> {
|
|
if (!candidate) return false;
|
|
return sameTokenHash(session.csrfTokenHash, hashToken(candidate));
|
|
}
|
|
|
|
async revokeSession(token: string | undefined): Promise<void> {
|
|
if (!token) return;
|
|
await getPool(this.config).query('UPDATE sessions SET revoked_at = now() WHERE token_hash = $1', [hashToken(token)]);
|
|
}
|
|
|
|
async revokeAllSessions(userId: string): Promise<void> {
|
|
await getPool(this.config).query('UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [userId]);
|
|
}
|
|
|
|
async recordAudit(organizationId: string, userId: string | null, eventType: string, requestId: string, metadata: Record<string, unknown> = {}): Promise<void> {
|
|
await getPool(this.config).query(
|
|
`INSERT INTO audit_events
|
|
(organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata)
|
|
VALUES ($1, $2, 'auth.' || $3, 'session', $4, $5, $6)`,
|
|
[organizationId, userId, eventType, userId || '', requestId, metadata]
|
|
);
|
|
this.log({
|
|
diagnostic_event: 'audit.event.staged',
|
|
diagnostic_stage: 'auth_audit',
|
|
request_id: requestId,
|
|
domain_event: `auth.${eventType}`,
|
|
entity_type: 'session',
|
|
actor_present: Boolean(userId),
|
|
metadata_keys: diagnosticMetadataKeys(metadata)
|
|
}, 'authentication audit event persisted');
|
|
}
|
|
}
|