feat: Implement comprehensive user authentication with JWT, social login, and password management.
This commit is contained in:
308
src/auth/auth.controller.ts
Normal file
308
src/auth/auth.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
33
src/auth/auth.module.ts
Normal file
33
src/auth/auth.module.ts
Normal file
@@ -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<string>('jwt.accessExpiration') ?? '15m';
|
||||
return {
|
||||
secret: configService.get<string>('jwt.secret'),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
657
src/auth/auth.service.ts
Normal file
657
src/auth/auth.service.ts
Normal file
@@ -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<string>('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<string>('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<string>('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<string>('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<string>('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<string>('jwt.secret');
|
||||
const accessExpiresIn = this.parseExpirationToSeconds(
|
||||
this.configService.get<string>('jwt.accessExpiration') || '15m',
|
||||
);
|
||||
const refreshExpiresIn = this.parseExpirationToSeconds(
|
||||
this.configService.get<string>('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<string>('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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
14
src/auth/decorators/current-user.decorator.ts
Normal file
14
src/auth/decorators/current-user.decorator.ts
Normal file
@@ -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;
|
||||
},
|
||||
);
|
||||
3
src/auth/decorators/index.ts
Normal file
3
src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './public.decorator';
|
||||
export * from './roles.decorator';
|
||||
export * from './current-user.decorator';
|
||||
4
src/auth/decorators/public.decorator.ts
Normal file
4
src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/auth/decorators/roles.decorator.ts
Normal file
5
src/auth/decorators/roles.decorator.ts
Normal file
@@ -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);
|
||||
11
src/auth/dto/forgot-password.dto.ts
Normal file
11
src/auth/dto/forgot-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
6
src/auth/dto/index.ts
Normal file
6
src/auth/dto/index.ts
Normal file
@@ -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';
|
||||
19
src/auth/dto/login.dto.ts
Normal file
19
src/auth/dto/login.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
11
src/auth/dto/refresh-token.dto.ts
Normal file
11
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
65
src/auth/dto/register.dto.ts
Normal file
65
src/auth/dto/register.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
28
src/auth/dto/reset-password.dto.ts
Normal file
28
src/auth/dto/reset-password.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
60
src/auth/dto/social-auth.dto.ts
Normal file
60
src/auth/dto/social-auth.dto.ts
Normal file
@@ -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';
|
||||
}
|
||||
2
src/auth/guards/index.ts
Normal file
2
src/auth/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
36
src/auth/guards/jwt-auth.guard.ts
Normal file
36
src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -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<boolean>(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;
|
||||
}
|
||||
}
|
||||
41
src/auth/guards/roles.guard.ts
Normal file
41
src/auth/guards/roles.guard.ts
Normal file
@@ -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<UserRole[]>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
7
src/auth/index.ts
Normal file
7
src/auth/index.ts
Normal file
@@ -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';
|
||||
1
src/auth/strategies/index.ts
Normal file
1
src/auth/strategies/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './jwt.strategy';
|
||||
60
src/auth/strategies/jwt.strategy.ts
Normal file
60
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -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<string>('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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user