initial: standalone repo from monorepo split
This commit is contained in:
62
src/auth/auth.controller.ts
Normal file
62
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
|
||||
async register(@Body() dto: RegisterTenantDto) {
|
||||
return this.authService.registerTenant(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshTokens(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Req() req: any) {
|
||||
await this.authService.logout(req.user.sub);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
async profile(@Req() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
|
||||
}
|
||||
}
|
||||
26
src/auth/auth.module.ts
Normal file
26
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
267
src/auth/auth.service.ts
Normal file
267
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<TokenResponse> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email, isActive: true },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Super admin may not have a tenant; regular users must have active tenant
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
async registerTenant(dto: RegisterTenantDto) {
|
||||
const existing = await this.prisma.tenant.findUnique({
|
||||
where: { slug: dto.slug },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Tenant slug already taken');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
|
||||
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
name: dto.tenantName,
|
||||
slug: dto.slug,
|
||||
settings: {
|
||||
companyName: dto.tenantName,
|
||||
currency: 'PHP',
|
||||
timezone: 'Asia/Manila',
|
||||
},
|
||||
users: {
|
||||
create: {
|
||||
email: dto.adminEmail,
|
||||
password: hashedPassword,
|
||||
firstName: dto.adminFirstName,
|
||||
lastName: dto.adminLastName,
|
||||
roles: {
|
||||
create: { role: 'tenant_admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
include: { roles: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const admin = tenant.users[0]!;
|
||||
const roles = admin.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: admin.id,
|
||||
tenantId: tenant.id,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
user: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
roles,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
if (stored) {
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
// Delete the used refresh token (rotation)
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: stored.userId },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
return this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: true,
|
||||
tenant: { select: { id: true, name: true, slug: true, settings: true } },
|
||||
tenantRoles: {
|
||||
include: {
|
||||
tenantRole: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
|
||||
// Resolve permission matrix from tenant roles
|
||||
const accessMap: Record<string, Record<string, boolean>> = {};
|
||||
if (!isSuperAdmin) {
|
||||
for (const utr of user.tenantRoles) {
|
||||
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
|
||||
for (const p of utr.tenantRole.permissions) {
|
||||
if (!accessMap[p.module]) {
|
||||
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
}
|
||||
if (p.canView) accessMap[p.module].canView = true;
|
||||
if (p.canCreate) accessMap[p.module].canCreate = true;
|
||||
if (p.canUpdate) accessMap[p.module].canUpdate = true;
|
||||
if (p.canArchive) accessMap[p.module].canArchive = true;
|
||||
if (p.canApprove) accessMap[p.module].canApprove = true;
|
||||
if (p.canExport) accessMap[p.module].canExport = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
accessMap: isSuperAdmin ? 'all' : accessMap,
|
||||
tenantRoles: user.tenantRoles.map((tr) => ({
|
||||
id: tr.tenantRole.id,
|
||||
name: tr.tenantRole.name,
|
||||
slug: tr.tenantRole.slug,
|
||||
})),
|
||||
tenant: user.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!valid) throw new UnauthorizedException('Current password is incorrect');
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 12);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashed, mustChangePassword: false },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
|
||||
const jti = crypto.randomUUID();
|
||||
const accessToken = this.jwt.sign({ ...payload, jti });
|
||||
|
||||
const refreshJti = crypto.randomUUID();
|
||||
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
|
||||
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
|
||||
});
|
||||
|
||||
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
const expiresAt = new Date();
|
||||
const days = parseInt(expiresIn) || 7;
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshToken,
|
||||
userId: payload.sub,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
10
src/auth/dto/change-password.dto.ts
Normal file
10
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
10
src/auth/dto/login.dto.ts
Normal file
10
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
6
src/auth/dto/refresh-token.dto.ts
Normal file
6
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
30
src/auth/dto/register-tenant.dto.ts
Normal file
30
src/auth/dto/register-tenant.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterTenantDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
tenantName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@IsEmail()
|
||||
adminEmail: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
adminPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminFirstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminLastName: string;
|
||||
}
|
||||
25
src/auth/strategies/jwt.strategy.ts
Normal file
25
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '@fiberops/shared';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload) {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId: payload.tenantId || null,
|
||||
roles: payload.roles || [],
|
||||
permissions: payload.permissions || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user