feat: Implement comprehensive user authentication with JWT, social login, and password management.
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user