🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ 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:
211
backend/src/modules/auth/auth.controller.ts
Normal file
211
backend/src/modules/auth/auth.controller.ts
Normal 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();
|
||||
166
backend/src/modules/auth/auth.routes.ts
Normal file
166
backend/src/modules/auth/auth.routes.ts
Normal 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();
|
||||
750
backend/src/modules/auth/auth.service.ts
Normal file
750
backend/src/modules/auth/auth.service.ts
Normal 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();
|
||||
23
backend/src/modules/auth/dtos/index.ts
Normal file
23
backend/src/modules/auth/dtos/index.ts
Normal 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;
|
||||
}
|
||||
21
backend/src/modules/auth/dtos/login.dto.ts
Normal file
21
backend/src/modules/auth/dtos/login.dto.ts
Normal 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>;
|
||||
15
backend/src/modules/auth/dtos/refresh.dto.ts
Normal file
15
backend/src/modules/auth/dtos/refresh.dto.ts
Normal 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>;
|
||||
47
backend/src/modules/auth/dtos/register.dto.ts
Normal file
47
backend/src/modules/auth/dtos/register.dto.ts
Normal 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>;
|
||||
10
backend/src/modules/auth/index.ts
Normal file
10
backend/src/modules/auth/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user