All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 27m55s
241 lines
9.6 KiB
TypeScript
241 lines
9.6 KiB
TypeScript
// src/services/authService.ts
|
|
import * as bcrypt from 'bcrypt';
|
|
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
import { DatabaseError, FlyerProcessingError } from './processingErrors';
|
|
import { withTransaction, userRepo } from './db/index.db';
|
|
import { RepositoryError, ValidationError } from './db/errors.db';
|
|
import { logger } from './logger.server';
|
|
import { sendPasswordResetEmail } from './emailService.server';
|
|
import type { UserProfile } from '../types';
|
|
import { validatePasswordStrength } from '../utils/authUtils';
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET!;
|
|
|
|
class AuthService {
|
|
async registerUser(
|
|
email: string,
|
|
password: string,
|
|
fullName: string | undefined,
|
|
avatarUrl: string | undefined,
|
|
reqLog: any,
|
|
) {
|
|
const strength = validatePasswordStrength(password);
|
|
if (!strength.isValid) {
|
|
throw new ValidationError([], strength.feedback);
|
|
}
|
|
|
|
// Wrap user creation and activity logging in a transaction for atomicity.
|
|
// The `createUser` method is now designed to be composed within other transactions.
|
|
return withTransaction(async (client) => {
|
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
|
|
|
const saltRounds = 10;
|
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
logger.info(`Hashing password for new user: ${email}`);
|
|
|
|
const newUser = await transactionalUserRepo.createUser(
|
|
email,
|
|
hashedPassword,
|
|
{ full_name: fullName, avatar_url: avatarUrl },
|
|
reqLog,
|
|
);
|
|
|
|
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
|
|
|
await adminRepo.logActivity(
|
|
{ userId: newUser.user.user_id, action: 'user_registered', displayText: `${email} has registered.`, icon: 'user-plus' },
|
|
reqLog,
|
|
);
|
|
|
|
return newUser;
|
|
}).catch((error: unknown) => {
|
|
// Re-throw known repository errors (like UniqueConstraintError) to allow for specific handling upstream.
|
|
if (error instanceof RepositoryError) {
|
|
throw error;
|
|
}
|
|
// For unknown errors, log them and wrap them in a generic DatabaseError
|
|
// to standardize the error contract of the service layer.
|
|
const message = error instanceof Error ? error.message : 'An unknown error occurred during registration.';
|
|
logger.error({ error, email }, `User registration failed with an unexpected error.`);
|
|
throw new DatabaseError(message);
|
|
});
|
|
}
|
|
|
|
async registerAndLoginUser(
|
|
email: string,
|
|
password: string,
|
|
fullName: string | undefined,
|
|
avatarUrl: string | undefined,
|
|
reqLog: any,
|
|
): Promise<{ newUserProfile: UserProfile; accessToken: string; refreshToken: string }> {
|
|
const newUserProfile = await this.registerUser(
|
|
email,
|
|
password,
|
|
fullName,
|
|
avatarUrl,
|
|
reqLog,
|
|
);
|
|
const { accessToken, refreshToken } = await this.handleSuccessfulLogin(newUserProfile, reqLog);
|
|
return { newUserProfile, accessToken, refreshToken };
|
|
}
|
|
|
|
generateAuthTokens(userProfile: UserProfile) {
|
|
const payload = {
|
|
user_id: userProfile.user.user_id,
|
|
email: userProfile.user.email,
|
|
role: userProfile.role,
|
|
};
|
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
|
const refreshToken = crypto.randomBytes(64).toString('hex');
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
async saveRefreshToken(userId: string, refreshToken: string, reqLog: any) {
|
|
// The repository method `saveRefreshToken` already includes robust error handling
|
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
|
await userRepo.saveRefreshToken(userId, refreshToken, reqLog);
|
|
}
|
|
|
|
async handleSuccessfulLogin(userProfile: UserProfile, reqLog: any) {
|
|
const { accessToken, refreshToken } = this.generateAuthTokens(userProfile);
|
|
await this.saveRefreshToken(userProfile.user.user_id, refreshToken, reqLog);
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
async resetPassword(email: string, reqLog: any) {
|
|
try {
|
|
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
|
|
const user = await userRepo.findUserByEmail(email, reqLog);
|
|
let token: string | undefined;
|
|
logger.debug(
|
|
{ user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' },
|
|
`[API /forgot-password] Database search result for ${email}:`,
|
|
);
|
|
|
|
if (user) {
|
|
token = crypto.randomBytes(32).toString('hex');
|
|
const saltRounds = 10;
|
|
const tokenHash = await bcrypt.hash(token, saltRounds);
|
|
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
|
|
|
|
// Wrap the token creation in a transaction to ensure atomicity of the DELETE and INSERT operations.
|
|
await withTransaction(async (client) => {
|
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
|
await transactionalUserRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt, reqLog, client);
|
|
});
|
|
|
|
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
|
|
|
try {
|
|
await sendPasswordResetEmail(email, resetLink, reqLog);
|
|
} catch (emailError) {
|
|
logger.error({ emailError }, `Email send failure during password reset for user`);
|
|
}
|
|
} else {
|
|
logger.warn(`Password reset requested for non-existent email: ${email}`);
|
|
}
|
|
|
|
return token;
|
|
} catch (error) {
|
|
// Re-throw known repository errors to allow for specific handling upstream.
|
|
if (error instanceof RepositoryError) {
|
|
throw error;
|
|
}
|
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
logger.error({ error, email }, `An unexpected error occurred during password reset for email: ${email}`);
|
|
throw new DatabaseError(message);
|
|
}
|
|
}
|
|
|
|
async updatePassword(token: string, newPassword: string, reqLog: any) {
|
|
const strength = validatePasswordStrength(newPassword);
|
|
if (!strength.isValid) {
|
|
throw new ValidationError([], strength.feedback);
|
|
}
|
|
|
|
// Wrap all database operations in a transaction to ensure atomicity.
|
|
return withTransaction(async (client) => {
|
|
const transactionalUserRepo = new (await import('./db/user.db')).UserRepository(client);
|
|
const adminRepo = new (await import('./db/admin.db')).AdminRepository(client);
|
|
|
|
// This read can happen outside the transaction if we use the non-transactional repo.
|
|
const validTokens = await userRepo.getValidResetTokens(reqLog);
|
|
let tokenRecord;
|
|
for (const record of validTokens) {
|
|
const isMatch = await bcrypt.compare(token, record.token_hash);
|
|
if (isMatch) {
|
|
tokenRecord = record;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!tokenRecord) {
|
|
return null; // Token is invalid or expired, not an error.
|
|
}
|
|
|
|
const saltRounds = 10;
|
|
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
|
|
|
// These three writes are now atomic.
|
|
await transactionalUserRepo.updateUserPassword(tokenRecord.user_id, hashedPassword, reqLog);
|
|
await transactionalUserRepo.deleteResetToken(tokenRecord.token_hash, reqLog);
|
|
await adminRepo.logActivity(
|
|
{ userId: tokenRecord.user_id, action: 'password_reset', displayText: `User ID ${tokenRecord.user_id} has reset their password.`, icon: 'key' },
|
|
reqLog,
|
|
);
|
|
|
|
return true;
|
|
}).catch((error) => {
|
|
// Re-throw known repository errors to allow for specific handling upstream.
|
|
if (error instanceof RepositoryError) {
|
|
throw error;
|
|
}
|
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
|
const message = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
logger.error({ error }, `An unexpected error occurred during password update.`);
|
|
throw new DatabaseError(message);
|
|
});
|
|
}
|
|
|
|
async getUserByRefreshToken(refreshToken: string, reqLog: any) {
|
|
try {
|
|
const basicUser = await userRepo.findUserByRefreshToken(refreshToken, reqLog);
|
|
if (!basicUser) {
|
|
return null;
|
|
}
|
|
const userProfile = await userRepo.findUserProfileById(basicUser.user_id, reqLog);
|
|
return userProfile;
|
|
} catch (error) {
|
|
// Re-throw known repository errors to allow for specific handling upstream.
|
|
if (error instanceof RepositoryError) {
|
|
throw error;
|
|
}
|
|
// For unknown errors, log them and wrap them in a generic DatabaseError.
|
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
logger.error({ error, refreshToken }, 'An unexpected error occurred while fetching user by refresh token.');
|
|
throw new DatabaseError(errorMessage);
|
|
}
|
|
}
|
|
|
|
async logout(refreshToken: string, reqLog: any) {
|
|
// The repository method `deleteRefreshToken` now includes robust error handling
|
|
// and logging via `handleDbError`. No need for a redundant try/catch block here.
|
|
// The original implementation also swallowed errors, which is now fixed.
|
|
await userRepo.deleteRefreshToken(refreshToken, reqLog);
|
|
}
|
|
|
|
async refreshAccessToken(refreshToken: string, reqLog: any): Promise<{ accessToken: string } | null> {
|
|
const user = await this.getUserByRefreshToken(refreshToken, reqLog);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
const { accessToken } = this.generateAuthTokens(user);
|
|
return { accessToken };
|
|
}
|
|
}
|
|
|
|
export const authService = new AuthService(); |