Initial import of LWLT AIBOT platform
This commit is contained in:
259
control-plane/src/auth.ts
Normal file
259
control-plane/src/auth.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import argon2 from 'argon2';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { getPool, withTransaction } from './db.js';
|
||||
import { hashToken, randomToken, sameTokenHash } from './crypto.js';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
username: string;
|
||||
role: 'admin';
|
||||
}
|
||||
|
||||
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 mapUser(row: Record<string, unknown>): AuthUser {
|
||||
return {
|
||||
id: String(row.id),
|
||||
organizationId: String(row.organization_id),
|
||||
username: String(row.username),
|
||||
role: 'admin'
|
||||
};
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
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 = normalizeUsername(username);
|
||||
if (!normalized || normalized.length > 160) throw new Error('username must be 1-160 characters.');
|
||||
if (!password || password.length < 12) throw new Error('password must be at least 12 characters.');
|
||||
const organization = await this.ensureOrganization();
|
||||
const passwordHash = await argon2.hash(password, { 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, is_active = true, failed_login_count = 0,
|
||||
locked_until = NULL, updated_at = now()
|
||||
WHERE id = $2
|
||||
RETURNING id, organization_id, username`,
|
||||
[passwordHash, existing.rows[0].id]
|
||||
)
|
||||
: await client.query(
|
||||
`INSERT INTO users (organization_id, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, organization_id, username`,
|
||||
[organization.id, normalized, passwordHash]
|
||||
);
|
||||
const user = mapUser(result.rows[0]);
|
||||
if (existing.rowCount && force) {
|
||||
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, 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 = normalizeUsername(username);
|
||||
if (!password || password.length < 12) throw new Error('password must be at least 12 characters.');
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
const result = await getPool(this.config).query(
|
||||
`UPDATE users
|
||||
SET password_hash = $1, 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]
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user