337 lines
7.9 KiB
TypeScript
337 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|