generated from fahricansecer/boilerplate-be
394 lines
9.7 KiB
TypeScript
394 lines
9.7 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');
|
||
}
|
||
|
||
// [AUTO-ASSIGN ADMIN ROLE FOR OWNER]
|
||
if (user.email === 'admin@contentgen.ai') {
|
||
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
|
||
if (!hasAdminRole) {
|
||
const adminRole = await this.prisma.role.findUnique({
|
||
where: { name: 'admin' },
|
||
});
|
||
if (adminRole) {
|
||
await this.prisma.userRole.create({
|
||
data: { userId: user.id, roleId: adminRole.id },
|
||
});
|
||
// Refresh user object
|
||
const refreshedUser = await this.prisma.user.findUnique({
|
||
where: { email: dto.email },
|
||
include: {
|
||
roles: {
|
||
include: {
|
||
role: {
|
||
include: { permissions: { include: { permission: true } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (refreshedUser) {
|
||
// Grant 999999 credits if not granted
|
||
const existingGrant = await this.prisma.creditTransaction.findFirst(
|
||
{
|
||
where: {
|
||
userId: refreshedUser.id,
|
||
type: 'grant',
|
||
description: 'Admin başlangıç kredisi — sınırsız',
|
||
},
|
||
},
|
||
);
|
||
if (!existingGrant) {
|
||
await this.prisma.creditTransaction.create({
|
||
data: {
|
||
userId: refreshedUser.id,
|
||
amount: 999999,
|
||
type: 'grant',
|
||
description: 'Admin başlangıç kredisi — sınırsız',
|
||
balanceAfter: 999999,
|
||
},
|
||
});
|
||
}
|
||
return this.generateTokens(
|
||
refreshedUser as unknown as UserWithRoles,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
const isAdmin = roles.includes('admin');
|
||
const accessExpiration = isAdmin
|
||
? '7d'
|
||
: this.configService.get('JWT_ACCESS_EXPIRATION', '15m');
|
||
|
||
// Generate access token
|
||
const accessToken = this.jwtService.sign(payload, {
|
||
expiresIn: accessExpiration,
|
||
});
|
||
|
||
// 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(accessExpiration) / 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;
|
||
}
|
||
}
|
||
}
|