trading-platform-mcp-auth-v2/src/services/auth.service.ts
rckrdmrd a9de3e4331 Migración desde trading-platform/apps/mcp-auth - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:33:07 -06:00

789 lines
23 KiB
TypeScript

/**
* Auth Service - Core authentication business logic
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { Pool, PoolClient } from 'pg';
import {
User,
Session,
Token,
Tenant,
RegisterInput,
LoginInput,
LoginResponse,
RefreshInput,
RefreshResponse,
PasswordResetRequestInput,
PasswordResetInput,
ChangePasswordInput,
LogoutInput,
AuthTokens,
JWTPayload,
DeviceInfo,
UserStatus,
} from '../types/auth.types';
import {
InvalidCredentialsError,
UserNotFoundError,
UserAlreadyExistsError,
TenantNotFoundError,
SessionExpiredError,
SessionRevokedError,
InvalidTokenError,
AccountLockedError,
AccountSuspendedError,
AccountNotVerifiedError,
PasswordMismatchError,
WeakPasswordError,
} from '../utils/errors';
import { getPool, setTenantContext, jwtConfig, passwordConfig, securityConfig } from '../config';
import { logger } from '../utils/logger';
export class AuthService {
private pool: Pool;
constructor() {
this.pool = getPool();
}
/**
* Register a new user
*/
async register(input: RegisterInput): Promise<{ user: Omit<User, 'passwordHash' | 'mfaSecret'>; tenant: Tenant }> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Validate password strength
this.validatePassword(input.password);
// Hash password
const passwordHash = await bcrypt.hash(input.password, passwordConfig.saltRounds);
// Create tenant if tenant info provided
let tenant: Tenant;
if (input.tenantName && input.tenantSlug) {
// Check if tenant slug exists
const existingTenant = await client.query(
'SELECT id FROM tenants.tenants WHERE slug = $1',
[input.tenantSlug]
);
if (existingTenant.rows.length > 0) {
throw new UserAlreadyExistsError(`Tenant with slug ${input.tenantSlug} already exists`);
}
// Create new tenant
const tenantResult = await client.query(
`INSERT INTO tenants.tenants (name, slug, owner_email, status)
VALUES ($1, $2, $3, 'active')
RETURNING id, name, slug, status`,
[input.tenantName, input.tenantSlug, input.email]
);
tenant = tenantResult.rows[0];
} else {
throw new Error('Tenant name and slug are required for registration');
}
// Set tenant context for RLS
await setTenantContext(client, tenant.id);
// Check if user exists in this tenant
const existingUser = await client.query(
'SELECT id FROM users.users WHERE email = $1 AND tenant_id = $2',
[input.email, tenant.id]
);
if (existingUser.rows.length > 0) {
throw new UserAlreadyExistsError(input.email);
}
// Create user
const userResult = await client.query(
`INSERT INTO users.users (
tenant_id, email, password_hash, first_name, last_name,
display_name, status, is_owner, email_verified, password_changed_at
) VALUES ($1, $2, $3, $4, $5, $6, 'pending', true, false, NOW())
RETURNING *`,
[
tenant.id,
input.email,
passwordHash,
input.firstName || null,
input.lastName || null,
input.firstName && input.lastName
? `${input.firstName} ${input.lastName}`
: input.firstName || input.email.split('@')[0],
]
);
await client.query('COMMIT');
const user = this.sanitizeUser(userResult.rows[0]);
logger.info('User registered successfully', { userId: user.id, tenantId: tenant.id });
return { user, tenant };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Login user
*/
async login(input: LoginInput): Promise<LoginResponse> {
const client = await this.pool.connect();
try {
// Find user by email
let userQuery = `
SELECT u.*, t.id as "tenantId", t.name as "tenantName", t.slug as "tenantSlug", t.status as "tenantStatus"
FROM users.users u
JOIN tenants.tenants t ON u.tenant_id = t.id
WHERE u.email = $1
`;
const params: string[] = [input.email];
if (input.tenantId) {
userQuery += ' AND u.tenant_id = $2';
params.push(input.tenantId);
}
const result = await client.query(userQuery, params);
if (result.rows.length === 0) {
throw new InvalidCredentialsError();
}
// If multiple tenants, require tenant selection
if (result.rows.length > 1 && !input.tenantId) {
const tenants = result.rows.map((r) => ({
id: r.tenantId,
name: r.tenantName,
slug: r.tenantSlug,
}));
throw new InvalidCredentialsError(
`Multiple tenants found. Please specify tenantId. Available: ${JSON.stringify(tenants)}`
);
}
const userRow = result.rows[0];
const tenantId = userRow.tenant_id;
// Set tenant context
await setTenantContext(client, tenantId);
// Check account status
await this.checkAccountStatus(userRow);
// Verify password
const isValidPassword = await bcrypt.compare(input.password, userRow.password_hash);
if (!isValidPassword) {
await this.handleFailedLogin(client, userRow.id, tenantId);
throw new InvalidCredentialsError();
}
// Reset failed login attempts on success
await client.query(
'UPDATE users.users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1 AND tenant_id = $2',
[userRow.id, tenantId]
);
// Generate tokens
const tokens = this.generateTokens({
sub: userRow.id,
email: userRow.email,
tenantId,
isOwner: userRow.is_owner,
});
// Create session
const session = await this.createSession(
client,
userRow.id,
tenantId,
tokens.refreshToken,
input.deviceInfo
);
// Update last login
await client.query(
`UPDATE users.users
SET last_login_at = NOW(), last_login_ip = $3
WHERE id = $1 AND tenant_id = $2`,
[userRow.id, tenantId, input.deviceInfo?.ipAddress || null]
);
const user = this.sanitizeUser(userRow);
logger.info('User logged in successfully', { userId: user.id, tenantId });
return {
user,
tokens,
session: {
id: session.id,
deviceType: session.deviceType,
expiresAt: session.expiresAt,
},
};
} finally {
client.release();
}
}
/**
* Refresh access token
*/
async refresh(input: RefreshInput): Promise<RefreshResponse> {
const client = await this.pool.connect();
try {
// Verify refresh token
let decoded: JWTPayload;
try {
decoded = jwt.verify(input.refreshToken, jwtConfig.secret) as JWTPayload;
} catch {
throw new InvalidTokenError('Invalid refresh token');
}
// Set tenant context
await setTenantContext(client, decoded.tenantId);
// Find session by token hash
const tokenHash = this.hashToken(input.refreshToken);
const sessionResult = await client.query(
`SELECT s.*, u.email, u.is_owner, u.status as user_status
FROM auth.sessions s
JOIN users.users u ON s.user_id = u.id
WHERE s.token_hash = $1 AND s.tenant_id = $2`,
[tokenHash, decoded.tenantId]
);
if (sessionResult.rows.length === 0) {
throw new SessionExpiredError();
}
const session = sessionResult.rows[0];
// Check session status
if (session.status === 'revoked') {
throw new SessionRevokedError();
}
if (session.status === 'expired' || new Date(session.expires_at) < new Date()) {
throw new SessionExpiredError();
}
// Check user status
if (session.user_status !== 'active') {
throw new AccountSuspendedError();
}
// Update session last active
await client.query(
'UPDATE auth.sessions SET last_active_at = NOW() WHERE id = $1',
[session.id]
);
// Generate new access token
const accessToken = jwt.sign(
{
sub: decoded.sub,
email: session.email,
tenantId: decoded.tenantId,
isOwner: session.is_owner,
} as Omit<JWTPayload, 'iat' | 'exp'>,
jwtConfig.secret,
{
expiresIn: jwtConfig.accessTokenExpiry,
issuer: jwtConfig.issuer,
}
);
const expiresIn = this.parseExpiry(jwtConfig.accessTokenExpiry);
logger.info('Token refreshed', { userId: decoded.sub, tenantId: decoded.tenantId });
return {
accessToken,
expiresIn,
};
} finally {
client.release();
}
}
/**
* Logout user
*/
async logout(input: LogoutInput): Promise<{ message: string; sessionsRevoked: number }> {
const client = await this.pool.connect();
try {
await setTenantContext(client, input.tenantId);
let result;
if (input.logoutAll) {
// Revoke all sessions for user
result = await client.query(
`UPDATE auth.sessions
SET status = 'revoked', revoked_at = NOW()
WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`,
[input.userId, input.tenantId]
);
} else if (input.sessionId) {
// Revoke specific session
result = await client.query(
`UPDATE auth.sessions
SET status = 'revoked', revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND status = 'active'`,
[input.sessionId, input.userId, input.tenantId]
);
} else {
throw new Error('Either sessionId or logoutAll must be provided');
}
logger.info('User logged out', {
userId: input.userId,
tenantId: input.tenantId,
sessionsRevoked: result.rowCount,
});
return {
message: 'Logged out successfully',
sessionsRevoked: result.rowCount || 0,
};
} finally {
client.release();
}
}
/**
* Request password reset
*/
async requestPasswordReset(input: PasswordResetRequestInput): Promise<{ message: string }> {
const client = await this.pool.connect();
try {
await setTenantContext(client, input.tenantId);
// Find user
const userResult = await client.query(
'SELECT id, email FROM users.users WHERE email = $1 AND tenant_id = $2',
[input.email, input.tenantId]
);
// Always return success to prevent email enumeration
if (userResult.rows.length === 0) {
return { message: 'If the email exists, a reset link will be sent' };
}
const user = userResult.rows[0];
// Generate reset token
const resetToken = crypto.randomBytes(32).toString('hex');
const tokenHash = this.hashToken(resetToken);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Invalidate existing reset tokens
await client.query(
`UPDATE auth.tokens
SET status = 'revoked', revoked_at = NOW()
WHERE user_id = $1 AND tenant_id = $2 AND token_type = 'password_reset' AND status = 'active'`,
[user.id, input.tenantId]
);
// Create new reset token
await client.query(
`INSERT INTO auth.tokens (user_id, tenant_id, token_type, token_hash, status, expires_at)
VALUES ($1, $2, 'password_reset', $3, 'active', $4)`,
[user.id, input.tenantId, tokenHash, expiresAt]
);
// TODO: Send email with reset link containing resetToken
logger.info('Password reset requested', { userId: user.id, tenantId: input.tenantId });
return { message: 'If the email exists, a reset link will be sent' };
} finally {
client.release();
}
}
/**
* Reset password with token
*/
async resetPassword(input: PasswordResetInput): Promise<{ message: string }> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const tokenHash = this.hashToken(input.token);
// Find valid token
const tokenResult = await client.query(
`SELECT t.*, u.tenant_id
FROM auth.tokens t
JOIN users.users u ON t.user_id = u.id
WHERE t.token_hash = $1 AND t.token_type = 'password_reset' AND t.status = 'active'`,
[tokenHash]
);
if (tokenResult.rows.length === 0) {
throw new InvalidTokenError('Invalid or expired reset token');
}
const token = tokenResult.rows[0];
if (new Date(token.expires_at) < new Date()) {
throw new InvalidTokenError('Reset token has expired');
}
await setTenantContext(client, token.tenant_id);
// Validate new password
this.validatePassword(input.newPassword);
// Hash new password
const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds);
// Update password
await client.query(
`UPDATE users.users
SET password_hash = $1, password_changed_at = NOW(), failed_login_attempts = 0, locked_until = NULL
WHERE id = $2 AND tenant_id = $3`,
[passwordHash, token.user_id, token.tenant_id]
);
// Mark token as used
await client.query(
`UPDATE auth.tokens SET status = 'used', used_at = NOW() WHERE id = $1`,
[token.id]
);
// Revoke all active sessions
await client.query(
`UPDATE auth.sessions
SET status = 'revoked', revoked_at = NOW()
WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`,
[token.user_id, token.tenant_id]
);
await client.query('COMMIT');
logger.info('Password reset successfully', { userId: token.user_id, tenantId: token.tenant_id });
return { message: 'Password reset successfully' };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Change password (authenticated)
*/
async changePassword(input: ChangePasswordInput): Promise<{ message: string }> {
const client = await this.pool.connect();
try {
await setTenantContext(client, input.tenantId);
// Get user
const userResult = await client.query(
'SELECT id, password_hash FROM users.users WHERE id = $1 AND tenant_id = $2',
[input.userId, input.tenantId]
);
if (userResult.rows.length === 0) {
throw new UserNotFoundError(input.userId);
}
const user = userResult.rows[0];
// Verify current password
const isValidPassword = await bcrypt.compare(input.currentPassword, user.password_hash);
if (!isValidPassword) {
throw new PasswordMismatchError();
}
// Validate new password
this.validatePassword(input.newPassword);
// Hash new password
const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds);
// Update password
await client.query(
`UPDATE users.users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2 AND tenant_id = $3`,
[passwordHash, input.userId, input.tenantId]
);
logger.info('Password changed', { userId: input.userId, tenantId: input.tenantId });
return { message: 'Password changed successfully' };
} finally {
client.release();
}
}
/**
* Verify access token
*/
async verifyToken(token: string): Promise<JWTPayload> {
try {
const decoded = jwt.verify(token, jwtConfig.secret) as JWTPayload;
return decoded;
} catch {
throw new InvalidTokenError();
}
}
/**
* Get user sessions
*/
async getUserSessions(userId: string, tenantId: string): Promise<Session[]> {
const client = await this.pool.connect();
try {
await setTenantContext(client, tenantId);
const result = await client.query(
`SELECT * FROM auth.sessions
WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'
ORDER BY last_active_at DESC`,
[userId, tenantId]
);
return result.rows.map(this.mapSession);
} finally {
client.release();
}
}
// Private helper methods
private validatePassword(password: string): void {
const issues: string[] = [];
if (password.length < passwordConfig.minLength) {
issues.push(`Password must be at least ${passwordConfig.minLength} characters`);
}
if (passwordConfig.requireUppercase && !/[A-Z]/.test(password)) {
issues.push('Password must contain at least one uppercase letter');
}
if (passwordConfig.requireLowercase && !/[a-z]/.test(password)) {
issues.push('Password must contain at least one lowercase letter');
}
if (passwordConfig.requireNumbers && !/\d/.test(password)) {
issues.push('Password must contain at least one number');
}
if (passwordConfig.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
issues.push('Password must contain at least one special character');
}
if (issues.length > 0) {
throw new WeakPasswordError(issues);
}
}
private async checkAccountStatus(user: Record<string, unknown>): Promise<void> {
const status = user.status as UserStatus;
if (status === 'suspended' || status === 'banned') {
throw new AccountSuspendedError();
}
if (status === 'deleted') {
throw new InvalidCredentialsError();
}
// Check if account is locked
if (user.locked_until && new Date(user.locked_until as string) > new Date()) {
throw new AccountLockedError(new Date(user.locked_until as string));
}
// Check if email verification is required
// Uncomment to enforce email verification
// if (!user.email_verified) {
// throw new AccountNotVerifiedError();
// }
}
private async handleFailedLogin(client: PoolClient, userId: string, tenantId: string): Promise<void> {
const result = await client.query(
`UPDATE users.users
SET failed_login_attempts = failed_login_attempts + 1
WHERE id = $1 AND tenant_id = $2
RETURNING failed_login_attempts`,
[userId, tenantId]
);
const attempts = result.rows[0]?.failed_login_attempts || 0;
if (attempts >= securityConfig.maxLoginAttempts) {
const lockedUntil = new Date(Date.now() + securityConfig.lockoutDuration);
await client.query(
'UPDATE users.users SET locked_until = $1 WHERE id = $2 AND tenant_id = $3',
[lockedUntil, userId, tenantId]
);
logger.warn('Account locked due to failed login attempts', { userId, tenantId, attempts });
}
}
private generateTokens(payload: Omit<JWTPayload, 'iat' | 'exp'>): AuthTokens {
const accessToken = jwt.sign(payload, jwtConfig.secret, {
expiresIn: jwtConfig.accessTokenExpiry,
issuer: jwtConfig.issuer,
});
const refreshToken = jwt.sign(payload, jwtConfig.secret, {
expiresIn: jwtConfig.refreshTokenExpiry,
issuer: jwtConfig.issuer,
});
return {
accessToken,
refreshToken,
expiresIn: this.parseExpiry(jwtConfig.accessTokenExpiry),
tokenType: 'Bearer',
};
}
private async createSession(
client: PoolClient,
userId: string,
tenantId: string,
refreshToken: string,
deviceInfo?: DeviceInfo
): Promise<Session> {
const tokenHash = this.hashToken(refreshToken);
const expiresAt = new Date(Date.now() + securityConfig.sessionDuration);
const result = await client.query(
`INSERT INTO auth.sessions (
user_id, tenant_id, token_hash, device_type, device_name,
browser, browser_version, os, os_version, ip_address,
user_agent, status, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12)
RETURNING *`,
[
userId,
tenantId,
tokenHash,
deviceInfo?.deviceType || 'unknown',
deviceInfo?.deviceName || null,
deviceInfo?.browser || null,
deviceInfo?.browserVersion || null,
deviceInfo?.os || null,
deviceInfo?.osVersion || null,
deviceInfo?.ipAddress || null,
deviceInfo?.userAgent || null,
expiresAt,
]
);
return this.mapSession(result.rows[0]);
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private parseExpiry(expiry: string): number {
const match = expiry.match(/^(\d+)([smhd])$/);
if (!match) return 900; // default 15 minutes
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's': return value;
case 'm': return value * 60;
case 'h': return value * 60 * 60;
case 'd': return value * 24 * 60 * 60;
default: return 900;
}
}
private sanitizeUser(row: Record<string, unknown>): Omit<User, 'passwordHash' | 'mfaSecret'> {
const { password_hash, mfa_secret, ...rest } = row;
return this.mapUser(rest);
}
private mapUser(row: Record<string, unknown>): Omit<User, 'passwordHash' | 'mfaSecret'> {
return {
id: row.id as string,
tenantId: row.tenant_id as string,
email: row.email as string,
firstName: row.first_name as string | null,
lastName: row.last_name as string | null,
displayName: row.display_name as string | null,
avatarUrl: row.avatar_url as string | null,
phone: row.phone as string | null,
status: row.status as UserStatus,
isOwner: row.is_owner as boolean,
emailVerified: row.email_verified as boolean,
emailVerifiedAt: row.email_verified_at ? new Date(row.email_verified_at as string) : null,
phoneVerified: row.phone_verified as boolean,
phoneVerifiedAt: row.phone_verified_at ? new Date(row.phone_verified_at as string) : null,
mfaEnabled: row.mfa_enabled as boolean,
passwordChangedAt: new Date(row.password_changed_at as string),
failedLoginAttempts: row.failed_login_attempts as number,
lockedUntil: row.locked_until ? new Date(row.locked_until as string) : null,
lastLoginAt: row.last_login_at ? new Date(row.last_login_at as string) : null,
lastLoginIp: row.last_login_ip as string | null,
preferences: (row.preferences as User['preferences']) || {
theme: 'light',
language: 'es',
notifications: { email: true, push: true, sms: false },
},
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
} as Omit<User, 'passwordHash' | 'mfaSecret'>;
}
private mapSession(row: Record<string, unknown>): Session {
return {
id: row.id as string,
userId: row.user_id as string,
tenantId: row.tenant_id as string,
tokenHash: row.token_hash as string,
deviceType: row.device_type as Session['deviceType'],
deviceName: row.device_name as string | null,
browser: row.browser as string | null,
browserVersion: row.browser_version as string | null,
os: row.os as string | null,
osVersion: row.os_version as string | null,
ipAddress: row.ip_address as string | null,
userAgent: row.user_agent as string | null,
status: row.status as Session['status'],
lastActiveAt: new Date(row.last_active_at as string),
expiresAt: new Date(row.expires_at as string),
revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null,
createdAt: new Date(row.created_at as string),
};
}
}
export const authService = new AuthService();