🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled

 Características:
- 45 ejercicios universitarios (Basic → Advanced)
- Renderizado LaTeX profesional
- IA generativa (Z.ai/DashScope)
- Docker 9 servicios
- Tests 123/123 pasando
- Seguridad enterprise (JWT, XSS, Rate limiting)

🐳 Infraestructura:
- Next.js 14 + Node.js 20
- PostgreSQL 15 + Redis 7
- Docker Compose completo
- Nginx + SSL ready

📚 Documentación:
- 5 informes técnicos completos
- README profesional
- Scripts de deployment automatizados

Estado: Producción lista 
This commit is contained in:
Renato
2026-03-31 11:27:11 -03:00
commit bc43c9e772
309 changed files with 84845 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
/**
* Authentication Controller
*
* Express route handlers for authentication endpoints
*/
import { Request, Response } from 'express';
import { authService } from './auth.service';
import { ApiResponse } from '../../shared/types';
import { JwtPayload } from './dtos';
/**
* Authentication Controller
*/
export class AuthController {
/**
* Register new user
* POST /api/auth/register
*/
async register(req: Request, res: Response): Promise<void> {
const result = await authService.register(req.body);
const response: ApiResponse = {
success: true,
data: {
user: result.user,
token: result.token,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
refreshTokenExpiresIn: result.refreshTokenExpiresIn,
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(201).json(response);
}
/**
* Login user
* POST /api/auth/login
*/
async login(req: Request, res: Response): Promise<void> {
const result = await authService.login(req.body);
const response: ApiResponse = {
success: true,
data: {
user: result.user,
token: result.token,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
refreshTokenExpiresIn: result.refreshTokenExpiresIn,
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
/**
* Get current user profile
* GET /api/auth/me
*/
async getProfile(req: Request, res: Response): Promise<void> {
const user = req.user as JwtPayload;
const profile = await authService.getProfile(user.userId);
const response: ApiResponse = {
success: true,
data: profile,
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
/**
* Logout user
* POST /api/auth/logout
*
* Invalidates the access token (blacklists in Redis) and optionally
* revokes the refresh token.
*/
async logout(req: Request, res: Response): Promise<void> {
const authHeader = req.headers.authorization;
let accessToken = '';
if (authHeader && authHeader.startsWith('Bearer ')) {
accessToken = authHeader.substring(7);
}
// Get refresh token from request body if provided
const { refreshToken } = req.body;
if (accessToken) {
await authService.logout(accessToken, refreshToken);
}
const response: ApiResponse = {
success: true,
data: {
message: 'Logged out successfully',
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
/**
* Refresh access token
* POST /api/auth/refresh
*
* Validates the refresh token and issues a new access token.
* Also rotates the refresh token for security.
*/
async refreshToken(req: Request, res: Response): Promise<void> {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({
success: false,
error: {
code: 'MISSING_REFRESH_TOKEN',
message: 'Refresh token is required',
},
});
return;
}
const result = await authService.refreshAccessToken(refreshToken);
const response: ApiResponse = {
success: true,
data: {
token: result.token,
refreshToken: result.refreshToken,
expiresIn: result.expiresIn,
refreshTokenExpiresIn: result.refreshTokenExpiresIn,
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
/**
* Request password reset
* POST /api/auth/forgot-password
*/
async forgotPassword(req: Request, res: Response): Promise<void> {
const { email } = req.body;
await authService.requestPasswordReset(email);
// Always return success to prevent email enumeration
const response: ApiResponse = {
success: true,
data: {
message: 'If an account exists with this email, a password reset link will be sent',
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
/**
* Reset password with token
* POST /api/auth/reset-password
*/
async resetPassword(req: Request, res: Response): Promise<void> {
const { token, newPassword } = req.body;
await authService.resetPassword(token, newPassword);
const response: ApiResponse = {
success: true,
data: {
message: 'Password reset successfully',
},
meta: {
timestamp: new Date().toISOString(),
requestId: req.correlationId,
},
};
res.status(200).json(response);
}
}
// Export singleton instance
export const authController = new AuthController();

View File

@@ -0,0 +1,166 @@
/**
* Authentication Routes
*
* Route definitions for authentication endpoints
*/
import { Router } from 'express';
import { authController } from './auth.controller';
import { authenticate } from '../../shared/middleware/auth.middleware';
import { validateBody } from '../../shared/middleware/validation.middleware';
import { registerSchema, loginSchema, refreshTokenSchema } from './dtos';
import { authRateLimiter } from '../../shared/middleware/rate-limit.middleware';
import { asyncHandler } from '../../shared/middleware/validation.middleware';
import { z } from 'zod';
/**
* Create authentication router
*/
export function createAuthRoutes(): Router {
const router = Router();
/**
* POST /api/auth/register
* Register a new user
*
* Request body:
* - email: string (valid email)
* - username: string (3-20 chars, alphanumeric + underscore)
* - password: string (min 8 chars, uppercase, lowercase, number, special char)
*
* Response: 201 Created
* - user: object (id, email, username, isActive, createdAt)
* - token: string (JWT)
* - expiresIn: number (seconds)
*/
router.post(
'/register',
authRateLimiter,
validateBody(registerSchema),
asyncHandler(async (req, res) => authController.register(req, res))
);
/**
* POST /api/auth/login
* Login with email and password
*
* Request body:
* - email: string
* - password: string
*
* Response: 200 OK
* - user: object (id, email, username, isActive, lastLoginAt)
* - token: string (JWT)
* - expiresIn: number (seconds)
*/
router.post(
'/login',
authRateLimiter,
validateBody(loginSchema),
asyncHandler(async (req, res) => authController.login(req, res))
);
/**
* GET /api/auth/me
* Get current user profile
*
* Requires: Bearer token in Authorization header
*
* Response: 200 OK
* - user: object (id, email, username, isActive, telegramChatId, createdAt, updatedAt, lastLoginAt)
*/
router.get(
'/me',
authenticate,
asyncHandler(async (req, res) => authController.getProfile(req, res))
);
/**
* POST /api/auth/logout
* Logout current user (invalidates token)
*
* Requires: Bearer token in Authorization header
*
* Request body (optional):
* - refreshToken: string (will be revoked if provided)
*
* Response: 200 OK
* - message: string
*
* Note: Client should delete both tokens from storage
*/
router.post(
'/logout',
authenticate,
asyncHandler(async (req, res) => authController.logout(req, res))
);
/**
* POST /api/auth/refresh
* Refresh access token using refresh token
*
* Request body:
* - refreshToken: string (required)
*
* Response: 200 OK
* - token: string (new JWT access token)
* - refreshToken: string (new refresh token - rotated)
* - expiresIn: number (seconds until access token expires)
* - refreshTokenExpiresIn: number (seconds until refresh token expires)
*
* Note: The old refresh token is invalidated after use
*/
router.post(
'/refresh',
validateBody(refreshTokenSchema),
asyncHandler(async (req, res) => authController.refreshToken(req, res))
);
/**
* POST /api/auth/forgot-password
* Request password reset email
*
* Request body:
* - email: string
*
* Response: 200 OK
* - message: string
*
* Note: Always returns success to prevent email enumeration
*/
router.post(
'/forgot-password',
authRateLimiter,
validateBody(z.object({
email: z.string().email('Invalid email format'),
})),
asyncHandler(async (req, res) => authController.forgotPassword(req, res))
);
/**
* POST /api/auth/reset-password
* Reset password with token
*
* Request body:
* - token: string (reset token from email)
* - newPassword: string (same requirements as registration)
*
* Response: 200 OK
* - message: string
*
* Note: Not yet fully implemented
*/
router.post(
'/reset-password',
validateBody(z.object({
token: z.string().min(1, 'Token is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
})),
asyncHandler(async (req, res) => authController.resetPassword(req, res))
);
return router;
}
// Export singleton instance
export const authRoutes = createAuthRoutes();

View File

@@ -0,0 +1,750 @@
/**
* Authentication Service
*
* Business logic for user authentication including:
* - User registration with password hashing
* - JWT token generation and validation
* - Login with credential verification
* - Token invalidation (logout)
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { prisma } from '../../shared/database/prisma.client';
import { blacklistToken, isTokenBlacklisted as checkTokenBlacklisted } from '../../shared/database/redis.client';
import { logger } from '../../shared/utils/logger';
import {
ConflictError,
AuthenticationError,
NotFoundError,
ValidationError,
} from '../../shared/types';
import { RegisterDto, LoginDto, JwtPayload } from './dtos';
import type { UserRole } from '@prisma/client';
import { telegramClient } from '../../modules/notification/telegram/telegram.client';
import { isTelegramEnabled } from '../../config/telegram';
/**
* Token expiration times
* - Access token: configurable via JWT_EXPIRES_IN env var (default: 15 minutes)
* - Refresh token: 7 days
*/
const ACCESS_TOKEN_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const REFRESH_TOKEN_EXPIRES_DAYS = 7;
const SALT_ROUNDS = 10;
const PASSWORD_RESET_TOKEN_EXPIRES_HOURS = 1; // 1 hour expiration
/**
* Response types for authentication operations
*/
export interface RegisterResponse {
user: {
id: string;
email: string;
username: string;
isActive: boolean;
createdAt: Date;
};
token: string;
refreshToken: string;
expiresIn: number;
refreshTokenExpiresIn: number;
}
export interface LoginResponse {
user: {
id: string;
email: string;
username: string;
isActive: boolean;
lastLoginAt: Date | null;
};
token: string;
refreshToken: string;
expiresIn: number;
refreshTokenExpiresIn: number;
}
export interface RefreshTokenResponse {
token: string;
refreshToken: string;
expiresIn: number;
refreshTokenExpiresIn: number;
}
export interface UserProfile {
id: string;
email: string;
username: string;
isActive: boolean;
telegramChatId: string | null;
createdAt: Date;
updatedAt: Date;
lastLoginAt: Date | null;
}
/**
* Authentication Service
*/
export class AuthService {
/**
* Get JWT secret from environment
*/
private getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
logger.error('JWT_SECRET not configured in environment');
throw new Error('JWT_SECRET not configured');
}
return secret;
}
/**
* Generate JWT access token for user (15 minutes)
*/
private generateAccessToken(user: { id: string; email: string; username: string; role: UserRole | null }): string {
const payload: JwtPayload = {
userId: user.id,
email: user.email,
username: user.username,
role: user.role ?? 'STUDENT',
};
const options = {
expiresIn: ACCESS_TOKEN_EXPIRES_IN as unknown as NonNullable<jwt.SignOptions['expiresIn']>,
} satisfies jwt.SignOptions;
const token = jwt.sign(payload, this.getJwtSecret(), options as jwt.SignOptions);
return token;
}
/**
* Generate a secure refresh token string
*/
private generateRefreshTokenString(): string {
return crypto.randomBytes(64).toString('hex');
}
/**
* Hash refresh token using SHA-256 for O(1) lookup
* SHA-256 is fast and deterministic, allowing direct database lookup
*/
private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Calculate access token expiration in seconds (15 minutes)
*/
private getAccessTokenExpirationSeconds(): number {
return 15 * 60;
}
/**
* Calculate refresh token expiration in seconds (7 days)
*/
private getRefreshTokenExpirationSeconds(): number {
return REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60;
}
/**
* Create and store a refresh token in database
* Uses SHA-256 hash for O(1) lookup instead of bcrypt
*/
private async createRefreshToken(userId: string): Promise<string> {
const tokenString = this.generateRefreshTokenString();
const hashedToken = this.hashRefreshToken(tokenString);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS);
await prisma.refreshToken.create({
data: {
token: hashedToken,
userId,
expiresAt,
},
});
logger.info({ userId }, 'Refresh token created');
return tokenString;
}
/**
* Verify refresh token and return associated user
* O(1) lookup using SHA-256 hash instead of O(n) bcrypt comparison
*/
private async verifyRefreshToken(tokenString: string): Promise<{ userId: string; tokenId: string }> {
// Hash the input token with SHA-256 for direct lookup
const hashedInput = this.hashRefreshToken(tokenString);
const storedToken = await prisma.refreshToken.findUnique({
where: {
token: hashedInput,
},
select: {
id: true,
userId: true,
revoked: true,
expiresAt: true,
},
});
if (!storedToken) {
throw new AuthenticationError('Invalid or expired refresh token');
}
if (storedToken.revoked) {
throw new AuthenticationError('Refresh token has been revoked');
}
if (storedToken.expiresAt < new Date()) {
throw new AuthenticationError('Refresh token has expired');
}
return { userId: storedToken.userId, tokenId: storedToken.id };
}
/**
* Revoke a refresh token by ID
*/
private async revokeRefreshToken(tokenId: string): Promise<void> {
await prisma.refreshToken.update({
where: { id: tokenId },
data: { revoked: true },
});
logger.info({ tokenId }, 'Refresh token revoked');
}
/**
* Clean up expired or revoked refresh tokens for a user
*/
private async cleanupUserRefreshTokens(userId: string): Promise<void> {
const result = await prisma.refreshToken.deleteMany({
where: {
userId,
OR: [
{ revoked: true },
{ expiresAt: { lt: new Date() } },
],
},
});
logger.debug({ userId, count: result.count }, 'Cleaned up user refresh tokens');
}
/**
* Hash password using bcrypt
*/
private async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Compare password with hash
*/
private async comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Validate email uniqueness
*/
private async isEmailUnique(email: string): Promise<boolean> {
const existingUser = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
return !existingUser;
}
/**
* Validate username uniqueness
*/
private async isUsernameUnique(username: string): Promise<boolean> {
const existingUser = await prisma.user.findUnique({
where: { username },
select: { id: true },
});
return !existingUser;
}
/**
* Register a new user
*
* @throws {ConflictError} If email or username already exists
*/
async register(dto: RegisterDto): Promise<RegisterResponse> {
logger.info({ email: dto.email, username: dto.username }, 'Register attempt');
// Check email uniqueness
const emailUnique = await this.isEmailUnique(dto.email);
if (!emailUnique) {
logger.warn({ email: dto.email }, 'Registration failed: email already exists');
throw new ConflictError('Email already registered', {
field: 'email',
message: 'An account with this email already exists',
});
}
// Check username uniqueness
const usernameUnique = await this.isUsernameUnique(dto.username);
if (!usernameUnique) {
logger.warn({ username: dto.username }, 'Registration failed: username already exists');
throw new ConflictError('Username already taken', {
field: 'username',
message: 'This username is already taken',
});
}
// Hash password
const passwordHash = await this.hashPassword(dto.password);
// Create user
const user = await prisma.user.create({
data: {
email: dto.email,
username: dto.username,
passwordHash,
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
createdAt: true,
},
});
// Generate tokens
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
const expiresIn = this.getAccessTokenExpirationSeconds();
const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();
logger.info(
{ userId: user.id, email: user.email },
'User registered successfully'
);
return {
user,
token: accessToken,
refreshToken,
expiresIn,
refreshTokenExpiresIn,
};
}
/**
* Login user with email and password
*
* @throws {AuthenticationError} If credentials are invalid
* @throws {NotFoundError} If user doesn't exist
*/
async login(dto: LoginDto): Promise<LoginResponse> {
logger.info({ email: dto.email }, 'Login attempt');
// Find user by email
const user = await prisma.user.findUnique({
where: { email: dto.email },
});
if (!user) {
logger.warn({ email: dto.email }, 'Login failed: user not found');
throw new AuthenticationError('Invalid credentials');
}
// Check if user is active
if (!user.isActive) {
logger.warn({ userId: user.id }, 'Login failed: user is inactive');
throw new AuthenticationError('Account is inactive');
}
// Verify password
const passwordValid = await this.comparePassword(dto.password, user.passwordHash);
if (!passwordValid) {
logger.warn({ userId: user.id }, 'Login failed: invalid password');
throw new AuthenticationError('Invalid credentials');
}
// Update last login
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Clean up old refresh tokens and generate new tokens
await this.cleanupUserRefreshTokens(user.id);
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
const expiresIn = this.getAccessTokenExpirationSeconds();
const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();
logger.info({ userId: user.id, email: user.email }, 'User logged in successfully');
return {
user: {
id: user.id,
email: user.email,
username: user.username,
isActive: user.isActive,
lastLoginAt: updatedUser.lastLoginAt,
},
token: accessToken,
refreshToken,
expiresIn,
refreshTokenExpiresIn,
};
}
/**
* Get user profile by ID
*
* @throws {NotFoundError} If user doesn't exist
*/
async getProfile(userId: string): Promise<UserProfile> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
isActive: true,
telegramChatId: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
},
});
if (!user) {
logger.warn({ userId }, 'Profile not found');
throw new NotFoundError('User');
}
return user;
}
/**
* Verify JWT token and return payload
*
* @throws {AuthenticationError} If token is invalid or expired
*/
verifyToken(token: string): JwtPayload {
try {
const decoded = jwt.verify(token, this.getJwtSecret()) as JwtPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.warn({ reason: 'token expired' }, 'Token verification failed');
throw new AuthenticationError('Token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
logger.warn({ reason: 'invalid token' }, 'Token verification failed');
throw new AuthenticationError('Invalid token');
}
logger.error({ error }, 'Unexpected token verification error');
throw new AuthenticationError('Token verification failed');
}
}
/**
* Logout user by blacklisting access token and revoking refresh tokens
*
* @param accessToken - The JWT access token to blacklist
* @param refreshToken - Optional refresh token to revoke
*/
async logout(accessToken: string, refreshToken?: string): Promise<void> {
// Blacklist access token in Redis (expires after 15 min)
await blacklistToken(accessToken);
// If refresh token provided, revoke it in database
if (refreshToken) {
try {
const { tokenId } = await this.verifyRefreshToken(refreshToken);
await this.revokeRefreshToken(tokenId);
} catch (error) {
// If refresh token is invalid, we still consider logout successful
logger.debug({ error }, 'Refresh token already invalid during logout');
}
}
logger.info({ tokenPrefix: accessToken.substring(0, 20) }, 'User logged out');
}
/**
* Refresh access token using refresh token
*
* Validates refresh token, generates new access token, optionally rotates refresh token
*
* @param refreshTokenString - The refresh token provided by client
* @returns New access token and optionally new refresh token
* @throws {AuthenticationError} If refresh token is invalid or expired
*/
async refreshAccessToken(refreshTokenString: string): Promise<RefreshTokenResponse> {
logger.info({ tokenPrefix: refreshTokenString.substring(0, 10) }, 'Token refresh attempt');
// Verify the refresh token
const { userId, tokenId } = await this.verifyRefreshToken(refreshTokenString);
// Get user to ensure they still exist and are active
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
},
});
if (!user || !user.isActive) {
// Revoke the refresh token if user is inactive/deleted
await this.revokeRefreshToken(tokenId);
throw new AuthenticationError('User account is inactive or deleted');
}
// Generate new access token
const accessToken = this.generateAccessToken(user);
// Optionally rotate refresh token (revoke old, create new)
// This provides better security by detecting potential token reuse attacks
await this.revokeRefreshToken(tokenId);
const newRefreshToken = await this.createRefreshToken(userId);
const expiresIn = this.getAccessTokenExpirationSeconds();
const refreshTokenExpiresIn = this.getRefreshTokenExpirationSeconds();
logger.info({ userId }, 'Access token refreshed successfully');
return {
token: accessToken,
refreshToken: newRefreshToken,
expiresIn,
refreshTokenExpiresIn,
};
}
/**
* Check if token is blacklisted in Redis
*/
async isTokenBlacklisted(token: string): Promise<boolean> {
return await checkTokenBlacklisted(token);
}
/**
* Request password reset
* Generates a reset token, saves it in DB, and sends via Telegram
*/
async requestPasswordReset(email: string): Promise<void> {
logger.info({ email }, 'Password reset requested');
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
username: true,
telegramChatId: true,
},
});
if (!user) {
// Don't reveal if email exists or not - log but return success
logger.info({ email }, 'Password reset requested for non-existent email');
return;
}
// Invalidate any existing unused tokens for this user
await prisma.passwordResetToken.updateMany({
where: {
userId: user.id,
used: false,
},
data: {
used: true, // Mark as used to invalidate
},
});
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString('hex');
// Calculate expiration time
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + PASSWORD_RESET_TOKEN_EXPIRES_HOURS);
// Save token in database
await prisma.passwordResetToken.create({
data: {
token: resetToken,
userId: user.id,
expiresAt,
},
});
logger.info({ userId: user.id }, 'Password reset token created');
// Send reset token via Telegram
await this.sendPasswordResetToken(user, resetToken);
}
/**
* Send password reset token via Telegram
* If user has telegramChatId, send directly. Otherwise, send to admin for forwarding.
*/
private async sendPasswordResetToken(
user: { id: string; email: string; username: string; telegramChatId: string | null },
token: string
): Promise<void> {
if (!isTelegramEnabled()) {
logger.warn({ userId: user.id }, 'Telegram disabled - password reset token cannot be sent');
return;
}
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
const message = `
🔐 <b>Password Reset Request</b>
User: <b>${user.username}</b> (${user.email})
Reset token: <code>${token}</code>
Reset URL: ${resetUrl}
⏰ This token expires in <b>${PASSWORD_RESET_TOKEN_EXPIRES_HOURS} hour</b>.
`;
try {
// If user has Telegram chat ID, send directly
if (user.telegramChatId) {
await telegramClient.getClient().sendMessage(user.telegramChatId, message, {
parseMode: 'HTML',
disableWebPagePreview: true,
});
logger.info({ userId: user.id, chatId: user.telegramChatId }, 'Password reset token sent to user via Telegram');
} else {
// Send to admin who can forward to the user
await telegramClient.sendToAdmin(message);
logger.info({ userId: user.id }, 'Password reset token sent to admin (user has no Telegram chat ID)');
}
} catch (error) {
logger.error({ error, userId: user.id }, 'Failed to send password reset token via Telegram');
// Don't throw - we don't want to reveal if the operation failed
}
}
/**
* Reset password with token
* Verifies token validity, updates password, and marks token as used
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
logger.info({ tokenPrefix: token.substring(0, 10) }, 'Password reset attempt');
// Find the reset token
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token },
include: {
user: {
select: {
id: true,
email: true,
username: true,
},
},
},
});
if (!resetToken) {
logger.warn({ tokenPrefix: token.substring(0, 10) }, 'Password reset token not found');
throw new ValidationError('Invalid or expired reset token');
}
// Check if token has been used
if (resetToken.used) {
logger.warn({ tokenId: resetToken.id }, 'Password reset token already used');
throw new ValidationError('Reset token has already been used');
}
// Check if token has expired
if (resetToken.expiresAt < new Date()) {
logger.warn({ tokenId: resetToken.id, expiresAt: resetToken.expiresAt }, 'Password reset token expired');
throw new ValidationError('Reset token has expired');
}
// Hash the new password
const passwordHash = await this.hashPassword(newPassword);
// Update user password and mark token as used
await prisma.$transaction([
prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash },
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true },
}),
]);
logger.info({ userId: resetToken.userId }, 'Password reset successful');
// Notify user via Telegram about password change
await this.notifyPasswordChanged(resetToken.user);
}
/**
* Notify user about password change via Telegram
*/
private async notifyPasswordChanged(
user: { id: string; email: string; username: string }
): Promise<void> {
if (!isTelegramEnabled()) {
return;
}
// Get user's Telegram chat ID
const fullUser = await prisma.user.findUnique({
where: { id: user.id },
select: { telegramChatId: true },
});
const message = `
✅ <b>Password Changed Successfully</b>
Your password for <b>${user.username}</b> has been reset.
If you did not request this change, please contact support immediately.
`;
try {
if (fullUser?.telegramChatId) {
await telegramClient.getClient().sendMessage(fullUser.telegramChatId, message, {
parseMode: 'HTML',
});
logger.info({ userId: user.id }, 'Password change notification sent to user');
} else {
// Notify admin
await telegramClient.sendToAdmin(`
✅ <b>Password Changed</b>
User: <b>${user.username}</b> (${user.email})
Password has been reset successfully.
Please forward this message to the user if needed.
`);
logger.info({ userId: user.id }, 'Password change notification sent to admin');
}
} catch (error) {
logger.error({ error, userId: user.id }, 'Failed to send password change notification');
}
}
}
// Export singleton instance
export const authService = new AuthService();

View File

@@ -0,0 +1,23 @@
/**
* Auth DTOs
*
* Export all authentication data transfer objects
*/
export * from './register.dto';
export * from './login.dto';
export * from './refresh.dto';
import type { UserRole } from '@prisma/client';
/**
* JWT Payload interface
*/
export interface JwtPayload {
userId: string;
email: string;
username: string;
role?: UserRole;
iat?: number;
exp?: number;
}

View File

@@ -0,0 +1,21 @@
/**
* Login DTO
*
* Validation schema for user login
*/
import { z } from 'zod';
export const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format')
.toLowerCase()
.trim(),
password: z
.string()
.min(1, 'Password is required'),
});
export type LoginDto = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,15 @@
/**
* Refresh Token DTO
*
* Validation schema for token refresh
*/
import { z } from 'zod';
export const refreshTokenSchema = z.object({
refreshToken: z
.string()
.min(1, 'Refresh token is required'),
});
export type RefreshTokenDto = z.infer<typeof refreshTokenSchema>;

View File

@@ -0,0 +1,47 @@
/**
* Register DTO
*
* Validation schema for user registration
*/
import { z } from 'zod';
/**
* Password requirements:
* - Minimum 8 characters
* - At least one uppercase letter
* - At least one lowercase letter
* - At least one number
* - At least one special character
*/
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$/;
/**
* Username requirements:
* - 3-20 characters
* - Alphanumeric and underscores only
* - Must start with a letter
*/
const usernameRegex = /^[a-zA-Z][a-zA-Z0-9_]{2,19}$/;
export const registerSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format')
.toLowerCase()
.trim(),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(usernameRegex, 'Username must start with a letter and contain only letters, numbers, and underscores')
.trim(),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password must not exceed 128 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'),
});
export type RegisterDto = z.infer<typeof registerSchema>;

View File

@@ -0,0 +1,10 @@
/**
* Authentication Module
*
* Exports all authentication-related functionality
*/
export * from './auth.service';
export * from './auth.controller';
export * from './auth.routes';
export * from './dtos';