Skip to content

Commit 7a44c43

Browse files
authored
feat(api): make the permissions field optional for the access token (#36)
Fixes #35
1 parent 6046a74 commit 7a44c43

File tree

17 files changed

+172
-89
lines changed

17 files changed

+172
-89
lines changed

modules/libs/protocol/auth.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ export interface JwtToken {
3939
}
4040

4141
export interface AccessToken extends JwtToken {
42-
permissions: UserPermission[];
42+
/**
43+
* User permissions.
44+
* @remarks This is an optional field. If not provided, service
45+
* should check the user's permissions from the database.
46+
* This is useful during development to avoid unnecessary
47+
* token refreshes.
48+
*/
49+
permissions?: UserPermission[];
4350
}
4451

4552
export interface RefreshToken extends JwtToken {
@@ -99,7 +106,6 @@ export interface LogOutResponse {
99106

100107
/**
101108
* Permission object. Contains the organization ID, school ID, and permission.
102-
*
103109
* @remarks Used short names for the properties to reduce the size of a JWT token.
104110
*/
105111
export type UserPermission = {

modules/services/api/src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
44
import { ConfigModule, ConfigType } from '@nestjs/config';
55
import { TypeOrmModule } from '@nestjs/typeorm';
66
import {
7+
AuthConfig,
78
DbConfig,
89
JwtConfig,
910
OtpConfig,
@@ -19,7 +20,7 @@ import { OrganizationsService } from './edu/services/organizations.service';
1920
imports: [
2021
ConfigModule.forRoot({
2122
isGlobal: true,
22-
load: [DbConfig, OtpConfig, RedisConfig, JwtConfig],
23+
load: [DbConfig, OtpConfig, RedisConfig, JwtConfig, AuthConfig],
2324
}),
2425
AutomapperModule.forRoot({
2526
strategyInitializer: classes(),
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import { Module } from '@nestjs/common';
22
import { JwtModule } from '@nestjs/jwt';
33
import { TypeOrmModule } from '@nestjs/typeorm';
4-
import { User } from '@vidya/entities';
4+
import { Role, User, UserRole } from '@vidya/entities';
55

66
import { LoginController } from './controllers/login.controller';
77
import { OtpController } from './controllers/otp.controller';
88
import { ProfileController } from './controllers/profile.controller';
99
import { TokensController } from './controllers/tokens.controller';
1010
import { AuthRolesMappingProfile } from './mappers/roles.mapper';
1111
import { AuthService } from './services/auth.service';
12+
import { AuthUsersService } from './services/auth-users.service';
1213
import { OtpService } from './services/otp.service';
1314
import { RevokedTokensService } from './services/revokedTokens.service';
14-
import { UsersService } from './services/users.service';
1515

1616
@Module({
1717
imports: [
18-
TypeOrmModule.forFeature([User]),
18+
TypeOrmModule.forFeature([User, Role, UserRole]),
1919
JwtModule.register({ global: true }),
2020
],
2121
controllers: [
@@ -26,11 +26,10 @@ import { UsersService } from './services/users.service';
2626
],
2727
providers: [
2828
OtpService,
29-
UsersService,
29+
AuthUsersService,
3030
AuthService,
3131
RevokedTokensService,
3232
AuthRolesMappingProfile,
3333
],
34-
exports: [RevokedTokensService],
3534
})
3635
export class AuthModule {}

modules/services/api/src/auth/controllers/login.controller.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { Mapper } from '@automapper/core';
2-
import { InjectMapper } from '@automapper/nestjs';
31
import {
42
Body,
53
Controller,
4+
Inject,
65
Post,
76
UnauthorizedException,
87
UseGuards,
98
} from '@nestjs/common';
9+
import { ConfigType } from '@nestjs/config';
1010
import {
1111
ApiBadRequestResponse,
1212
ApiBearerAuth,
@@ -21,22 +21,23 @@ import * as dto from '@vidya/api/auth/dto';
2121
import { AuthenticatedUser } from '@vidya/api/auth/guards';
2222
import {
2323
AuthService,
24+
AuthUsersService,
2425
OtpService,
2526
RevokedTokensService,
26-
UsersService,
2727
} from '@vidya/api/auth/services';
28-
import * as entities from '@vidya/entities';
28+
import { AuthConfig } from '@vidya/api/configs';
2929
import { JwtToken, OtpType, Routes } from '@vidya/protocol';
3030

3131
@Controller()
3232
@ApiTags('Authentication')
3333
export class LoginController {
3434
constructor(
35+
@Inject(AuthConfig.KEY)
36+
private readonly authConfig: ConfigType<typeof AuthConfig>,
3537
private readonly otpService: OtpService,
36-
private readonly usersService: UsersService,
38+
private readonly usersService: AuthUsersService,
3739
private readonly authService: AuthService,
3840
private readonly revokedTokensService: RevokedTokensService,
39-
@InjectMapper() private readonly mapper: Mapper,
4041
) {}
4142

4243
/* -------------------------------------------------------------------------- */
@@ -66,7 +67,7 @@ export class LoginController {
6667
async loginWithOtp(
6768
@Body() request: dto.OtpLogInRequest,
6869
): Promise<dto.OtpLogInResponse> {
69-
// TODO: rate limit login attempts
70+
// TODO rate limit login attempts
7071

7172
// validate OTP, if invalid send 401 Unauthorized response
7273
const otp = await this.otpService.validate(request.login, request.otp);
@@ -85,7 +86,9 @@ export class LoginController {
8586
);
8687
const tokens = await this.authService.generateTokens(
8788
user.id,
88-
this.mapper.mapArray(user.roles, entities.Role, dto.UserPermission),
89+
this.authConfig.savePermissionsInJwtToken
90+
? await this.usersService.getUserPermissions(user.id)
91+
: undefined,
8992
);
9093

9194
return new dto.OtpLogInResponse({

modules/services/api/src/auth/controllers/profile.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {
1414
import { UserId } from '@vidya/api/auth/decorators';
1515
import * as dto from '@vidya/api/auth/dto';
1616
import { AuthenticatedUser } from '@vidya/api/auth/guards';
17-
import { UsersService } from '@vidya/api/auth/services';
17+
import { AuthUsersService } from '@vidya/api/auth/services';
1818
import { Routes } from '@vidya/protocol';
1919

2020
@Controller()
2121
@ApiTags('User')
2222
export class ProfileController {
23-
constructor(private readonly usersService: UsersService) {}
23+
constructor(private readonly usersService: AuthUsersService) {}
2424

2525
/* -------------------------------------------------------------------------- */
2626
/* GET /auth/profile */

modules/services/api/src/auth/controllers/tokens.controller.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { Mapper } from '@automapper/core';
2-
import { InjectMapper } from '@automapper/nestjs';
31
import {
42
Body,
53
Controller,
@@ -17,10 +15,9 @@ import {
1715
import * as dto from '@vidya/api/auth/dto';
1816
import {
1917
AuthService,
18+
AuthUsersService,
2019
RevokedTokensService,
21-
UsersService,
2220
} from '@vidya/api/auth/services';
23-
import * as entities from '@vidya/entities';
2421
import { Routes } from '@vidya/protocol';
2522

2623
@Controller()
@@ -29,8 +26,7 @@ export class TokensController {
2926
constructor(
3027
private readonly revokedTokensService: RevokedTokensService,
3128
private readonly authService: AuthService,
32-
private readonly usersService: UsersService,
33-
@InjectMapper() private readonly mapper: Mapper,
29+
private readonly usersService: AuthUsersService,
3430
) {}
3531

3632
/* -------------------------------------------------------------------------- */
@@ -88,7 +84,7 @@ export class TokensController {
8884
// generate new tokens
8985
const tokens = await this.authService.generateTokens(
9086
refreshToken.sub,
91-
this.mapper.mapArray(user.roles, entities.Role, dto.UserPermission),
87+
await this.usersService.getUserPermissions(user.id),
9288
);
9389
return new dto.RefreshTokensResponse({
9490
accessToken: tokens.accessToken,
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
1-
import {
2-
createParamDecorator,
3-
ExecutionContext,
4-
UnauthorizedException,
5-
} from '@nestjs/common';
6-
import { UserPermissions } from '@vidya/api/auth/utils';
7-
import { AccessToken } from '@vidya/protocol';
1+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2+
import { UserPermissions, VidyaRequest } from '@vidya/api/auth/utils';
83

94
export const UserWithPermissions = createParamDecorator(
105
(data, ctx: ExecutionContext) => {
11-
const accessToken: AccessToken = ctx
6+
const userPermissions = ctx
127
.switchToHttp()
13-
.getRequest().accessToken;
14-
if (!accessToken) {
15-
throw new UnauthorizedException('Access token not found');
16-
}
17-
return new UserPermissions(accessToken.permissions);
8+
.getRequest<VidyaRequest>().userPermissions;
9+
return new UserPermissions(userPermissions);
1810
},
1911
);

modules/services/api/src/auth/guards/authenticated-user.guard.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,47 +7,60 @@ import {
77
} from '@nestjs/common';
88
import { ConfigType } from '@nestjs/config';
99
import { JwtService } from '@nestjs/jwt';
10+
import {
11+
AuthUsersService,
12+
RevokedTokensService,
13+
} from '@vidya/api/auth/services';
14+
import { VidyaRequest } from '@vidya/api/auth/utils';
1015
import { JwtConfig } from '@vidya/api/configs';
11-
import { JwtToken } from '@vidya/protocol';
12-
import { Request } from 'express';
13-
14-
import { RevokedTokensService } from '../services/revokedTokens.service';
16+
import { AccessToken } from '@vidya/protocol';
1517

1618
@Injectable()
1719
export class AuthenticatedUser implements CanActivate {
1820
constructor(
1921
@Inject(JwtConfig.KEY)
2022
private readonly jwtConfig: ConfigType<typeof JwtConfig>,
2123
private readonly jwtService: JwtService,
22-
private readonly revoketTokensService: RevokedTokensService,
24+
private readonly revokedTokensService: RevokedTokensService,
25+
private readonly usersService: AuthUsersService,
2326
) {}
2427

2528
async canActivate(context: ExecutionContext): Promise<boolean> {
26-
const request = context.switchToHttp().getRequest();
29+
const request = context.switchToHttp().getRequest<VidyaRequest>();
2730
const token = this.extractTokenFromHeader(request);
2831
if (!token) {
2932
throw new UnauthorizedException();
3033
}
3134

3235
try {
33-
const payload = await this.jwtService.verifyAsync<JwtToken>(token, {
34-
secret: this.jwtConfig.secret,
35-
});
36+
// Verify the token and extract the payload
37+
const accessToken = await this.jwtService.verifyAsync<AccessToken>(
38+
token,
39+
{
40+
secret: this.jwtConfig.secret,
41+
},
42+
);
3643

37-
const isTokenRevoked = await this.revoketTokensService.isRevoked(payload);
44+
// Check if the token has been revoked
45+
const isTokenRevoked =
46+
await this.revokedTokensService.isRevoked(accessToken);
3847
if (isTokenRevoked) {
3948
throw new UnauthorizedException();
4049
}
4150

42-
request['accessToken'] = payload;
43-
request['userId'] = payload.sub;
51+
// Attach the user id and permissions to the request object
52+
request.userId = accessToken.sub;
53+
request.accessToken = accessToken;
54+
request.userPermissions = accessToken.permissions
55+
? accessToken.permissions
56+
: await this.usersService.getUserPermissions(accessToken.sub);
4457
} catch {
4558
throw new UnauthorizedException();
4659
}
4760
return true;
4861
}
4962

50-
private extractTokenFromHeader(request: Request): string | undefined {
63+
private extractTokenFromHeader(request: VidyaRequest): string | undefined {
5164
const [type, token] = request.headers.authorization?.split(' ') ?? [];
5265
return type === 'Bearer' ? token : undefined;
5366
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Mapper } from '@automapper/core';
2+
import { InjectMapper } from '@automapper/nestjs';
3+
import { Injectable } from '@nestjs/common';
4+
import { InjectRepository } from '@nestjs/typeorm';
5+
import * as dto from '@vidya/api/auth/dto';
6+
import { Role, User } from '@vidya/entities';
7+
import * as entities from '@vidya/entities';
8+
import { UserPermission } from '@vidya/protocol';
9+
import { Repository } from 'typeorm';
10+
11+
export type LoginField = 'email' | 'phone';
12+
13+
@Injectable()
14+
export class AuthUsersService {
15+
/**
16+
* Creates an instance of AuthUsersService.
17+
* @param users Users repository
18+
* @param roles Rples repository
19+
* @param mapper Mapper instance
20+
*/
21+
constructor(
22+
@InjectRepository(User) private readonly users: Repository<User>,
23+
@InjectRepository(Role) private readonly roles: Repository<Role>,
24+
@InjectMapper() private readonly mapper: Mapper,
25+
) {}
26+
27+
/**
28+
* Finds a user by id.
29+
* @param id Id of the user
30+
* @returns User with the given id or null if not found
31+
*/
32+
async findById(id: string): Promise<User | null> {
33+
return await this.users.findOne({ where: { id }, relations: ['roles'] });
34+
}
35+
36+
/**
37+
* Gets a user by login or creates a new one if not found.
38+
* @param field Field to search by
39+
* @param login Login value
40+
* @returns User with the given login or null if not found
41+
*/
42+
async getOrCreateByLogin(field: LoginField, login: string): Promise<User> {
43+
const existingUser = await this.users.findOne({
44+
where: { [field]: login },
45+
relations: ['roles'],
46+
});
47+
if (existingUser) {
48+
return existingUser;
49+
}
50+
51+
const newUser = this.users.create({
52+
[field]: login,
53+
roles: [],
54+
});
55+
return await this.users.save(newUser);
56+
}
57+
58+
/**
59+
* Gets roles of a user.
60+
* @param userId Id of the user
61+
* @returns Roles of the user
62+
*/
63+
async getRolesOfUser(userId: string): Promise<Role[]> {
64+
return await this.roles
65+
.createQueryBuilder('role')
66+
.innerJoin('role.userRoles', 'userRole')
67+
.where('userRole.userId = :userId', { userId })
68+
.getMany();
69+
}
70+
71+
/**
72+
* Gets user permissions.
73+
* @param userId User id
74+
* @returns User permissions
75+
*/
76+
async getUserPermissions(userId: string): Promise<UserPermission[]> {
77+
const userRoles = await this.getRolesOfUser(userId);
78+
return this.mapper.mapArray(userRoles, entities.Role, dto.UserPermission);
79+
}
80+
}

0 commit comments

Comments
 (0)