elearning/Backend/src/services/auth.service.ts

518 lines
18 KiB
TypeScript
Raw Normal View History

2026-01-09 06:28:15 +00:00
import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import bcrypt from '@node-rs/bcrypt';
2026-01-09 06:28:15 +00:00
import jwt from 'jsonwebtoken';
import {
LoginRequest,
RegisterRequest,
LoginResponse,
RegisterResponse,
RefreshTokenResponse,
2026-01-13 03:10:44 +00:00
ResetPasswordResponse,
ResetRequestResponse
2026-01-09 06:28:15 +00:00
} from '../types/auth.types';
2026-01-13 17:55:00 +07:00
import { UserResponse } from '../types/user.types';
2026-01-09 06:28:15 +00:00
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
2026-01-13 03:10:44 +00:00
import nodemailer from 'nodemailer';
import { getPresignedUrl } from '../config/minio';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
2026-01-09 06:28:15 +00:00
export class AuthService {
/**
* User login
*/
async login(data: LoginRequest): Promise<LoginResponse> {
2026-01-09 10:14:13 +00:00
const { email, password } = data;
2026-01-09 06:28:15 +00:00
// Find user with role and profile
const user = await prisma.user.findUnique({
2026-01-09 10:14:13 +00:00
where: { email },
2026-01-09 06:28:15 +00:00
include: {
role: true,
profile: true
}
});
if (!user) {
2026-01-09 10:14:13 +00:00
logger.warn('Login attempt with invalid email', { email });
throw new UnauthorizedError('Invalid email or password');
2026-01-09 06:28:15 +00:00
}
2026-01-15 10:17:15 +07:00
// Check if account is deactivated
if (user.is_deactivated) {
logger.warn('Login attempt with deactivated account', { email, userId: user.id });
throw new ForbiddenError('This account has been deactivated');
}
2026-01-09 06:28:15 +00:00
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
2026-01-09 10:14:13 +00:00
logger.warn('Login attempt with invalid password', { email });
throw new UnauthorizedError('Invalid email or password');
2026-01-09 06:28:15 +00:00
}
// Generate tokens
2026-01-09 10:14:13 +00:00
const token = this.generateAccessToken(user.id, user.email, user.email, user.role.code);
2026-01-09 06:28:15 +00:00
const refreshToken = this.generateRefreshToken(user.id);
2026-01-09 10:14:13 +00:00
logger.info('User logged in successfully', { userId: user.id, email: user.email });
2026-01-09 06:28:15 +00:00
// Audit log - LOGIN
auditService.log({
userId: user.id,
action: AuditAction.LOGIN,
entityType: 'User',
entityId: user.id,
metadata: { email: user.email, role: user.role.code }
});
2026-01-09 06:28:15 +00:00
return {
code: 200,
message: 'Login successful',
data: {
token,
refreshToken,
user: await this.formatUserResponse(user)
}
2026-01-09 06:28:15 +00:00
};
}
/**
* User registration
*/
async register(data: RegisterRequest): Promise<RegisterResponse> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data;
2026-01-09 06:28:15 +00:00
// Check if username already exists
const existingUsername = await prisma.user.findUnique({
where: { username }
});
2026-01-09 06:28:15 +00:00
if (existingUsername) {
throw new ValidationError('Username already exists');
}
2026-01-09 06:28:15 +00:00
// Check if email already exists
const existingEmail = await prisma.user.findUnique({
where: { email }
});
2026-01-09 06:28:15 +00:00
if (existingEmail) {
throw new ValidationError('Email already exists');
}
2026-01-09 10:59:26 +00:00
// Check if phone number already exists in user profiles
const existingPhone = await prisma.userProfile.findFirst({
where: { phone }
});
2026-01-09 10:59:26 +00:00
if (existingPhone) {
throw new ValidationError('Phone number already exists');
}
2026-01-09 06:28:15 +00:00
// Get STUDENT role
const studentRole = await prisma.role.findUnique({
where: { code: 'STUDENT' }
});
2026-01-09 06:28:15 +00:00
if (!studentRole) {
logger.error('STUDENT role not found in database');
throw new Error('System configuration error');
}
2026-01-09 06:28:15 +00:00
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user with profile
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
role_id: studentRole.id,
profile: {
create: {
prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull,
first_name,
last_name,
phone
}
2026-01-09 06:28:15 +00:00
}
},
include: {
role: true,
profile: true
2026-01-09 06:28:15 +00:00
}
});
logger.info('New user registered', { userId: user.id, username: user.username });
// Audit log - REGISTER (Student)
auditService.log({
userId: user.id,
action: AuditAction.CREATE,
entityType: 'User',
entityId: user.id,
newValue: { username: user.username, email: user.email, role: 'STUDENT' }
});
return {
user: this.formatUserResponseSync(user),
message: 'Registration successful'
};
} catch (error) {
logger.error('Failed to register user', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: 0,
metadata: {
operation: 'register_user',
email: data.email,
username: data.username,
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
2026-01-09 06:28:15 +00:00
}
async registerInstructor(data: RegisterRequest): Promise<RegisterResponse> {
try {
const { username, email, password, first_name, last_name, prefix, phone } = data;
// Check if username already exists
const existingUsername = await prisma.user.findUnique({
where: { username }
});
if (existingUsername) {
throw new ValidationError('Username already exists');
}
// Check if email already exists
const existingEmail = await prisma.user.findUnique({
where: { email }
});
if (existingEmail) {
throw new ValidationError('Email already exists');
}
// Check if phone number already exists in user profiles
const existingPhone = await prisma.userProfile.findFirst({
where: { phone }
});
if (existingPhone) {
throw new ValidationError('Phone number already exists');
}
// Get INSTRUCTOR role
const instructorRole = await prisma.role.findUnique({
where: { code: 'INSTRUCTOR' }
});
if (!instructorRole) {
logger.error('INSTRUCTOR role not found in database');
throw new Error('System configuration error');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user with profile
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
role_id: instructorRole.id,
profile: {
create: {
prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull,
first_name,
last_name,
phone
}
}
},
include: {
role: true,
profile: true
}
});
logger.info('New user registered', { userId: user.id, username: user.username });
// Audit log - REGISTER (Instructor)
auditService.log({
userId: user.id,
action: AuditAction.CREATE,
entityType: 'User',
entityId: user.id,
newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' }
});
return {
user: this.formatUserResponseSync(user),
message: 'Registration successful'
};
} catch (error) {
logger.error('Failed to register instructor', { error });
await auditService.logSync({
userId: 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: 0,
metadata: {
operation: 'register_instructor',
email: data.email,
username: data.username,
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
2026-01-09 06:28:15 +00:00
/**
* Refresh access token
*/
async refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, config.jwt.secret) as { id: number; type: string };
if (decoded.type !== 'refresh') {
throw new UnauthorizedError('Invalid token type');
}
// Get user
const user = await prisma.user.findUnique({
where: { id: decoded.id },
include: { role: true }
});
if (!user) {
throw new UnauthorizedError('User not found');
}
// Generate new tokens
const newToken = this.generateAccessToken(user.id, user.username, user.email, user.role.code);
const newRefreshToken = this.generateRefreshToken(user.id);
logger.info('Token refreshed', { userId: user.id });
return {
token: newToken,
refreshToken: newRefreshToken
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
throw new UnauthorizedError('Invalid refresh token');
}
if (error instanceof jwt.TokenExpiredError) {
throw new UnauthorizedError('Refresh token expired');
}
throw error;
}
}
2026-01-13 03:10:44 +00:00
/**
* Reset request
*/
async resetRequest(email: string): Promise<ResetRequestResponse> {
try {
// Find user with role
const user = await prisma.user.findUnique({
where: { email },
include: { role: true }
});
2026-01-13 03:10:44 +00:00
if (!user) throw new UnauthorizedError('User not found');
const token = jwt.sign({ id: user.id, email: user.email }, config.jwt.secret, { expiresIn: '1h' });
// Create reset URL based on role
const isInstructor = user.role.code === 'INSTRUCTOR';
const baseUrl = isInstructor
? (process.env.FRONTEND_URL_INSTRUCTOR || 'http://localhost:3001')
: (process.env.FRONTEND_URL_STUDENT || 'http://localhost:3000');
const resetURL = `${baseUrl}/reset-password?token=${token}`;
2026-01-13 03:10:44 +00:00
// Create transporter
const transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
...(config.smtp.user && config.smtp.pass && {
auth: {
user: config.smtp.user,
pass: config.smtp.pass
}
})
});
// Send email
const mailOptions = {
from: config.smtp.from,
to: user.email,
subject: 'Password Reset Request',
text: `You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\nPlease click on the following link, or paste this into your browser to complete the process:\n\n${resetURL}\n\nIf you did not request this, please ignore this email and your password will remain unchanged.\n`,
html: `
<h2>Password Reset Request</h2>
<p>You are receiving this because you (or someone else) have requested the reset of the password for your account.</p>
<p>Please click on the following link to complete the process:</p>
<p><a href="${resetURL}">${resetURL}</a></p>
<p>If you did not request this, please ignore this email and your password will remain unchanged.</p>
`
};
await transporter.sendMail(mailOptions);
logger.info('Password reset email sent', { email: user.email });
return {
code: 200,
message: 'Password reset email sent successfully'
};
} catch (error) {
logger.error('Failed to send password reset email', { email, error });
throw error;
}
}
2026-01-14 13:42:54 +07:00
async resetPassword(token: string, password: string): Promise<ResetPasswordResponse> {
2026-01-13 03:10:44 +00:00
try {
2026-01-14 13:42:54 +07:00
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
2026-01-13 03:10:44 +00:00
if (!user) throw new UnauthorizedError('User not found');
const secret = config.jwt.secret;
const verify = jwt.verify(token, secret);
const encryptedPassword = await bcrypt.hash(password, 10);
if (!verify) throw new UnauthorizedError('Invalid token');
await prisma.user.update({
where: { id: user.id },
data: { password: encryptedPassword }
});
logger.info('Password reset successfully', { userId: user.id });
// Audit log - RESET_PASSWORD
auditService.log({
userId: user.id,
action: AuditAction.RESET_PASSWORD,
entityType: 'User',
entityId: user.id,
metadata: { email: user.email }
});
2026-01-13 03:10:44 +00:00
return {
code: 200,
message: 'Password reset successfully'
};
} catch (error) {
2026-01-14 13:42:54 +07:00
logger.error('Failed to reset password', { error });
2026-01-13 03:10:44 +00:00
throw error;
}
}
2026-01-09 06:28:15 +00:00
/**
* Generate access token (JWT)
*/
private generateAccessToken(id: number, username: string, email: string, roleCode: string): string {
return jwt.sign(
{
id,
username,
email,
roleCode
},
config.jwt.secret,
{ expiresIn: config.jwt.expiresIn } as jwt.SignOptions
);
}
/**
* Generate refresh token
*/
private generateRefreshToken(id: number): string {
return jwt.sign(
{
id,
type: 'refresh'
},
config.jwt.secret,
{ expiresIn: config.jwt.refreshExpiresIn } as jwt.SignOptions
);
}
/**
* Format user response with presigned URL for avatar (for login)
2026-01-09 06:28:15 +00:00
*/
private async formatUserResponse(user: any): Promise<UserResponse> {
let avatar_url: string | null = null;
if (user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
2026-01-09 06:28:15 +00:00
return {
id: user.id,
username: user.username,
email: user.email,
email_verified_at: user.email_verified_at,
2026-01-13 17:55:00 +07:00
updated_at: user.updated_at,
created_at: user.created_at,
2026-01-09 06:28:15 +00:00
role: {
code: user.role.code,
name: user.role.name
},
profile: user.profile ? {
prefix: user.profile.prefix,
first_name: user.profile.first_name,
last_name: user.profile.last_name,
2026-01-13 17:55:00 +07:00
phone: user.profile.phone,
avatar_url: avatar_url,
2026-01-13 17:55:00 +07:00
birth_date: user.profile.birth_date
2026-01-09 06:28:15 +00:00
} : undefined
};
}
2026-01-13 03:10:44 +00:00
/**
* Format user response without presigned URL (for register)
*/
private formatUserResponseSync(user: any): UserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
email_verified_at: user.email_verified_at,
updated_at: user.updated_at,
created_at: user.created_at,
role: {
code: user.role.code,
name: user.role.name
},
profile: user.profile ? {
prefix: user.profile.prefix,
first_name: user.profile.first_name,
last_name: user.profile.last_name,
phone: user.profile.phone,
avatar_url: user.profile.avatar_url,
birth_date: user.profile.birth_date
} : undefined
};
}
2026-01-09 06:28:15 +00:00
}