Replaces the fixed 5-value Role enum with a database-driven Role/Permission model: admins can create custom roles with a precise per-resource/action permission set (83-key code-owned catalog), instead of being locked to ADMIN/BEEKEEPER/STAFF/READONLY/API_CLIENT. - New Role/Permission/RolePermission tables; legacy Role enum renamed to LegacyRole and kept as User.legacyRole so all existing @Roles() call sites keep working unchanged during the migration. - New RolesController/RolesService (CRUD, permission-set editing, deactivate/reactivate) with lockout-prevention invariants: system roles (ADMIN) can't be edited or deactivated, and a role still assigned to users can't be deactivated. - New PermissionResolverService (30s-cached, DB-fresh per user) and PermissionsGuard + @RequirePermission(), registered as an additional APP_GUARD alongside the legacy RolesGuard so both systems run side-by-side until controllers are migrated in Phase 2. - PermissionCatalogSyncService seeds any new catalog entries into the DB on boot, so future features only touch the TS catalog. - New GET /auth/me returns the caller's role and effective permissions. - Migration hand-written (destructive column rename) with grants derived from a fresh grep of every existing @Roles() call site, verified against 5 controllers read in full for STAFF's exact write boundary. - test/roles.e2e-spec.ts covers the new endpoints and invariants end to end; test/authorization-coverage.e2e-spec.ts added but skipped until Phase 2 gives every controller a @RequirePermission. Co-Authored-By: Claude Sonnet 5 <[email protected]>
174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import * as crypto from 'node:crypto';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { UsersService } from '../users/users.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
|
import { AuthenticatedUser } from './types/authenticated-user';
|
|
import { parseDurationToMs } from './util/duration';
|
|
|
|
interface AuthTokens {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
}
|
|
|
|
export interface CurrentUserProfile {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
roleKey: string;
|
|
roleName: string;
|
|
permissions: string[];
|
|
}
|
|
|
|
const REFRESH_TOKEN_BYTES = 40;
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private readonly usersService: UsersService,
|
|
private readonly prisma: PrismaService,
|
|
private readonly jwtService: JwtService,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
) {}
|
|
|
|
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
|
const user = await this.prisma.user.findUniqueOrThrow({
|
|
where: { id: userId },
|
|
include: { role: { select: { key: true, name: true } } },
|
|
});
|
|
const permissions = await this.permissionResolver.getPermissions(userId);
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.legacyRole,
|
|
roleKey: user.role.key,
|
|
roleName: user.role.name,
|
|
permissions: Array.from(permissions),
|
|
};
|
|
}
|
|
|
|
private hashRefreshToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
private signAccessToken(user: AuthenticatedUser): string {
|
|
const expiresInSeconds = Math.floor(
|
|
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
|
|
);
|
|
return this.jwtService.sign(
|
|
{ sub: user.id, email: user.email, role: user.role },
|
|
{
|
|
secret: process.env.JWT_ACCESS_SECRET,
|
|
expiresIn: expiresInSeconds,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async issueRefreshToken(userId: string): Promise<string> {
|
|
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
|
|
const expiresAt = new Date(
|
|
Date.now() +
|
|
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
|
);
|
|
await this.prisma.refreshToken.create({
|
|
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
|
|
});
|
|
return token;
|
|
}
|
|
|
|
async login(email: string, password: string): Promise<AuthTokens> {
|
|
const user = await this.usersService.findByEmail(email);
|
|
if (!user || !user.isActive) {
|
|
await this.auditLog.record({
|
|
entityType: 'AuthSession',
|
|
entityId: user?.id ?? email,
|
|
action: 'LOGIN_FAILED',
|
|
userId: user?.id,
|
|
});
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
|
if (!passwordMatches) {
|
|
await this.auditLog.record({
|
|
entityType: 'AuthSession',
|
|
entityId: user.id,
|
|
action: 'LOGIN_FAILED',
|
|
userId: user.id,
|
|
});
|
|
throw new UnauthorizedException('Invalid credentials');
|
|
}
|
|
const authenticatedUser: AuthenticatedUser = {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.legacyRole,
|
|
};
|
|
const accessToken = this.signAccessToken(authenticatedUser);
|
|
const refreshToken = await this.issueRefreshToken(user.id);
|
|
await this.auditLog.record({
|
|
entityType: 'AuthSession',
|
|
entityId: user.id,
|
|
action: 'LOGIN',
|
|
userId: user.id,
|
|
});
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
async refresh(refreshToken: string): Promise<AuthTokens> {
|
|
const tokenHash = this.hashRefreshToken(refreshToken);
|
|
const stored = await this.prisma.refreshToken.findUnique({
|
|
where: { tokenHash },
|
|
});
|
|
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
const user = await this.usersService.findById(stored.userId);
|
|
if (!user.isActive) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
await this.prisma.refreshToken.update({
|
|
where: { id: stored.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
const authenticatedUser: AuthenticatedUser = {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.legacyRole,
|
|
};
|
|
const accessToken = this.signAccessToken(authenticatedUser);
|
|
const newRefreshToken = await this.issueRefreshToken(user.id);
|
|
await this.auditLog.record({
|
|
entityType: 'AuthSession',
|
|
entityId: user.id,
|
|
action: 'TOKEN_REFRESHED',
|
|
userId: user.id,
|
|
});
|
|
return { accessToken, refreshToken: newRefreshToken };
|
|
}
|
|
|
|
async logout(refreshToken: string): Promise<void> {
|
|
const tokenHash = this.hashRefreshToken(refreshToken);
|
|
const stored = await this.prisma.refreshToken.findUnique({
|
|
where: { tokenHash },
|
|
});
|
|
if (!stored || stored.revokedAt) {
|
|
return;
|
|
}
|
|
await this.prisma.refreshToken.update({
|
|
where: { id: stored.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await this.auditLog.record({
|
|
entityType: 'AuthSession',
|
|
entityId: stored.userId,
|
|
action: 'LOGOUT',
|
|
userId: stored.userId,
|
|
});
|
|
}
|
|
}
|