789 lines
23 KiB
TypeScript
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();
|