517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
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';
|
|
import jwt from 'jsonwebtoken';
|
|
import {
|
|
LoginRequest,
|
|
RegisterRequest,
|
|
LoginResponse,
|
|
RegisterResponse,
|
|
RefreshTokenResponse,
|
|
ResetPasswordResponse,
|
|
ResetRequestResponse
|
|
} from '../types/auth.types';
|
|
import { UserResponse } from '../types/user.types';
|
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
|
import nodemailer from 'nodemailer';
|
|
import { getPresignedUrl } from '../config/minio';
|
|
import { auditService } from './audit.service';
|
|
import { AuditAction } from '@prisma/client';
|
|
|
|
export class AuthService {
|
|
/**
|
|
* User login
|
|
*/
|
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
|
const { email, password } = data;
|
|
|
|
// Find user with role and profile
|
|
const user = await prisma.user.findUnique({
|
|
where: { email },
|
|
include: {
|
|
role: true,
|
|
profile: true
|
|
}
|
|
});
|
|
if (!user) {
|
|
logger.warn('Login attempt with invalid email', { email });
|
|
throw new UnauthorizedError('Invalid email or password');
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
// Verify password
|
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
|
if (!isPasswordValid) {
|
|
logger.warn('Login attempt with invalid password', { email });
|
|
throw new UnauthorizedError('Invalid email or password');
|
|
}
|
|
|
|
// Generate tokens
|
|
const token = this.generateAccessToken(user.id, user.email, user.email, user.role.code);
|
|
const refreshToken = this.generateRefreshToken(user.id);
|
|
|
|
logger.info('User logged in successfully', { userId: user.id, email: user.email });
|
|
|
|
// Audit log - LOGIN
|
|
auditService.log({
|
|
userId: user.id,
|
|
action: AuditAction.LOGIN,
|
|
entityType: 'User',
|
|
entityId: user.id,
|
|
metadata: { email: user.email, role: user.role.code }
|
|
});
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'Login successful',
|
|
data: {
|
|
token,
|
|
refreshToken,
|
|
user: await this.formatUserResponse(user)
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* User registration
|
|
*/
|
|
async register(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 STUDENT role
|
|
const studentRole = await prisma.role.findUnique({
|
|
where: { code: 'STUDENT' }
|
|
});
|
|
|
|
if (!studentRole) {
|
|
logger.error('STUDENT 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: studentRole.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 (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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset request
|
|
*/
|
|
async resetRequest(email: string): Promise<ResetRequestResponse> {
|
|
try {
|
|
// Find user with role
|
|
const user = await prisma.user.findUnique({
|
|
where: { email },
|
|
include: { role: true }
|
|
});
|
|
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}`;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
async resetPassword(token: string, password: string): Promise<ResetPasswordResponse> {
|
|
try {
|
|
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string };
|
|
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
|
|
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 }
|
|
});
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'Password reset successfully'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to reset password', { error });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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: avatar_url,
|
|
birth_date: user.profile.birth_date
|
|
} : undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
};
|
|
}
|
|
}
|