@boarteam/boar-pack-users-backend 6.0.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/auth/auth-manage.controller.ts +3 -4
- package/src/auth/auth-strategies.constants.ts +1 -0
- package/src/auth/auth.config.ts +20 -0
- package/src/auth/auth.constants.ts +1 -0
- package/src/auth/auth.controller.ts +18 -2
- package/src/auth/auth.module.ts +3 -0
- package/src/auth/auth.service.ts +53 -9
- package/src/auth/google/google-auth.controller.ts +2 -2
- package/src/auth/local-auth/local-auth.controller.ts +3 -3
- package/src/auth/local-auth/local-auth.dto.ts +10 -1
- package/src/auth/microsoft/ms-auth.controller.ts +2 -2
- package/src/auth/yandex/yandex-auth.controller.ts +2 -2
- package/src/jwt-auth/jwt-auth.config.ts +8 -5
- package/src/jwt-auth/jwt-auth.module.ts +2 -0
- package/src/jwt-auth/jwt-auth.refresh.guard.ts +7 -0
- package/src/jwt-auth/jwt-auth.refresh.srtategy.ts +85 -0
- package/src/jwt-auth/jwt-auth.service.ts +37 -12
- package/src/jwt-auth/jwt-auth.srtategy.ts +10 -1
- package/src/revoked-tokens/entities/revoked-token.entity.ts +27 -1
- package/src/revoked-tokens/revoked-tokens.service.ts +43 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boarteam/boar-pack-users-backend",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "NestJS Users module including permissions system, authentication strategies etc",
|
|
5
5
|
"main": "src/index",
|
|
6
6
|
"files": [
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@boarteam/boar-pack-common-backend": "^3.0
|
|
17
|
+
"@boarteam/boar-pack-common-backend": "^3.1.0",
|
|
18
18
|
"@casl/ability": "^6.7.3",
|
|
19
19
|
"@dataui/crud": "^5.3.4",
|
|
20
20
|
"@dataui/crud-typeorm": "^5.3.4",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"lodash": "^4.17.21",
|
|
33
33
|
"moment": "^2.30.1",
|
|
34
34
|
"moment-timezone": "^0.5.47",
|
|
35
|
+
"ms": "^2.1.3",
|
|
35
36
|
"nestjs-joi": "^1.10.1",
|
|
36
37
|
"openapi-typescript-codegen": "^0.29.0",
|
|
37
38
|
"passport": "^0.7.0",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/bcrypt": "^5.0.2",
|
|
53
|
+
"@types/ms": "^2.1.0",
|
|
52
54
|
"@types/passport-google-oauth20": "^2.0.16",
|
|
53
55
|
"@types/passport-http-bearer": "^1.0.41",
|
|
54
56
|
"@types/passport-jwt": "^4.0.1",
|
|
@@ -62,5 +64,5 @@
|
|
|
62
64
|
"yalc:push": "yalc push",
|
|
63
65
|
"gen-types": "SWAGGER=true JWT_SECRET=swagger nest start"
|
|
64
66
|
},
|
|
65
|
-
"gitHead": "
|
|
67
|
+
"gitHead": "18738ad91a207f2961e7571eddc3b5a5c76430f2"
|
|
66
68
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Controller, NotFoundException, Param, Post, Req, Res, } from '@nestjs/common';
|
|
2
2
|
import { AuthService } from './auth.service';
|
|
3
|
-
import { tokenName } from './auth.constants';
|
|
4
3
|
import { ApiTags } from '@nestjs/swagger';
|
|
5
4
|
import type { Request, Response } from 'express';
|
|
6
5
|
import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
|
|
@@ -29,8 +28,8 @@ export default class AuthManageController {
|
|
|
29
28
|
throw new NotFoundException(`User with id ${userId} is not found`);
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
const
|
|
33
|
-
this.authService.setCookie(res,
|
|
34
|
-
return
|
|
31
|
+
const tokens = await this.authService.login(user);
|
|
32
|
+
this.authService.setCookie(res, tokens);
|
|
33
|
+
return tokens;
|
|
35
34
|
}
|
|
36
35
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
|
|
4
|
+
export type TAuthConfig = {
|
|
5
|
+
refreshTokenPath: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class AuthConfigService {
|
|
10
|
+
constructor(private configService: ConfigService) {
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get config(): TAuthConfig {
|
|
14
|
+
const refreshTokenPath = this.configService.get<string>('REFRESH_TOKEN_PATH', '/api/auth/refresh');
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
refreshTokenPath,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -2,9 +2,10 @@ import { Controller, Post, Req, Res, UnauthorizedException, UseGuards } from '@n
|
|
|
2
2
|
import { ApiTags } from '@nestjs/swagger';
|
|
3
3
|
import type { Request, Response } from 'express';
|
|
4
4
|
import { AuthService } from './auth.service';
|
|
5
|
-
import { JwtAuthGuard } from '../jwt-auth/jwt-auth.guard';
|
|
5
|
+
import { JwtAuthGuard, SkipJWTGuard } from '../jwt-auth/jwt-auth.guard';
|
|
6
6
|
import { SkipPoliciesGuard } from '../casl/policies.guard';
|
|
7
7
|
import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
|
|
8
|
+
import { JwtAuthRefreshGuard } from "../jwt-auth/jwt-auth.refresh.guard";
|
|
8
9
|
|
|
9
10
|
@SkipPoliciesGuard()
|
|
10
11
|
@ApiTags('Authentication')
|
|
@@ -33,6 +34,21 @@ export default class AuthController {
|
|
|
33
34
|
await this.authService.logout(req.jwt);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
this.authService.
|
|
37
|
+
this.authService.clearCookies(res);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@SkipJWTGuard()
|
|
41
|
+
@UseGuards(JwtAuthRefreshGuard)
|
|
42
|
+
@Post('refresh')
|
|
43
|
+
async refresh(
|
|
44
|
+
@Req() req: Request,
|
|
45
|
+
@Res({ passthrough: true }) res: Response,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
if (!req.user) {
|
|
48
|
+
throw new UnauthorizedException(`User is not authorized`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tokens = await this.authService.login(req.user);
|
|
52
|
+
this.authService.setCookie(res, tokens);
|
|
37
53
|
}
|
|
38
54
|
}
|
package/src/auth/auth.module.ts
CHANGED
|
@@ -19,6 +19,7 @@ import LocalAuthController from "./local-auth/local-auth.controller";
|
|
|
19
19
|
import { YandexAuthStrategy } from "./yandex/yandex-auth.strategy";
|
|
20
20
|
import { YandexAuthConfigService } from "./yandex/yandex-auth.config";
|
|
21
21
|
import YandexAuthController from "./yandex/yandex-auth.controller";
|
|
22
|
+
import { AuthConfigService } from "./auth.config";
|
|
22
23
|
|
|
23
24
|
@Module({})
|
|
24
25
|
export class AuthModule {
|
|
@@ -45,6 +46,7 @@ export class AuthModule {
|
|
|
45
46
|
],
|
|
46
47
|
providers: [
|
|
47
48
|
AuthService,
|
|
49
|
+
AuthConfigService,
|
|
48
50
|
{
|
|
49
51
|
provide: APP_GUARD,
|
|
50
52
|
useClass: JwtAuthGuard,
|
|
@@ -102,6 +104,7 @@ export class AuthModule {
|
|
|
102
104
|
}),
|
|
103
105
|
],
|
|
104
106
|
providers: [
|
|
107
|
+
AuthConfigService,
|
|
105
108
|
AuthService,
|
|
106
109
|
{
|
|
107
110
|
provide: APP_GUARD,
|
package/src/auth/auth.service.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Injectable, Logger } from '@nestjs/common';
|
|
2
2
|
import { TUser, UsersService } from '../users';
|
|
3
|
-
import { JWTAuthService, TJWTPayload } from '../jwt-auth';
|
|
3
|
+
import { JWTAuthService, TJWTPayload, TJWTRefreshPayload } from '../jwt-auth';
|
|
4
4
|
import bcrypt from 'bcrypt';
|
|
5
5
|
import { LocalAuthTokenDto } from "./local-auth/local-auth.dto";
|
|
6
6
|
import { Response } from 'express';
|
|
7
|
-
import { tokenName } from "./auth.constants";
|
|
7
|
+
import { refreshTokenName, tokenName } from "./auth.constants";
|
|
8
|
+
import { AuthConfigService, TAuthConfig } from "./auth.config";
|
|
9
|
+
import { TOKEN_TYPE } from "../revoked-tokens";
|
|
8
10
|
|
|
9
11
|
declare global {
|
|
10
12
|
namespace Express {
|
|
@@ -18,11 +20,15 @@ declare global {
|
|
|
18
20
|
@Injectable()
|
|
19
21
|
export class AuthService {
|
|
20
22
|
private readonly logger = new Logger(AuthService.name);
|
|
23
|
+
private readonly config: TAuthConfig;
|
|
21
24
|
|
|
22
25
|
constructor(
|
|
23
26
|
private usersService: UsersService,
|
|
24
27
|
private jwtAuthService: JWTAuthService,
|
|
25
|
-
|
|
28
|
+
private readonly authConfigService: AuthConfigService,
|
|
29
|
+
) {
|
|
30
|
+
this.config = this.authConfigService.config;
|
|
31
|
+
}
|
|
26
32
|
|
|
27
33
|
async validateUser(email: string, pass: string): Promise<TUser | null> {
|
|
28
34
|
const user = await this.usersService.findByEmail(email);
|
|
@@ -52,27 +58,65 @@ export class AuthService {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
async login(user: Pick<TUser, 'email' | 'id'>): Promise<LocalAuthTokenDto> {
|
|
55
|
-
const
|
|
61
|
+
const sid = this.jwtAuthService.generateJwtId();
|
|
62
|
+
const payload: TJWTPayload = {
|
|
63
|
+
email: user.email,
|
|
64
|
+
sub: user.id,
|
|
65
|
+
sid,
|
|
66
|
+
};
|
|
67
|
+
const refreshPayload: TJWTRefreshPayload = {
|
|
68
|
+
sub: user.id,
|
|
69
|
+
sid,
|
|
70
|
+
}
|
|
56
71
|
return {
|
|
57
|
-
accessToken: this.jwtAuthService.sign(payload),
|
|
72
|
+
accessToken: this.jwtAuthService.sign(payload, TOKEN_TYPE.ACCESS),
|
|
73
|
+
refreshToken: this.jwtAuthService.sign(refreshPayload, TOKEN_TYPE.REFRESH),
|
|
58
74
|
};
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
async logout(jwt: TJWTPayload): Promise<void> {
|
|
62
78
|
if (!jwt.jti || !jwt.exp) {
|
|
63
|
-
this.logger.warn('JWT does not have JTI or exp, cannot
|
|
79
|
+
this.logger.warn('JWT does not have JTI or exp, cannot revoke it');
|
|
64
80
|
return;
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
await this.jwtAuthService.revokeToken(
|
|
83
|
+
await this.jwtAuthService.revokeToken({
|
|
84
|
+
jti: jwt.jti,
|
|
85
|
+
expiresAt: new Date(jwt.exp * 1000),
|
|
86
|
+
tokenType: TOKEN_TYPE.ACCESS,
|
|
87
|
+
sid: jwt.sid || null,
|
|
88
|
+
});
|
|
68
89
|
this.logger.log(`User with id ${jwt.sub} has been logged out and token revoked`);
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
public setCookie(res: Response,
|
|
72
|
-
res.cookie(tokenName, token, {
|
|
92
|
+
public setCookie(res: Response, tokens: LocalAuthTokenDto): void {
|
|
93
|
+
res.cookie(tokenName, tokens.accessToken.token, {
|
|
94
|
+
httpOnly: true,
|
|
95
|
+
secure: process.env.SECURE_COOKIE === 'true',
|
|
96
|
+
sameSite: 'lax',
|
|
97
|
+
maxAge: tokens.accessToken.payload.exp && Math.max((tokens.accessToken.payload.exp * 1000) - Date.now(), 0),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
res.cookie(refreshTokenName, tokens.refreshToken.token, {
|
|
101
|
+
httpOnly: true,
|
|
102
|
+
secure: process.env.SECURE_COOKIE === 'true',
|
|
103
|
+
sameSite: 'lax',
|
|
104
|
+
maxAge: tokens.refreshToken.payload.exp && Math.max((tokens.refreshToken.payload.exp * 1000) - Date.now(), 0),
|
|
105
|
+
path: this.config.refreshTokenPath,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public clearCookies(res: Response): void {
|
|
110
|
+
res.clearCookie(tokenName, {
|
|
111
|
+
httpOnly: true,
|
|
112
|
+
secure: process.env.SECURE_COOKIE === 'true',
|
|
113
|
+
sameSite: 'lax',
|
|
114
|
+
});
|
|
115
|
+
res.clearCookie(refreshTokenName, {
|
|
73
116
|
httpOnly: true,
|
|
74
117
|
secure: process.env.SECURE_COOKIE === 'true',
|
|
75
118
|
sameSite: 'lax',
|
|
119
|
+
path: this.config.refreshTokenPath,
|
|
76
120
|
});
|
|
77
121
|
}
|
|
78
122
|
}
|
|
@@ -32,8 +32,8 @@ export default class GoogleAuthController {
|
|
|
32
32
|
throw new UnauthorizedException(`User is not authorized`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const
|
|
36
|
-
this.authService.setCookie(res,
|
|
35
|
+
const tokens = await this.authService.login(req.user);
|
|
36
|
+
this.authService.setCookie(res, tokens);
|
|
37
37
|
res.redirect('/');
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -26,8 +26,8 @@ export default class LocalAuthController {
|
|
|
26
26
|
if (!req.user) {
|
|
27
27
|
throw new UnauthorizedException(`User is not authorized`);
|
|
28
28
|
}
|
|
29
|
-
const
|
|
30
|
-
this.authService.setCookie(res,
|
|
31
|
-
return
|
|
29
|
+
const tokens = await this.authService.login(req.user);
|
|
30
|
+
this.authService.setCookie(res, tokens);
|
|
31
|
+
return tokens;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
import { TJWTPayload, TJWTRefreshPayload } from "../../jwt-auth";
|
|
2
|
+
|
|
1
3
|
export class LocalAuthLoginDto {
|
|
2
4
|
email: string;
|
|
3
5
|
password: string;
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
export class LocalAuthTokenDto {
|
|
7
|
-
accessToken:
|
|
9
|
+
accessToken: {
|
|
10
|
+
token: string;
|
|
11
|
+
payload: TJWTPayload;
|
|
12
|
+
};
|
|
13
|
+
refreshToken: {
|
|
14
|
+
token: string;
|
|
15
|
+
payload: TJWTRefreshPayload;
|
|
16
|
+
}
|
|
8
17
|
}
|
|
@@ -33,8 +33,8 @@ export default class MsAuthController {
|
|
|
33
33
|
throw new UnauthorizedException(`User is not authorized`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
this.authService.setCookie(res,
|
|
36
|
+
const tokens = await this.authService.login(req.user);
|
|
37
|
+
this.authService.setCookie(res, tokens);
|
|
38
38
|
res.redirect('/');
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -32,8 +32,8 @@ export default class YandexAuthController {
|
|
|
32
32
|
throw new UnauthorizedException(`User is not authorized`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const
|
|
36
|
-
this.authService.setCookie(res,
|
|
35
|
+
const tokens = await this.authService.login(req.user);
|
|
36
|
+
this.authService.setCookie(res, tokens);
|
|
37
37
|
res.redirect('/');
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Injectable } from '@nestjs/common';
|
|
2
2
|
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { StringValue } from "ms";
|
|
3
4
|
|
|
4
5
|
export type TJWTAuthConfig = {
|
|
5
6
|
jwtSecret: string;
|
|
7
|
+
accessTokenExpiration: StringValue;
|
|
8
|
+
refreshTokenExpiration: StringValue;
|
|
6
9
|
};
|
|
7
10
|
|
|
8
11
|
@Injectable()
|
|
@@ -11,14 +14,14 @@ export class JWTAuthConfigService {
|
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
get config(): TJWTAuthConfig {
|
|
14
|
-
const jwtSecret = this.configService.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
throw new Error('JWT_SECRET is not defined');
|
|
18
|
-
}
|
|
17
|
+
const jwtSecret = this.configService.getOrThrow<string>('JWT_SECRET');
|
|
18
|
+
const accessTokenExpiration = this.configService.get<StringValue>('ACCESS_TOKEN_EXPIRATION', '1h');
|
|
19
|
+
const refreshTokenExpiration = this.configService.get<StringValue>('REFRESH_TOKEN_EXPIRATION', '7d');
|
|
19
20
|
|
|
20
21
|
return {
|
|
21
22
|
jwtSecret,
|
|
23
|
+
accessTokenExpiration,
|
|
24
|
+
refreshTokenExpiration,
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -9,6 +9,7 @@ import { JWTAuthConfigService } from "./jwt-auth.config";
|
|
|
9
9
|
import { PassportModule } from "@nestjs/passport";
|
|
10
10
|
import { JWTAuthService } from "./jwt-auth.service";
|
|
11
11
|
import { RevokedTokensModule } from '../revoked-tokens';
|
|
12
|
+
import { JwtAuthRefreshStrategy } from "./jwt-auth.refresh.srtategy";
|
|
12
13
|
|
|
13
14
|
@Module({})
|
|
14
15
|
export class JwtAuthModule {
|
|
@@ -38,6 +39,7 @@ export class JwtAuthModule {
|
|
|
38
39
|
providers: [
|
|
39
40
|
JWTAuthConfigService,
|
|
40
41
|
JwtAuthStrategy,
|
|
42
|
+
JwtAuthRefreshStrategy,
|
|
41
43
|
JWTAuthService,
|
|
42
44
|
{
|
|
43
45
|
provide: APP_GUARD,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
2
|
+
import { PassportStrategy } from '@nestjs/passport';
|
|
3
|
+
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|
4
|
+
import { JWTAuthConfigService } from './jwt-auth.config';
|
|
5
|
+
import { Request } from 'express';
|
|
6
|
+
import { JWT_AUTH_REFRESH } from '../auth/auth-strategies.constants';
|
|
7
|
+
import { refreshTokenName } from "../auth/auth.constants";
|
|
8
|
+
import { UsersService } from '../users';
|
|
9
|
+
import { RevokedTokensService, TOKEN_TYPE } from '../revoked-tokens';
|
|
10
|
+
import { TJWTRefreshPayload } from "./jwt-auth.srtategy";
|
|
11
|
+
import ms from "ms";
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class JwtAuthRefreshStrategy extends PassportStrategy(Strategy, JWT_AUTH_REFRESH) {
|
|
15
|
+
private readonly logger = new Logger(JwtAuthRefreshStrategy.name);
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private usersService: UsersService,
|
|
19
|
+
private jwtAuthConfigService: JWTAuthConfigService,
|
|
20
|
+
private revokedTokensService: RevokedTokensService,
|
|
21
|
+
) {
|
|
22
|
+
super({
|
|
23
|
+
jwtFromRequest: ExtractJwt.fromExtractors([
|
|
24
|
+
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
25
|
+
(req: Request) => {
|
|
26
|
+
const cookies = req.headers.cookie?.split('; ');
|
|
27
|
+
if (!cookies) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cookie = cookies.find(c => c.startsWith(`${refreshTokenName}=`));
|
|
32
|
+
if (!cookie) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return cookie.split('=')[1];
|
|
37
|
+
},
|
|
38
|
+
]),
|
|
39
|
+
ignoreExpiration: false,
|
|
40
|
+
secretOrKey: jwtAuthConfigService.config.jwtSecret,
|
|
41
|
+
passReqToCallback: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async validate(payload: TJWTRefreshPayload) {
|
|
46
|
+
this.logger.debug(`Validating refresh token for user with id: ${payload.sub}`);
|
|
47
|
+
|
|
48
|
+
// Check if a token has been revoked
|
|
49
|
+
if (!payload.jti) {
|
|
50
|
+
this.logger.error('Refresh token payload does not contain JTI');
|
|
51
|
+
throw new UnauthorizedException('Invalid token payload');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti, payload.sid);
|
|
55
|
+
if (isRevoked) {
|
|
56
|
+
this.logger.debug(`Refresh token with JTI ${payload.jti} has already been revoked`);
|
|
57
|
+
throw new UnauthorizedException('Token has been revoked');
|
|
58
|
+
} else {
|
|
59
|
+
// Refresh token is valid, we revoke it to prevent reuse
|
|
60
|
+
this.logger.debug(`Revoking refresh token with JTI ${payload.jti}`);
|
|
61
|
+
await this.revokedTokensService.revokeRefreshToken({
|
|
62
|
+
jti: payload.jti,
|
|
63
|
+
expiresAt: new Date(
|
|
64
|
+
payload.exp
|
|
65
|
+
? payload.exp * 1000
|
|
66
|
+
: Date.now() + ms(this.jwtAuthConfigService.config.refreshTokenExpiration)
|
|
67
|
+
),
|
|
68
|
+
sid: payload.sid || null,
|
|
69
|
+
tokenType: TOKEN_TYPE.REFRESH,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const userId = payload.sub;
|
|
74
|
+
const user = await this.usersService.findOne({
|
|
75
|
+
select: ['id', 'email', 'role', 'permissions'],
|
|
76
|
+
where: { id: userId },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!user) {
|
|
80
|
+
throw new UnauthorizedException();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return user;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,34 +1,59 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common";
|
|
2
2
|
import { JwtService } from "@nestjs/jwt";
|
|
3
|
-
import { TJWTPayload } from "./jwt-auth.srtategy";
|
|
4
|
-
import { RevokedTokensService } from '../revoked-tokens';
|
|
3
|
+
import { TJWTPayload, TJWTRefreshPayload } from "./jwt-auth.srtategy";
|
|
4
|
+
import { RevokedToken, RevokedTokensService, TOKEN_TYPE, TRevokedToken } from '../revoked-tokens';
|
|
5
5
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import { JWTAuthConfigService, TJWTAuthConfig } from "./jwt-auth.config";
|
|
7
|
+
import { JwtSignOptions } from "@nestjs/jwt/dist/interfaces";
|
|
8
|
+
import ms from 'ms';
|
|
6
9
|
|
|
7
10
|
@Injectable()
|
|
8
11
|
export class JWTAuthService {
|
|
12
|
+
private readonly config: TJWTAuthConfig;
|
|
13
|
+
|
|
9
14
|
constructor(
|
|
10
15
|
private readonly jwtService: JwtService,
|
|
11
16
|
private revokedTokensService: RevokedTokensService,
|
|
17
|
+
private readonly jwtAuthConfig: JWTAuthConfigService,
|
|
12
18
|
) {
|
|
19
|
+
this.config = this.jwtAuthConfig.config;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public generateJwtId(): string {
|
|
23
|
+
return uuidv4();
|
|
13
24
|
}
|
|
14
25
|
|
|
15
|
-
sign
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
public sign<PayloadType extends TJWTPayload | TJWTRefreshPayload>(
|
|
27
|
+
payload: PayloadType,
|
|
28
|
+
tokenType: TOKEN_TYPE,
|
|
29
|
+
) {
|
|
30
|
+
const expiresIn = tokenType === TOKEN_TYPE.ACCESS
|
|
31
|
+
? this.config.accessTokenExpiration
|
|
32
|
+
: this.config.refreshTokenExpiration;
|
|
33
|
+
const options: JwtSignOptions = {
|
|
34
|
+
expiresIn,
|
|
35
|
+
jwtid: this.generateJwtId(),
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
token: this.jwtService.sign(payload, options),
|
|
39
|
+
payload: {
|
|
40
|
+
...payload,
|
|
41
|
+
exp: Math.floor((ms(expiresIn) + Date.now()) / 1000),
|
|
42
|
+
jti: options.jwtid,
|
|
43
|
+
sid: payload.sid,
|
|
44
|
+
} as PayloadType,
|
|
45
|
+
};
|
|
20
46
|
}
|
|
21
47
|
|
|
22
|
-
decode<T = TJWTPayload>(token: string): T {
|
|
48
|
+
public decode<T = TJWTPayload>(token: string): T {
|
|
23
49
|
return this.jwtService.decode(token) as T;
|
|
24
50
|
}
|
|
25
51
|
|
|
26
52
|
/**
|
|
27
53
|
* Revoke a JWT token by its JTI
|
|
28
|
-
* @param
|
|
29
|
-
* @param expiresAt When the token naturally expires
|
|
54
|
+
* @param token The token to revoke
|
|
30
55
|
*/
|
|
31
|
-
async revokeToken(
|
|
32
|
-
return this.revokedTokensService.revokeToken(
|
|
56
|
+
public async revokeToken(token: TRevokedToken) {
|
|
57
|
+
return this.revokedTokensService.revokeToken(token, this.config.refreshTokenExpiration);
|
|
33
58
|
}
|
|
34
59
|
}
|
|
@@ -13,8 +13,17 @@ export type TJWTPayload = {
|
|
|
13
13
|
iat?: string; // Issued At
|
|
14
14
|
exp?: number; // Expiration Time
|
|
15
15
|
jti?: string; // JWT ID, used for revocation
|
|
16
|
+
sid?: string; // Session ID to identify the family of tokens
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
export type TJWTRefreshPayload = {
|
|
20
|
+
sub: string; // User ID
|
|
21
|
+
jti?: string; // JWT ID, used for revocation
|
|
22
|
+
iat?: number; // Issued At
|
|
23
|
+
exp?: number; // Expiration Time
|
|
24
|
+
sid?: string; // Session ID to identify the family of tokens
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
@Injectable()
|
|
19
28
|
export class JwtAuthStrategy extends PassportStrategy(Strategy, JWT_AUTH) {
|
|
20
29
|
private readonly logger = new Logger(JwtAuthStrategy.name);
|
|
@@ -51,7 +60,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, JWT_AUTH) {
|
|
|
51
60
|
|
|
52
61
|
// Check if a token has been revoked
|
|
53
62
|
if (payload.jti) {
|
|
54
|
-
const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti);
|
|
63
|
+
const isRevoked = await this.revokedTokensService.isTokenRevoked(payload.jti, payload.sid);
|
|
55
64
|
if (isRevoked) {
|
|
56
65
|
this.logger.debug(`Token with JTI ${payload.jti} has been revoked`);
|
|
57
66
|
throw new UnauthorizedException('Token has been revoked');
|
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
2
2
|
|
|
3
|
+
export enum TOKEN_TYPE {
|
|
4
|
+
ACCESS = 'access',
|
|
5
|
+
REFRESH = 'refresh',
|
|
6
|
+
SESSION = 'session',
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
@Entity('revoked_tokens')
|
|
10
|
+
@Index('IDX_REVOKED_TOKEN_SID_TYPE', ['sid', 'tokenType'])
|
|
4
11
|
export class RevokedToken {
|
|
5
12
|
@PrimaryGeneratedColumn('uuid')
|
|
6
13
|
id: string;
|
|
7
14
|
|
|
8
|
-
@Column({
|
|
15
|
+
@Column({
|
|
16
|
+
unique: true,
|
|
17
|
+
type: 'uuid',
|
|
18
|
+
})
|
|
9
19
|
@Index('IDX_REVOKED_TOKEN_JTI')
|
|
10
20
|
jti: string;
|
|
11
21
|
|
|
22
|
+
@Column({
|
|
23
|
+
type: 'uuid',
|
|
24
|
+
nullable: true,
|
|
25
|
+
default: null,
|
|
26
|
+
})
|
|
27
|
+
sid: string | null;
|
|
28
|
+
|
|
29
|
+
@Column({
|
|
30
|
+
type: 'enum',
|
|
31
|
+
enum: TOKEN_TYPE,
|
|
32
|
+
default: TOKEN_TYPE.ACCESS,
|
|
33
|
+
})
|
|
34
|
+
tokenType: TOKEN_TYPE;
|
|
35
|
+
|
|
12
36
|
@Column({
|
|
13
37
|
name: 'expires_at',
|
|
14
38
|
type: 'timestamp with time zone',
|
|
@@ -22,3 +46,5 @@ export class RevokedToken {
|
|
|
22
46
|
})
|
|
23
47
|
createdAt: Date;
|
|
24
48
|
}
|
|
49
|
+
|
|
50
|
+
export type TRevokedToken = Omit<RevokedToken, 'id' | 'createdAt'>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Injectable, Logger } from '@nestjs/common';
|
|
2
2
|
import { Repository } from 'typeorm';
|
|
3
|
-
import { RevokedToken } from './entities/revoked-token.entity';
|
|
3
|
+
import { RevokedToken, TOKEN_TYPE, TRevokedToken } from './entities/revoked-token.entity';
|
|
4
4
|
import { Cron, CronExpression } from "@nestjs/schedule";
|
|
5
|
+
import ms, { StringValue } from "ms";
|
|
6
|
+
import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere";
|
|
5
7
|
|
|
6
8
|
@Injectable()
|
|
7
9
|
export class RevokedTokensService {
|
|
@@ -11,31 +13,59 @@ export class RevokedTokensService {
|
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Revokes a JWT token by storing its JTI in the database
|
|
14
|
-
* @param
|
|
15
|
-
* @param
|
|
16
|
+
* @param token The token to revoke, containing at least the JTI and expiration date
|
|
17
|
+
* @param refreshTokenExpiration The expiration time for the refresh token. We use it to set the expiration date of
|
|
18
|
+
* the session token.
|
|
16
19
|
*/
|
|
17
|
-
async revokeToken(
|
|
18
|
-
|
|
20
|
+
public async revokeToken(token: TRevokedToken, refreshTokenExpiration: StringValue): Promise<void> {
|
|
21
|
+
const tokens: TRevokedToken[] = [token];
|
|
22
|
+
|
|
23
|
+
if (token.sid) {
|
|
24
|
+
tokens.push({
|
|
25
|
+
jti: token.sid,
|
|
26
|
+
sid: token.sid,
|
|
27
|
+
expiresAt: new Date(Date.now() + ms(refreshTokenExpiration)),
|
|
28
|
+
tokenType: TOKEN_TYPE.SESSION,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await this.revokeTokens(tokens);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public async revokeRefreshToken(token: TRevokedToken): Promise<void> {
|
|
36
|
+
await this.revokeTokens([token]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private revokeTokens(tokens: TRevokedToken[]): Promise<void> {
|
|
40
|
+
return this.revokedTokenRepository
|
|
19
41
|
.createQueryBuilder()
|
|
20
42
|
.insert()
|
|
21
43
|
.into(RevokedToken)
|
|
22
|
-
.values(
|
|
44
|
+
.values(tokens)
|
|
23
45
|
.orIgnore()
|
|
24
|
-
.execute()
|
|
25
|
-
|
|
26
|
-
|
|
46
|
+
.execute()
|
|
47
|
+
.then(() => {
|
|
48
|
+
this.logger.debug(`Tokens with JTI ${tokens.map(t => t.jti).join(', ')} have been revoked`);
|
|
49
|
+
});
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
/**
|
|
30
53
|
* Checks if a token has been revoked
|
|
31
54
|
* @param jti The JWT token identifier
|
|
55
|
+
* @param sid Optional session identifier, used for session tokens
|
|
32
56
|
* @returns true if the token is revoked, false otherwise
|
|
33
57
|
*/
|
|
34
|
-
async isTokenRevoked(jti: string): Promise<boolean> {
|
|
35
|
-
const
|
|
36
|
-
|
|
58
|
+
public async isTokenRevoked(jti: string, sid?: string): Promise<boolean> {
|
|
59
|
+
const whereConditions: FindOptionsWhere<RevokedToken>[] = [{ jti }];
|
|
60
|
+
|
|
61
|
+
if (sid) {
|
|
62
|
+
whereConditions.push({ sid, tokenType: TOKEN_TYPE.SESSION });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tokensCount = await this.revokedTokenRepository.count({
|
|
66
|
+
where: whereConditions,
|
|
37
67
|
});
|
|
38
|
-
return
|
|
68
|
+
return tokensCount > 0;
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
@Cron(CronExpression.EVERY_HOUR)
|