Skip to content

Commit b68c488

Browse files
authored
Merge pull request #432 from shamoo53/Authentication-Authorization-JWT-refresh-tokens-and-RBAC
Authentication & Authorization: JWT, refresh tokens, and RBAC
2 parents 8cff8c1 + 3411d14 commit b68c488

5 files changed

Lines changed: 192 additions & 15 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ QUEUE_REMOVE_ON_FAIL=false
4646

4747
# Authentication Configuration
4848
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
49-
JWT_EXPIRES_IN=3600
49+
JWT_EXPIRES_IN=900
5050
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this-in-production
51+
JWT_REFRESH_EXPIRES_IN=604800
5152

5253
# Email (SMTP) Configuration - Nodemailer
5354
SMTP_HOST=smtp.example.com

src/auth/auth.controller.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,29 @@ import {
3030
} from './dto/password-reset.dto';
3131
import { Enable2FADto, TwoFADto, Verify2FASetupDto } from './dto/2fa.dto';
3232
import { JwtAuthGuard } from './guards/jwt-auth.guard';
33+
import { RolesGuard } from './guards/roles.guard';
3334
import type { JwtPayload } from './guards/jwt-auth.guard';
3435
import { CurrentUser } from './decorators/current-user.decorator';
3536
import { Public } from './decorators/public.decorator';
3637
import { ApiAuthErrorResponses } from '../common/decorators/swagger-error-responses.decorator';
38+
import { Roles } from '../identity/roles/decorators/roles.decorator';
39+
import { UserRole } from '../identity/roles/enums/user-role.enum';
3740

38-
@ApiTags('identity/auth')
39-
@UseGuards(JwtAuthGuard)
40-
@Controller('identity/auth')
41+
@ApiTags('auth')
42+
@UseGuards(JwtAuthGuard, RolesGuard)
43+
@Controller('auth')
4144
export class AuthController {
4245
constructor(private readonly authService: AuthService) {}
4346

4447
// ─── Registration & Activation ─────────────────────────────────────────────
4548

4649
@Public()
47-
@Post('register')
50+
@Post('signup')
4851
@HttpCode(HttpStatus.CREATED)
4952
@ApiOperation({ summary: 'Register a new user account' })
5053
@ApiResponse({ status: 201, description: 'Registration successful' })
5154
@ApiAuthErrorResponses()
52-
register(@Body() dto: RegisterDto, @Req() req: Request) {
55+
signup(@Body() dto: RegisterDto, @Req() req: Request) {
5356
const correlationId =
5457
(req.headers['x-correlation-id'] as string) ||
5558
(req.headers['x-request-id'] as string);
@@ -104,13 +107,13 @@ export class AuthController {
104107
// ─── Token Refresh ─────────────────────────────────────────────────────────
105108

106109
@Public()
107-
@Post('token/refresh')
110+
@Post('refresh')
108111
@HttpCode(HttpStatus.OK)
109112
@ApiOperation({
110113
summary: 'Rotate refresh token and obtain new access + refresh tokens',
111114
})
112115
@ApiResponse({ status: 200, description: 'Tokens refreshed' })
113-
refreshToken(@Body() dto: RefreshTokenDto) {
116+
refresh(@Body() dto: RefreshTokenDto) {
114117
return this.authService.refreshTokens(dto);
115118
}
116119

@@ -210,4 +213,41 @@ export class AuthController {
210213
disable2FA(@CurrentUser() user: JwtPayload, @Body() dto: TwoFADto) {
211214
return this.authService.disable2FA(user.sub, dto);
212215
}
216+
217+
// ─── Admin Endpoints ───────────────────────────────────────────────────────
218+
219+
@Get('admin/users')
220+
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
221+
@ApiBearerAuth()
222+
@ApiOperation({ summary: 'List all users (admin only)' })
223+
@ApiResponse({ status: 200, description: 'List of all users' })
224+
listAllUsers(@CurrentUser() user: JwtPayload) {
225+
return this.authService.listAllUsers();
226+
}
227+
228+
@Post('admin/users/:userId/roles')
229+
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
230+
@ApiBearerAuth()
231+
@ApiOperation({ summary: 'Assign roles to a user (admin only)' })
232+
@ApiResponse({ status: 200, description: 'Roles assigned successfully' })
233+
assignUserRole(
234+
@CurrentUser() user: JwtPayload,
235+
@Param('userId') userId: string,
236+
@Body() body: { roles: UserRole[] },
237+
) {
238+
return this.authService.assignUserRoles(userId, body.roles);
239+
}
240+
241+
@Delete('admin/users/:userId/roles')
242+
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
243+
@ApiBearerAuth()
244+
@ApiOperation({ summary: 'Revoke roles from a user (admin only)' })
245+
@ApiResponse({ status: 200, description: 'Roles revoked successfully' })
246+
revokeUserRole(
247+
@CurrentUser() user: JwtPayload,
248+
@Param('userId') userId: string,
249+
@Body() body: { roles: UserRole[] },
250+
) {
251+
return this.authService.revokeUserRoles(userId, body.roles);
252+
}
213253
}

src/auth/auth.module.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Auth } from './entities/auth.entity';
1111
import { Session } from './entities/session.entity';
1212
import { User } from '../user/entities/user.entity';
1313
import { JwtAuthGuard } from './guards/jwt-auth.guard';
14+
import { RolesGuard } from './guards/roles.guard';
1415
import { AuthAuditListener } from './listeners/auth-audit.listener';
1516
import { AuditLogModule } from '../audit-log/audit-log.module';
1617

@@ -23,14 +24,20 @@ import { AuditLogModule } from '../audit-log/audit-log.module';
2324
useFactory: (config: ConfigService) => ({
2425
secret: config.get<string>('JWT_SECRET'),
2526
signOptions: {
26-
expiresIn: config.get<number>('JWT_EXPIRES_IN', 3600),
27+
expiresIn: config.get<number>('JWT_EXPIRES_IN', 900),
2728
},
2829
}),
2930
}),
3031
AuditLogModule,
3132
],
3233
controllers: [AuthController, MFAController],
33-
providers: [AuthService, MFAService, JwtAuthGuard, AuthAuditListener],
34-
exports: [AuthService, MFAService, JwtAuthGuard, JwtModule],
34+
providers: [
35+
AuthService,
36+
MFAService,
37+
JwtAuthGuard,
38+
RolesGuard,
39+
AuthAuditListener,
40+
],
41+
exports: [AuthService, MFAService, JwtAuthGuard, RolesGuard, JwtModule],
3542
})
3643
export class AuthModule {}

src/auth/auth.service.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,6 @@ export class AuthService {
122122
message:
123123
'Registration successful. Please check your email to activate your account.',
124124
userId: user.id,
125-
// NOTE: In production, remove activationToken from response and send via email only
126-
activationToken,
127125
};
128126
}
129127

@@ -418,8 +416,6 @@ export class AuthService {
418416
return {
419417
message:
420418
'If an account with that email exists, a reset link has been sent.',
421-
// NOTE: In production remove this and send via email only
422-
resetToken: token,
423419
};
424420
}
425421

@@ -743,4 +739,74 @@ export class AuthService {
743739
createdAt: user.createdAt,
744740
};
745741
}
742+
743+
// ─── Admin Functions ─────────────────────────────────────────────────────────
744+
745+
async listAllUsers() {
746+
const users = await this.userRepo.find({
747+
select: [
748+
'id',
749+
'username',
750+
'email',
751+
'firstName',
752+
'lastName',
753+
'role',
754+
'roles',
755+
'status',
756+
'createdAt',
757+
],
758+
order: { createdAt: 'DESC' },
759+
});
760+
761+
return users.map((user) => this.sanitizeUser(user));
762+
}
763+
764+
async assignUserRoles(userId: string, roles: UserRole[]) {
765+
const user = await this.userRepo.findOne({ where: { id: userId } });
766+
if (!user) {
767+
throw new NotFoundException('User not found');
768+
}
769+
770+
// Merge new roles with existing roles
771+
const existingRoles = user.roles || [];
772+
const updatedRoles = Array.from(new Set([...existingRoles, ...roles]));
773+
774+
user.roles = updatedRoles;
775+
user.role = updatedRoles[0] || UserRole.USER;
776+
await this.userRepo.save(user);
777+
778+
this.logger.log(`Roles assigned to user ${userId}: ${roles.join(', ')}`);
779+
780+
return {
781+
message: 'Roles assigned successfully',
782+
user: this.sanitizeUser(user),
783+
};
784+
}
785+
786+
async revokeUserRoles(userId: string, roles: UserRole[]) {
787+
const user = await this.userRepo.findOne({ where: { id: userId } });
788+
if (!user) {
789+
throw new NotFoundException('User not found');
790+
}
791+
792+
// Remove specified roles from existing roles
793+
const existingRoles = user.roles || [];
794+
const updatedRoles = existingRoles.filter((role) => !roles.includes(role));
795+
796+
// Ensure user always has at least USER role
797+
if (updatedRoles.length === 0) {
798+
updatedRoles.push(UserRole.USER);
799+
}
800+
801+
user.roles = updatedRoles;
802+
user.role = updatedRoles[0] || UserRole.USER;
803+
await this.userRepo.save(user);
804+
805+
this.logger.log(`Roles revoked from user ${userId}: ${roles.join(', ')}`);
806+
807+
return {
808+
message: 'Roles revoked successfully',
809+
user: this.sanitizeUser(user),
810+
};
811+
}
746812
}

src/auth/guards/roles.guard.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
CanActivate,
3+
ExecutionContext,
4+
ForbiddenException,
5+
Injectable,
6+
} from '@nestjs/common';
7+
import { Reflector } from '@nestjs/core';
8+
import { UserRole } from '../../identity/roles/enums/user-role.enum';
9+
import { ROLES_METADATA_KEY } from '../../identity/roles/decorators/roles.decorator';
10+
import { JwtPayload } from './jwt-auth.guard';
11+
12+
/**
13+
* Roles Guard - enforces role-based access control
14+
*
15+
* Checks if the authenticated user has the required roles
16+
* to access a protected endpoint
17+
*/
18+
@Injectable()
19+
export class RolesGuard implements CanActivate {
20+
constructor(private readonly reflector: Reflector) {}
21+
22+
canActivate(context: ExecutionContext): boolean {
23+
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
24+
ROLES_METADATA_KEY,
25+
[context.getHandler(), context.getClass()],
26+
);
27+
28+
// If no roles are required, allow access
29+
if (!requiredRoles || requiredRoles.length === 0) {
30+
return true;
31+
}
32+
33+
const request = context.switchToHttp().getRequest();
34+
const user: JwtPayload = request.user;
35+
36+
if (!user) {
37+
throw new ForbiddenException('Authentication required');
38+
}
39+
40+
const userRoles: UserRole[] = user.roles as UserRole[] || [user.role as UserRole];
41+
42+
// SUPER_ADMIN and ADMIN bypass all role checks
43+
if (
44+
userRoles.includes(UserRole.SUPER_ADMIN) ||
45+
userRoles.includes(UserRole.ADMIN)
46+
) {
47+
return true;
48+
}
49+
50+
// Check if user has at least one of the required roles
51+
const hasRole = requiredRoles.some((requiredRole) =>
52+
userRoles.includes(requiredRole),
53+
);
54+
55+
if (!hasRole) {
56+
throw new ForbiddenException(
57+
`Access denied. Required roles: [${requiredRoles.join(', ')}]`,
58+
);
59+
}
60+
61+
return true;
62+
}
63+
}

0 commit comments

Comments
 (0)