336
src/modules/auth/auth.service.ts
Normal file
336
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
isActive: boolean;
|
||||
tenantId: string | null;
|
||||
roles: Array<{
|
||||
role: {
|
||||
name: string;
|
||||
permissions: Array<{
|
||||
permission: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<TokenResponseDto> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
// Create user with default role
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
roles: {
|
||||
create: {
|
||||
role: {
|
||||
connectOrCreate: {
|
||||
where: { name: 'user' },
|
||||
create: { name: 'user', description: 'Default user role' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<TokenResponseDto> {
|
||||
// Find user by email
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await this.comparePassword(
|
||||
dto.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
||||
}
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponseDto> {
|
||||
// Find refresh token
|
||||
const storedToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
// Delete old refresh token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
|
||||
return this.generateTokens(storedToken.user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate refresh token
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user by ID (used by JWT strategy)
|
||||
*/
|
||||
async validateUser(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove password from user object
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(user: UserWithRoles): Promise<TokenResponseDto> {
|
||||
// Extract roles and permissions
|
||||
const roles = user.roles.map((ur) => ur.role.name);
|
||||
const permissions = user.roles.flatMap((ur) =>
|
||||
ur.role.permissions.map((rp) => rp.permission.name),
|
||||
);
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles,
|
||||
permissions,
|
||||
tenantId: user.tenantId || undefined,
|
||||
};
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
const refreshTokenValue = crypto.randomUUID();
|
||||
const refreshExpiration = this.parseExpiration(
|
||||
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
|
||||
);
|
||||
|
||||
// Store refresh token
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshTokenValue,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + refreshExpiration),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn:
|
||||
this.parseExpiration(
|
||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
) / 1000, // Convert to seconds
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
roles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with hash
|
||||
*/
|
||||
private async comparePassword(
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse expiration string to milliseconds
|
||||
*/
|
||||
private parseExpiration(expiration: string): number {
|
||||
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
return 15 * 60 * 1000; // Default 15 minutes
|
||||
}
|
||||
|
||||
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 15 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user