From 158a617600938ccdc2033166325cbfea887f2cb7 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sat, 20 Dec 2025 19:22:30 +0530 Subject: [PATCH] feat: Implement comprehensive user authentication with JWT, social login, and password management. --- src/app.controller.ts | 2 + src/app.module.ts | 19 +- src/auth/auth.controller.ts | 308 ++++++++ src/auth/auth.module.ts | 33 + src/auth/auth.service.ts | 657 ++++++++++++++++++ src/auth/decorators/current-user.decorator.ts | 14 + src/auth/decorators/index.ts | 3 + src/auth/decorators/public.decorator.ts | 4 + src/auth/decorators/roles.decorator.ts | 5 + src/auth/dto/forgot-password.dto.ts | 11 + src/auth/dto/index.ts | 6 + src/auth/dto/login.dto.ts | 19 + src/auth/dto/refresh-token.dto.ts | 11 + src/auth/dto/register.dto.ts | 65 ++ src/auth/dto/reset-password.dto.ts | 28 + src/auth/dto/social-auth.dto.ts | 60 ++ src/auth/guards/index.ts | 2 + src/auth/guards/jwt-auth.guard.ts | 36 + src/auth/guards/roles.guard.ts | 41 ++ src/auth/index.ts | 7 + src/auth/strategies/index.ts | 1 + src/auth/strategies/jwt.strategy.ts | 60 ++ src/users/index.ts | 3 + src/users/users.controller.ts | 117 ++++ src/users/users.module.ts | 12 + src/users/users.service.ts | 88 +++ 26 files changed, 1608 insertions(+), 4 deletions(-) create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/decorators/current-user.decorator.ts create mode 100644 src/auth/decorators/index.ts create mode 100644 src/auth/decorators/public.decorator.ts create mode 100644 src/auth/decorators/roles.decorator.ts create mode 100644 src/auth/dto/forgot-password.dto.ts create mode 100644 src/auth/dto/index.ts create mode 100644 src/auth/dto/login.dto.ts create mode 100644 src/auth/dto/refresh-token.dto.ts create mode 100644 src/auth/dto/register.dto.ts create mode 100644 src/auth/dto/reset-password.dto.ts create mode 100644 src/auth/dto/social-auth.dto.ts create mode 100644 src/auth/guards/index.ts create mode 100644 src/auth/guards/jwt-auth.guard.ts create mode 100644 src/auth/guards/roles.guard.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/strategies/index.ts create mode 100644 src/auth/strategies/jwt.strategy.ts create mode 100644 src/users/index.ts create mode 100644 src/users/users.controller.ts create mode 100644 src/users/users.module.ts create mode 100644 src/users/users.service.ts diff --git a/src/app.controller.ts b/src/app.controller.ts index 7a58cb1..df35634 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AppService } from './app.service'; +import { Public } from './auth'; @ApiTags('Health') @Controller() +@Public() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/src/app.module.ts b/src/app.module.ts index 5c43f9f..4b51ca1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { configuration } from './config'; import { PrismaModule } from './prisma'; +import { AuthModule, JwtAuthGuard, RolesGuard } from './auth'; +import { UsersModule } from './users'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -36,10 +38,9 @@ import { AppService } from './app.service'; // Database PrismaModule, - // Feature Modules (will be added as we develop them) - // AuthModule, - // UsersModule, - // AgentsModule, + // Feature Modules + AuthModule, + UsersModule, ], controllers: [AppController], providers: [ @@ -49,6 +50,16 @@ import { AppService } from './app.service'; provide: APP_GUARD, useClass: ThrottlerGuard, }, + // Global JWT Auth Guard + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + // Global Roles Guard + { + provide: APP_GUARD, + useClass: RolesGuard, + }, ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..147f9cb --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,308 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + Req, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiBody, +} from '@nestjs/swagger'; +import type { Request } from 'express'; + +import { AuthService } from './auth.service'; +import { + RegisterDto, + LoginDto, + SocialAuthDto, + ForgotPasswordDto, + ResetPasswordDto, + RefreshTokenDto, +} from './dto'; +import { JwtAuthGuard } from './guards'; +import { Public, CurrentUser } from './decorators'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + // ========================================== + // REGISTER + // ========================================== + @Public() + @Post('register') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Register a new user' }) + @ApiBody({ type: RegisterDto }) + @ApiResponse({ + status: 201, + description: 'User registered successfully', + schema: { + example: { + success: true, + data: { + user: { + id: 'uuid', + email: 'john@example.com', + role: 'USER', + firstName: 'John', + lastName: 'Doe', + }, + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 409, description: 'Email already registered' }) + async register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + // ========================================== + // LOGIN + // ========================================== + @Public() + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login with email and password' }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ + status: 200, + description: 'Login successful', + schema: { + example: { + success: true, + data: { + user: { + id: 'uuid', + email: 'john@example.com', + role: 'USER', + firstName: 'John', + lastName: 'Doe', + avatar: null, + }, + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + async login(@Body() dto: LoginDto, @Req() req: Request) { + const userAgent = req.headers['user-agent']; + const ipAddress = + req.ip || req.headers['x-forwarded-for']?.toString() || 'unknown'; + + return this.authService.login(dto, userAgent, ipAddress); + } + + // ========================================== + // SOCIAL AUTH + // ========================================== + @Public() + @Post('social') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login or register with social provider' }) + @ApiBody({ type: SocialAuthDto }) + @ApiResponse({ + status: 200, + description: 'Social auth successful', + schema: { + example: { + success: true, + data: { + user: { + id: 'uuid', + email: 'john@example.com', + role: 'USER', + firstName: 'John', + lastName: 'Doe', + avatar: 'https://example.com/avatar.jpg', + }, + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid provider data' }) + async socialAuth(@Body() dto: SocialAuthDto) { + return this.authService.socialAuth(dto); + } + + // ========================================== + // FORGOT PASSWORD + // ========================================== + @Public() + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request password reset email' }) + @ApiBody({ type: ForgotPasswordDto }) + @ApiResponse({ + status: 200, + description: 'Password reset email sent (if email exists)', + schema: { + example: { + success: true, + data: { + message: 'If the email exists, a password reset link will be sent', + }, + }, + }, + }) + async forgotPassword(@Body() dto: ForgotPasswordDto) { + return this.authService.forgotPassword(dto); + } + + // ========================================== + // RESET PASSWORD + // ========================================== + @Public() + @Post('reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset password with token' }) + @ApiBody({ type: ResetPasswordDto }) + @ApiResponse({ + status: 200, + description: 'Password reset successful', + schema: { + example: { + success: true, + data: { + message: 'Password reset successful. Please login with your new password.', + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword(@Body() dto: ResetPasswordDto) { + return this.authService.resetPassword(dto); + } + + // ========================================== + // REFRESH TOKEN + // ========================================== + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token' }) + @ApiBody({ type: RefreshTokenDto }) + @ApiResponse({ + status: 200, + description: 'Tokens refreshed successfully', + schema: { + example: { + success: true, + data: { + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Invalid refresh token' }) + async refreshToken(@Body() dto: RefreshTokenDto) { + return this.authService.refreshToken(dto); + } + + // ========================================== + // GET CURRENT USER + // ========================================== + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ + status: 200, + description: 'Current user profile', + schema: { + example: { + success: true, + data: { + id: 'uuid', + email: 'john@example.com', + role: 'USER', + status: 'ACTIVE', + emailVerified: true, + authProvider: 'LOCAL', + firstName: 'John', + lastName: 'Doe', + avatar: null, + phone: null, + city: null, + state: null, + country: null, + createdAt: '2024-01-01T00:00:00.000Z', + lastLoginAt: '2024-01-01T00:00:00.000Z', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getCurrentUser(@CurrentUser('id') userId: string) { + return this.authService.getCurrentUser(userId); + } + + // ========================================== + // LOGOUT + // ========================================== + @Post('logout') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Logout current session' }) + @ApiResponse({ + status: 200, + description: 'Logged out successfully', + schema: { + example: { + success: true, + data: { + message: 'Logged out successfully', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async logout(@CurrentUser('id') userId: string, @Req() req: Request) { + const authHeader = req.headers.authorization; + const accessToken = authHeader?.replace('Bearer ', ''); + + return this.authService.logout(userId, accessToken); + } + + // ========================================== + // LOGOUT ALL SESSIONS + // ========================================== + @Post('logout-all') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Logout all sessions' }) + @ApiResponse({ + status: 200, + description: 'All sessions logged out', + schema: { + example: { + success: true, + data: { + message: 'Logged out successfully', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async logoutAll(@CurrentUser('id') userId: string) { + return this.authService.logout(userId); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..d63792f --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies'; +import { PrismaModule } from '../prisma'; + +@Module({ + imports: [ + PrismaModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService): JwtModuleOptions => { + const expiresIn = configService.get('jwt.accessExpiration') ?? '15m'; + return { + secret: configService.get('jwt.secret'), + signOptions: { + expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`, + }, + }; + }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtModule, PassportModule], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..ddd81b2 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,657 @@ +import { + Injectable, + UnauthorizedException, + ConflictException, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as argon2 from 'argon2'; +import slugify from 'slugify'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + RegisterDto, + LoginDto, + SocialAuthDto, + ForgotPasswordDto, + ResetPasswordDto, + RefreshTokenDto, +} from './dto'; +import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly eventEmitter: EventEmitter2, + ) {} + + // ========================================== + // REGISTER + // ========================================== + async register(dto: RegisterDto) { + // Check if user exists + const existingUser = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + }); + + if (existingUser) { + throw new ConflictException('Email already registered'); + } + + // Hash password + const hashedPassword = await argon2.hash(dto.password); + + // Determine role + const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER; + + // Create user + const user = await this.prisma.user.create({ + data: { + email: dto.email.toLowerCase(), + password: hashedPassword, + role, + status: UserStatus.ACTIVE, + authProvider: AuthProvider.LOCAL, + }, + }); + + // Create profile based on role + if (role === UserRole.USER) { + await this.prisma.userProfile.create({ + data: { + userId: user.id, + firstName: dto.firstName, + lastName: dto.lastName, + }, + }); + } else if (role === UserRole.AGENT) { + const slug = await this.generateUniqueSlug(dto.firstName, dto.lastName); + await this.prisma.agentProfile.create({ + data: { + userId: user.id, + slug, + firstName: dto.firstName, + lastName: dto.lastName, + }, + }); + } + + // Generate tokens + const tokens = await this.generateTokens(user.id, user.email, user.role); + + // Create session + await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); + + // Emit event for email verification (can be handled by email service) + this.eventEmitter.emit('user.registered', { + userId: user.id, + email: user.email, + role: user.role, + }); + + return { + user: { + id: user.id, + email: user.email, + role: user.role, + firstName: dto.firstName, + lastName: dto.lastName, + }, + ...tokens, + }; + } + + // ========================================== + // LOGIN + // ========================================== + async login(dto: LoginDto, userAgent?: string, ipAddress?: string) { + const user = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('Invalid email or password'); + } + + if (!user.password) { + throw new UnauthorizedException( + 'This account uses social login. Please sign in with Google or Facebook.', + ); + } + + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('Account is not active'); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.password, dto.password); + + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid email or password'); + } + + // Generate tokens + const tokens = await this.generateTokens(user.id, user.email, user.role); + + // Create session + await this.createSession( + user.id, + tokens.accessToken, + tokens.refreshToken, + userAgent, + ipAddress, + ); + + // Update last login + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + // Get profile based on role + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + return { + user: { + id: user.id, + email: user.email, + role: user.role, + firstName: profile?.firstName, + lastName: profile?.lastName, + avatar: profile?.avatar, + }, + ...tokens, + }; + } + + // ========================================== + // SOCIAL AUTH + // ========================================== + async socialAuth(dto: SocialAuthDto) { + const providerIdField = + dto.provider === 'google' ? 'googleId' : 'facebookId'; + + // Check if user exists with this provider ID + let user = await this.prisma.user.findFirst({ + where: { [providerIdField]: dto.providerId }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (!user) { + // Check if user exists with this email + user = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (user) { + // Link provider to existing account + await this.prisma.user.update({ + where: { id: user.id }, + data: { [providerIdField]: dto.providerId }, + }); + } else { + // Create new user + const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER; + const authProvider = + dto.provider === 'google' + ? AuthProvider.GOOGLE + : AuthProvider.FACEBOOK; + + // Parse name + const nameParts = dto.name?.split(' ') || []; + const firstName = nameParts[0] || ''; + const lastName = nameParts.slice(1).join(' ') || ''; + + user = await this.prisma.user.create({ + data: { + email: dto.email.toLowerCase(), + role, + status: UserStatus.ACTIVE, + emailVerified: true, + emailVerifiedAt: new Date(), + authProvider, + [providerIdField]: dto.providerId, + }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + // Create profile based on role + if (role === UserRole.USER) { + await this.prisma.userProfile.create({ + data: { + userId: user.id, + firstName, + lastName, + avatar: dto.avatar, + }, + }); + } else if (role === UserRole.AGENT) { + const slug = await this.generateUniqueSlug(firstName, lastName); + await this.prisma.agentProfile.create({ + data: { + userId: user.id, + slug, + firstName, + lastName, + avatar: dto.avatar, + }, + }); + } + + // Refetch user with profile + user = await this.prisma.user.findUnique({ + where: { id: user.id }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + } + } + + if (!user) { + throw new BadRequestException('Failed to create or find user'); + } + + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('Account is not active'); + } + + // Generate tokens + const tokens = await this.generateTokens(user.id, user.email, user.role); + + // Create session + await this.createSession(user.id, tokens.accessToken, tokens.refreshToken); + + // Update last login + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + return { + user: { + id: user.id, + email: user.email, + role: user.role, + firstName: profile?.firstName, + lastName: profile?.lastName, + avatar: profile?.avatar, + }, + ...tokens, + }; + } + + // ========================================== + // FORGOT PASSWORD + // ========================================== + async forgotPassword(dto: ForgotPasswordDto) { + const user = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + }); + + // Always return success to prevent email enumeration + if (!user) { + return { + message: 'If the email exists, a password reset link will be sent', + }; + } + + if (!user.password) { + return { + message: 'If the email exists, a password reset link will be sent', + }; + } + + // Generate reset token (short-lived JWT) + const resetToken = this.jwtService.sign( + { sub: user.id, email: user.email, type: 'password-reset' }, + { + secret: this.configService.get('jwt.secret'), + expiresIn: '1h', + }, + ); + + // Emit event for email service + this.eventEmitter.emit('password.reset-requested', { + userId: user.id, + email: user.email, + resetToken, + resetUrl: `${this.configService.get('app.frontendUrl')}/reset-password?token=${resetToken}`, + }); + + return { + message: 'If the email exists, a password reset link will be sent', + }; + } + + // ========================================== + // RESET PASSWORD + // ========================================== + async resetPassword(dto: ResetPasswordDto) { + try { + const payload = this.jwtService.verify(dto.token, { + secret: this.configService.get('jwt.secret'), + }); + + if (payload.type !== 'password-reset') { + throw new BadRequestException('Invalid reset token'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Hash new password + const hashedPassword = await argon2.hash(dto.password); + + // Update password + await this.prisma.user.update({ + where: { id: user.id }, + data: { password: hashedPassword }, + }); + + // Invalidate all existing sessions + await this.prisma.session.deleteMany({ + where: { userId: user.id }, + }); + + // Emit event + this.eventEmitter.emit('password.reset-completed', { + userId: user.id, + email: user.email, + }); + + return { + message: 'Password reset successful. Please login with your new password.', + }; + } catch (error) { + if (error.name === 'TokenExpiredError') { + throw new BadRequestException('Reset token has expired'); + } + if (error.name === 'JsonWebTokenError') { + throw new BadRequestException('Invalid reset token'); + } + throw error; + } + } + + // ========================================== + // REFRESH TOKEN + // ========================================== + async refreshToken(dto: RefreshTokenDto) { + try { + const payload = this.jwtService.verify(dto.refreshToken, { + secret: this.configService.get('jwt.secret'), + }); + + // Check if session exists + const session = await this.prisma.session.findFirst({ + where: { + userId: payload.sub, + refreshToken: dto.refreshToken, + }, + }); + + if (!session) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // Get user + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + + if (!user || user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('User not found or inactive'); + } + + // Generate new tokens + const tokens = await this.generateTokens(user.id, user.email, user.role); + + // Update session + await this.prisma.session.update({ + where: { id: session.id }, + data: { + token: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: new Date( + Date.now() + + this.parseExpiration( + this.configService.get('jwt.refreshExpiration') || '7d', + ), + ), + }, + }); + + return tokens; + } catch (error) { + if ( + error.name === 'TokenExpiredError' || + error.name === 'JsonWebTokenError' + ) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + throw error; + } + } + + // ========================================== + // LOGOUT + // ========================================== + async logout(userId: string, accessToken?: string) { + if (accessToken) { + // Delete specific session + await this.prisma.session.deleteMany({ + where: { + userId, + token: accessToken, + }, + }); + } else { + // Delete all sessions for user + await this.prisma.session.deleteMany({ + where: { userId }, + }); + } + + return { message: 'Logged out successfully' }; + } + + // ========================================== + // GET CURRENT USER + // ========================================== + async getCurrentUser(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + userProfile: true, + agentProfile: true, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + return { + id: user.id, + email: user.email, + role: user.role, + status: user.status, + emailVerified: user.emailVerified, + authProvider: user.authProvider, + firstName: profile?.firstName, + lastName: profile?.lastName, + avatar: profile?.avatar, + phone: profile?.phone, + city: profile?.city, + state: profile?.state, + country: profile?.country, + ...(user.role === UserRole.AGENT && user.agentProfile + ? { + slug: user.agentProfile.slug, + bio: user.agentProfile.bio, + headline: user.agentProfile.headline, + yearsOfExperience: user.agentProfile.yearsOfExperience, + licenseNumber: user.agentProfile.licenseNumber, + companyName: user.agentProfile.companyName, + isProfileComplete: user.agentProfile.isProfileComplete, + profileCompleteness: user.agentProfile.profileCompleteness, + } + : {}), + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + }; + } + + // ========================================== + // HELPER METHODS + // ========================================== + private async generateTokens(userId: string, email: string, role: UserRole) { + const payload = { sub: userId, email, role }; + const secret = this.configService.get('jwt.secret'); + const accessExpiresIn = this.parseExpirationToSeconds( + this.configService.get('jwt.accessExpiration') || '15m', + ); + const refreshExpiresIn = this.parseExpirationToSeconds( + this.configService.get('jwt.refreshExpiration') || '7d', + ); + + const accessToken = this.jwtService.sign(payload, { + secret, + expiresIn: accessExpiresIn, + }); + + const refreshToken = this.jwtService.sign(payload, { + secret, + expiresIn: refreshExpiresIn, + }); + + return { accessToken, refreshToken }; + } + + private parseExpirationToSeconds(expiration: string): number { + const match = expiration.match(/^(\d+)([smhd])$/); + if (!match) return 900; // Default 15 minutes + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 60 * 60; + case 'd': + return value * 24 * 60 * 60; + default: + return 900; + } + } + + private async createSession( + userId: string, + accessToken: string, + refreshToken: string, + userAgent?: string, + ipAddress?: string, + ) { + const expiresAt = new Date( + Date.now() + + this.parseExpiration( + this.configService.get('jwt.refreshExpiration') || '7d', + ), + ); + + await this.prisma.session.create({ + data: { + userId, + token: accessToken, + refreshToken, + userAgent, + ipAddress, + expiresAt, + }, + }); + } + + private parseExpiration(expiration: string): number { + const match = expiration.match(/^(\d+)([smhd])$/); + if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + return 7 * 24 * 60 * 60 * 1000; + } + } + + private async generateUniqueSlug( + firstName?: string, + lastName?: string, + ): Promise { + const baseName = `${firstName || ''} ${lastName || ''}`.trim() || 'agent'; + let slug = slugify(baseName, { lower: true, strict: true }); + + // Check if slug exists + let counter = 0; + let uniqueSlug = slug; + + while (true) { + const existing = await this.prisma.agentProfile.findUnique({ + where: { slug: uniqueSlug }, + }); + + if (!existing) break; + + counter++; + uniqueSlug = `${slug}-${counter}`; + } + + return uniqueSlug; + } +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..4e21d76 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,14 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + return null; + } + + return data ? user[data] : user; + }, +); diff --git a/src/auth/decorators/index.ts b/src/auth/decorators/index.ts new file mode 100644 index 0000000..8c09df0 --- /dev/null +++ b/src/auth/decorators/index.ts @@ -0,0 +1,3 @@ +export * from './public.decorator'; +export * from './roles.decorator'; +export * from './current-user.decorator'; diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..ec0c377 --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '@prisma/client'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/dto/forgot-password.dto.ts b/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..4932734 --- /dev/null +++ b/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ForgotPasswordDto { + @ApiProperty({ + example: 'john@example.com', + description: 'Email address to send password reset link', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + email: string; +} diff --git a/src/auth/dto/index.ts b/src/auth/dto/index.ts new file mode 100644 index 0000000..5cff2b0 --- /dev/null +++ b/src/auth/dto/index.ts @@ -0,0 +1,6 @@ +export * from './register.dto'; +export * from './login.dto'; +export * from './social-auth.dto'; +export * from './forgot-password.dto'; +export * from './reset-password.dto'; +export * from './refresh-token.dto'; diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..2d23661 --- /dev/null +++ b/src/auth/dto/login.dto.ts @@ -0,0 +1,19 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ + example: 'john@example.com', + description: 'User email address', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + email: string; + + @ApiProperty({ + example: 'Password@123', + description: 'User password', + }) + @IsString() + @MinLength(1, { message: 'Password is required' }) + password: string; +} diff --git a/src/auth/dto/refresh-token.dto.ts b/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..3805acf --- /dev/null +++ b/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,11 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenDto { + @ApiProperty({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'Refresh token', + }) + @IsString() + refreshToken: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..afeffed --- /dev/null +++ b/src/auth/dto/register.dto.ts @@ -0,0 +1,65 @@ +import { + IsEmail, + IsString, + MinLength, + MaxLength, + Matches, + IsEnum, + IsOptional, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; + +export class RegisterDto { + @ApiProperty({ + example: 'john@example.com', + description: 'User email address', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + email: string; + + @ApiProperty({ + example: 'Password@123', + description: 'Password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)', + minLength: 8, + }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + @MaxLength(50, { message: 'Password must not exceed 50 characters' }) + @Matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, + { + message: + 'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character', + }, + ) + password: string; + + @ApiPropertyOptional({ + example: 'USER', + description: 'User role (USER or AGENT)', + enum: ['USER', 'AGENT'], + default: 'USER', + }) + @IsOptional() + @IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' }) + role?: 'USER' | 'AGENT'; + + @ApiPropertyOptional({ + example: 'John', + description: 'First name', + }) + @IsOptional() + @IsString() + @MaxLength(50) + firstName?: string; + + @ApiPropertyOptional({ + example: 'Doe', + description: 'Last name', + }) + @IsOptional() + @IsString() + @MaxLength(50) + lastName?: string; +} diff --git a/src/auth/dto/reset-password.dto.ts b/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..350eb05 --- /dev/null +++ b/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,28 @@ +import { IsString, MinLength, MaxLength, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ResetPasswordDto { + @ApiProperty({ + example: 'abc123token', + description: 'Password reset token from email', + }) + @IsString() + token: string; + + @ApiProperty({ + example: 'NewPassword@123', + description: 'New password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)', + minLength: 8, + }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + @MaxLength(50, { message: 'Password must not exceed 50 characters' }) + @Matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, + { + message: + 'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character', + }, + ) + password: string; +} diff --git a/src/auth/dto/social-auth.dto.ts b/src/auth/dto/social-auth.dto.ts new file mode 100644 index 0000000..f14ea23 --- /dev/null +++ b/src/auth/dto/social-auth.dto.ts @@ -0,0 +1,60 @@ +import { + IsEmail, + IsString, + IsEnum, + IsOptional, + IsUrl, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SocialAuthDto { + @ApiProperty({ + example: 'google', + description: 'OAuth provider', + enum: ['google', 'facebook'], + }) + @IsEnum(['google', 'facebook'], { + message: 'Provider must be either google or facebook', + }) + provider: 'google' | 'facebook'; + + @ApiProperty({ + example: '123456789', + description: 'Provider user ID', + }) + @IsString() + providerId: string; + + @ApiProperty({ + example: 'john@example.com', + description: 'User email from OAuth provider', + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + email: string; + + @ApiPropertyOptional({ + example: 'John Doe', + description: 'User name from OAuth provider', + }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ + example: 'https://example.com/avatar.jpg', + description: 'User avatar URL from OAuth provider', + }) + @IsOptional() + @IsUrl({}, { message: 'Avatar must be a valid URL' }) + avatar?: string; + + @ApiPropertyOptional({ + example: 'USER', + description: 'User role (USER or AGENT)', + enum: ['USER', 'AGENT'], + default: 'USER', + }) + @IsOptional() + @IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' }) + role?: 'USER' | 'AGENT'; +} diff --git a/src/auth/guards/index.ts b/src/auth/guards/index.ts new file mode 100644 index 0000000..e174be2 --- /dev/null +++ b/src/auth/guards/index.ts @@ -0,0 +1,2 @@ +export * from './jwt-auth.guard'; +export * from './roles.guard'; diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..3bc9480 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,36 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + // Check if route is marked as public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Invalid or expired token'); + } + return user; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..4158696 --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,41 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { UserRole } from '@prisma/client'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user) { + throw new ForbiddenException('Access denied'); + } + + const hasRole = requiredRoles.some((role) => user.role === role); + + if (!hasRole) { + throw new ForbiddenException( + `Access denied. Required roles: ${requiredRoles.join(', ')}`, + ); + } + + return true; + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..c886b5c --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,7 @@ +export * from './auth.module'; +export * from './auth.service'; +export * from './auth.controller'; +export * from './dto'; +export * from './guards'; +export * from './strategies'; +export * from './decorators'; diff --git a/src/auth/strategies/index.ts b/src/auth/strategies/index.ts new file mode 100644 index 0000000..e7586f7 --- /dev/null +++ b/src/auth/strategies/index.ts @@ -0,0 +1 @@ +export * from './jwt.strategy'; diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..2d45023 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,60 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface JwtPayload { + sub: string; + email: string; + role: string; + iat?: number; + exp?: number; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) { + const secret = configService.get('jwt.secret'); + if (!secret) { + throw new Error('JWT_SECRET is not configured'); + } + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: secret, + }); + } + + async validate(payload: JwtPayload) { + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { + id: true, + email: true, + role: true, + status: true, + emailVerified: true, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + if (user.status !== 'ACTIVE') { + throw new UnauthorizedException('Account is not active'); + } + + return { + id: user.id, + email: user.email, + role: user.role, + status: user.status, + emailVerified: user.emailVerified, + }; + } +} diff --git a/src/users/index.ts b/src/users/index.ts new file mode 100644 index 0000000..4757dcb --- /dev/null +++ b/src/users/index.ts @@ -0,0 +1,3 @@ +export * from './users.module'; +export * from './users.service'; +export * from './users.controller'; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..8ae70dd --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,117 @@ +import { + Controller, + Get, + Param, + Patch, + Body, + Query, + UseGuards, + NotFoundException, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { JwtAuthGuard, RolesGuard } from '../auth/guards'; +import { Roles } from '../auth/decorators'; +import { UserRole, UserStatus } from '@prisma/client'; + +@ApiTags('Users') +@Controller('users') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth('JWT-auth') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get all users (Admin only)' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ + status: 200, + description: 'List of users', + schema: { + example: { + success: true, + data: { + users: [ + { + id: 'uuid', + email: 'user@example.com', + role: 'USER', + status: 'ACTIVE', + emailVerified: true, + authProvider: 'LOCAL', + createdAt: '2024-01-01T00:00:00.000Z', + lastLoginAt: '2024-01-01T00:00:00.000Z', + profile: { + firstName: 'John', + lastName: 'Doe', + avatar: null, + phone: null, + city: null, + state: null, + country: null, + }, + }, + ], + total: 100, + page: 1, + limit: 10, + totalPages: 10, + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin only' }) + async findAll( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const limitNum = limit ? parseInt(limit, 10) : 10; + + return this.usersService.findAll(pageNum, limitNum); + } + + @Get(':id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get user by ID (Admin only)' }) + @ApiResponse({ + status: 200, + description: 'User details', + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async findOne(@Param('id') id: string) { + const user = await this.usersService.findOne(id); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + @Patch(':id/status') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update user status (Admin only)' }) + @ApiResponse({ + status: 200, + description: 'User status updated', + }) + async updateStatus( + @Param('id') id: string, + @Body('status') status: UserStatus, + ) { + const user = await this.usersService.updateStatus(id, status); + return { + id: user.id, + status: user.status, + message: 'User status updated successfully', + }; + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..02bde50 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..a24b14e --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { UserStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +type UserWithProfile = Prisma.UserGetPayload<{ + include: { userProfile: true }; +}>; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(page = 1, limit = 10) { + const skip = (page - 1) * limit; + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + userProfile: true, + }, + }) as Promise, + this.prisma.user.count(), + ]); + + return { + users: users.map((user) => ({ + id: user.id, + email: user.email, + role: user.role, + status: user.status, + emailVerified: user.emailVerified, + authProvider: user.authProvider, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + profile: user.userProfile + ? { + firstName: user.userProfile.firstName, + lastName: user.userProfile.lastName, + avatar: user.userProfile.avatar, + phone: user.userProfile.phone, + city: user.userProfile.city, + state: user.userProfile.state, + country: user.userProfile.country, + } + : null, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(id: string) { + const user = (await this.prisma.user.findUnique({ + where: { id }, + include: { + userProfile: true, + }, + })) as UserWithProfile | null; + + if (!user) { + return null; + } + + return { + id: user.id, + email: user.email, + role: user.role, + status: user.status, + emailVerified: user.emailVerified, + authProvider: user.authProvider, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + profile: user.userProfile, + }; + } + + async updateStatus(id: string, status: UserStatus) { + return this.prisma.user.update({ + where: { id }, + data: { status }, + }); + } +}