일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- decoder implementation
- JWT
- 티스토리챌린지
- token-based authentication
- truth table
- Digital Circuit Design
- nestjs guards
- 오블완
- decoder
- nestjs authorization
- Memory Addressing
- Logic Gates
- nestjs
- Role-based Access Control
- memory reading
- Today
- Total
doksan-gooner
Managing Authorization Levels with NestJS Guard and JWT 본문
Purpose of the Article
During my internship, I worked on managing user levels and configuring API call permissions based on those levels using NestJS. This blog post aims to explain the implementation of Guards, JWT, and custom decorators that I created to achieve this.
Requirements
• User Levels: Members within a workspace are categorized into four levels. Level 1 has the highest authority, with progressively fewer permissions as the level number increases.
• Guard Functionality: The Guard must verify whether the API caller possesses the required permission level. However, it should not query the database. (I believe querying the database within the Guard is resource-intensive and inappropriate since it occurs before the controller method is invoked.)
Implementation Approach
1. Include User Level in JWT Payload
The JWT payload contains the user’s level when issued.
2. Extract Level in JWT Guard
The JWT Guard validates the token and extracts the user level, injecting it into the request body.
3. Define a @MinimumLevel Decorator
A custom decorator, @MinimumLevel(type of MemberLevel), is used to specify the minimum required user level for each controller method.
4. Validate in RolesGuard
The RolesGuard compares the @MinimumLevel value with the user’s level from the request body to determine if the caller has the necessary permissions to execute the method.
1. Adding the Member’s Level to the JWT Payload
In the application, a single user can join multiple workspaces. Since permissions may vary across workspaces, an access token is reissued whenever the user switches between workspaces.
Step 1: Define Member Levels
First, create an enumeration (MemberLevel) to represent different permission levels:
export enum MemberLevel {
UNASSIGNED = 100,
LEVEL_1 = 1,
LEVEL_2 = 2,
LEVEL_3 = 3,
LEVEL_4 = 4,
}
• The smaller the number, the higher the permissions.
• UNASSIGNED (100) represents a user without any assigned role in the workspace.
Step 2: Generate the JWT Payload
To include the workspaceId and level in the JWT payload, we fetch the member’s access level for the target workspace from the database.
Here’s how this is implemented:
async getAccessPayload(memberId: string, workspaceId: string) {
if (workspaceId === undefined) {
return {
memberId: memberId,
workspaceId: DEFAULT_WORKSPACE_ID,
level: MemberLevel.UNASSIGNED,
};
} else {
const dataSource = await this.dataBaseService.getTenantDataSource(workspaceId);
const memberWorkspaceEntity = await dataSource.manager.findOne(
MemberEntity,
{ where: { uuid: memberId } }
);
if (!memberWorkspaceEntity) {
throw new ServiceException(ExceptionCode.NOT_AUTHORIZED);
}
return {
memberId: memberId,
workspaceId: workspaceId,
level: memberWorkspaceEntity.accessLevel,
};
}
}
• If workspaceId is not provided, assign the default workspace and set the level to UNASSIGNED.
• Otherwise:
• Retrieve the workspace-specific data source.
• Query the database for the user’s role in the workspace.
• Throw an exception if the user is not authorized.
• Include the workspaceId and level in the payload.
Step 3: Structure of the JWT Payload
The resulting JWT payload will include the following fields:
{
"memberId": "member-90cd9162-8ed2-4845-b477-1d5754beddbb",
"workspaceId": "workspace-7540925c-b8c2-4c38-8c5c-f6c5673ae072",
"level": 1,
"iat": 1731648985,
"exp": 1732253785
}
• memberId: Unique identifier for the member.
• workspaceId: Identifier for the current workspace.
• level: Permission level of the member in the workspace.
• iat: Issued at timestamp.
• exp: Expiration timestamp.
2. Creating JwtGuard to Validate Access Token and Inject Level into the Request Body
To validate the Access Token and extract the member’s information, including their permission level, you can use a custom JwtAuthGuard. This guard extends the built-in AuthGuard from @nestjs/passport and overrides the handleRequest method.
Here’s the implementation:
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err, user, info, context: ExecutionContext) {
// If there’s an error or the user object is invalid, throw an exception.
if (err || !user) {
throw err || new ServiceException(ExceptionCode.TOKEN_INVALID);
}
const request = context.switchToHttp().getRequest();
// Extract information from the user object and inject it into the request body.
if (user && user.memberId) {
request.body.memberId = user.memberId;
request.body.workspaceId = user.workspaceId;
request.body.level = user.level;
}
return user; // Return the user object to make it available in the context.
}
}
3. Setting the @MinimumLevel Decorator
To define access permissions for controller methods based on user levels, we use a custom decorator called @MinimumLevel. This decorator sets metadata that specifies the minimum level required to access a method.
import { SetMetadata } from '@nestjs/common';
import { MemberLevel } from './member-level.enum';
export const MinimumLevel = (role: MemberLevel) => SetMetadata('authLevels', role);
• SetMetadata: A utility function from NestJS that attaches custom metadata to a method or class.
• authLevels: The key used to store the required level in the method’s metadata.
• Parameter role: The minimum MemberLevel required for the method.
The @MinimumLevel decorator is applied to controller methods to specify the required level for access. For example:
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { MinimumLevel } from './decorators/minimum-level.decorator';
@ApiOperation({ summary: 'Delete a Project' })
@MinimumLevel(MemberLevel.LEVEL_1)
@UseGuards(JwtAuthGuard, RolesGuard)
@Post('delete')
delete(@Body() body: DeleteProjectResponseDto) {
return this.projectService.delete(
tenantQueryRunnerManager,
projectCollection,
body.projectId
);
}
• @ApiOperation: Documents the API using Swagger.
• @MinimumLevel(MemberLevel.LEVEL_1): Sets the minimum level required for this endpoint to LEVEL_1. Only the highest authority users can delete projects.
• @UseGuards(JwtAuthGuard, RolesGuard):
• JwtAuthGuard: Validates the JWT token and extracts user information.
• RolesGuard: Verifies that the user’s level meets the required level specified by @MinimumLevel.
4. Implementing RolesGuard to Compare MinimumLevel and User’s Level
The RolesGuard ensures that a user’s permission level meets the minimum requirements defined in the @MinimumLevel decorator for a given controller method. It compares the metadata (authLevels) set by the decorator with the user’s level, which is injected into the request body by the JwtAuthGuard.
Here’s the implementation of the RolesGuard:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MemberLevel } from './member-level.enum';
import { ServiceException } from './exceptions/service.exception';
import { ExceptionCode } from './exceptions/exception-code.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Retrieve the required level from metadata
const requiredRole = this.reflector.getAllAndOverride<MemberLevel>('authLevels', [
context.getHandler(),
context.getClass(),
]);
// If no level is required, allow access
if (!requiredRole) {
return true;
}
// Extract the user's level from the request body
const request = context.switchToHttp().getRequest();
const memberLevel = MemberLevel[request.body.level as keyof typeof MemberLevel];
// Deny access if user's level is higher (numerically larger) than required
if (memberLevel > requiredRole) {
throw new ServiceException(ExceptionCode.NOT_AUTHORIZED);
}
// Allow access if the user's level meets or exceeds the required level
return true;
}
}
• Level 1 User:
Can access all methods where @MinimumLevel is set to LEVEL_1, LEVEL_2, LEVEL_3, etc.
• Level 3 User:
Can only access methods where @MinimumLevel is set to LEVEL_3 or LEVEL_4. Attempts to access LEVEL_1 or LEVEL_2 methods will result in an Unauthorized exception.
Advantages of the Approach
1. Validation of level Integrity
By injecting level into the JWT, even if the level is tampered with externally, the RolesGuard validates it against the @MinimumLevel decorator to ensure its authenticity.
2. No Database Query for Permissions
Permission validation occurs without querying the database for every request, reducing overhead and improving performance.
3. Separation of Concerns
The service layer focuses solely on business logic, with permission checks handled at the Guard level.
4. Ease of Metadata Configuration
Using decorators like @MinimumLevel, metadata for permission checks can be added and updated easily in a clean and intuitive manner.
Disadvantages of the Approach
1. Increased JWT Payload Size
• Adding level to the JWT payload slightly increases its size, which could impact network communication.
• However, since level is just a numeric value, the impact is minimal.
2. Challenges with Real-Time Permission Changes
• If a user’s permissions change, the JWT remains outdated until the user requests a new access token through a refresh process.
• This refresh process must be initiated by the client, which might not reflect changes in permissions immediately.
3. Exposure of User Levels
• Since level is included in the JWT payload, it is visible if the token is decoded.
• This could reveal the user’s roles and permissions within a workspace, potentially leaking sensitive information.
4. Limitations of the Level-Based System
• The current design assumes that lower numeric values equate to higher permissions.
• If permissions need to be further refined or if different actions require distinct permission sets (beyond a simple hierarchy), the system will require additional redesign and implementation effort.