This commit is contained in:
JakkrapartXD 2026-01-13 03:10:44 +00:00
parent a3da5c9d55
commit f026c14f0c
9 changed files with 7286 additions and 6 deletions

1
Backend/.npmrc Normal file
View file

@ -0,0 +1 @@
enable-pre-post-scripts=true

View file

@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@tsoa/runtime": "7.0.0-alpha.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@ -30,6 +31,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"minio": "^8.0.2", "minio": "^8.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^7.0.12",
"redis": "^4.7.0", "redis": "^4.7.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
@ -43,6 +45,7 @@
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/nodemailer": "^7.0.5",
"@types/swagger-ui-express": "^4.1.6", "@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1", "@typescript-eslint/parser": "^8.19.1",

7031
Backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,7 @@ export const config = {
smtp: { smtp: {
host: process.env.SMTP_HOST || 'localhost', host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '1025', 10), port: parseInt(process.env.SMTP_PORT || '1025', 10),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER, user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM || 'noreply@elearning.local' from: process.env.SMTP_FROM || 'noreply@elearning.local'

View file

@ -1,14 +1,17 @@
import { Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller } from 'tsoa'; import { Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security } from 'tsoa';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { import {
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
RefreshTokenRequest, RefreshTokenRequest,
ResetRequest,
ResetPasswordRequest,
LoginResponse, LoginResponse,
RegisterResponse, RegisterResponse,
RefreshTokenResponse RefreshTokenResponse,
ChangePassword
} from '../types/auth.types'; } from '../types/auth.types';
import { loginSchema, registerSchema, refreshTokenSchema } from '../validators/auth.validator'; import { loginSchema, registerSchema, refreshTokenSchema, resetRequestSchema, resetPasswordSchema, changePasswordSchema } from '../validators/auth.validator';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
@Route('api/auth') @Route('api/auth')
@ -126,4 +129,38 @@ export class AuthController {
return await this.authService.refreshToken(body.refreshToken); return await this.authService.refreshToken(body.refreshToken);
} }
@Post('reset-request')
@SuccessResponse('200', 'Reset request successful')
@Response('401', 'Invalid or expired refresh token')
public async resetRequest(@Body() body: ResetRequest): Promise<{ message: string }> {
const { error } = resetRequestSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.resetRequest(body.email);
}
@Post('reset-password')
@SuccessResponse('200', 'Password reset successful')
@Response('401', 'Invalid or expired reset token')
public async resetPassword(@Body() body: ResetPasswordRequest): Promise<{ message: string }> {
const { error } = resetPasswordSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.resetPassword(body.id, body.token, body.password);
}
@Post('change-password')
@Security('jwt')
@SuccessResponse('200', 'Password changed successfully')
@Response('401', 'Invalid or expired reset token')
public async changePassword(@Body() body: ChangePassword): Promise<{ message: string }> {
const { error } = changePasswordSchema.validate(body);
if (error) {
throw new ValidationError(error.details[0].message);
}
return await this.authService.changePassword(body.id, body.oldPassword, body.newPassword);
}
} }

View file

@ -10,9 +10,13 @@ import {
LoginResponse, LoginResponse,
RegisterResponse, RegisterResponse,
RefreshTokenResponse, RefreshTokenResponse,
UserResponse UserResponse,
ResetPasswordResponse,
ChangePasswordResponse,
ResetRequestResponse
} from '../types/auth.types'; } from '../types/auth.types';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import nodemailer from 'nodemailer';
export class AuthService { export class AuthService {
/** /**
@ -172,6 +176,115 @@ export class AuthService {
} }
} }
/**
* Reset request
*/
async resetRequest(email: string): Promise<ResetRequestResponse> {
try {
// Find user
const user = await prisma.user.findUnique({ where: { email } });
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
const resetURL = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?id=${user.id}&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(id: number, token: string, password: string): Promise<ResetPasswordResponse> {
try {
const user = await prisma.user.findUnique({ where: { 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 });
return {
code: 200,
message: 'Password reset successfully'
};
} catch (error) {
logger.error('Failed to reset password', { id, error });
throw error;
}
}
async changePassword(id: number, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> {
try {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedError('User not found');
const isPasswordValid = await bcrypt.compare(oldPassword, user.password);
if (!isPasswordValid) throw new UnauthorizedError('Invalid password');
const encryptedPassword = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: { password: encryptedPassword }
});
logger.info('Password changed successfully', { userId: user.id });
return {
code: 200,
message: 'Password changed successfully'
};
} catch (error) {
logger.error('Failed to change password', { id, error });
throw error;
}
}
/** /**
* Generate access token (JWT) * Generate access token (JWT)
*/ */
@ -222,4 +335,6 @@ export class AuthService {
} : undefined } : undefined
}; };
} }
} }

View file

@ -61,3 +61,36 @@ export interface UserResponse {
avatar_url?: string; avatar_url?: string;
}; };
} }
export interface ResetRequest {
email: string;
}
export interface ResetPasswordRequest {
id: number;
token: string;
password: string;
}
export interface ChangePassword {
id: number;
oldPassword: string;
newPassword: string;
}
export interface ResetPasswordResponse {
code: number;
message: string;
}
export interface ChangePasswordResponse {
code: number;
message: string;
}
export interface ResetRequestResponse {
code: number;
message: string;
}

View file

@ -79,3 +79,62 @@ export const refreshTokenSchema = Joi.object({
'any.required': 'Refresh token is required' 'any.required': 'Refresh token is required'
}) })
}); });
export const resetPasswordSchema = Joi.object({
id: Joi.number()
.required()
.messages({
'any.required': 'User ID is required'
}),
token: Joi.string()
.required()
.messages({
'any.required': 'Reset token is required'
}),
password: Joi.string()
.min(6)
.max(100)
.required()
.messages({
'string.min': 'Password must be at least 6 characters',
'string.max': 'Password must not exceed 100 characters',
'any.required': 'Password is required'
})
});
export const changePasswordSchema = Joi.object({
id: Joi.number()
.required()
.messages({
'any.required': 'User ID is required'
}),
oldPassword: Joi.string()
.min(6)
.max(100)
.required()
.messages({
'string.min': 'Password must be at least 6 characters',
'string.max': 'Password must not exceed 100 characters',
'any.required': 'Password is required'
}),
newPassword: Joi.string()
.min(6)
.max(100)
.required()
.messages({
'string.min': 'Password must be at least 6 characters',
'string.max': 'Password must not exceed 100 characters',
'any.required': 'Password is required'
})
});
export const resetRequestSchema = Joi.object({
email: Joi.string()
.email()
.required()
.messages({
'string.email': 'Please provide a valid email address',
'any.required': 'Email is required'
})
});

View file

@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2021",
"module": "commonjs", "module": "commonjs",
"lib": [ "lib": [
"ES2020" "ES2021"
], ],
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",