Skip to content

Commit 8103aac

Browse files
[update] 5st series (Implementing Refresh Tokens & Storing the newly generated Refresh Token in a key-value store database such as Redis & Invalidating Token after its subsequent use) :
+ add .env.sample file with configuration placeholders + update JWT configuration to include refresh token TTL + add refresh token functionality to AuthController and AuthService + add Redis service to docker-compose.yml and update npm dependencies + add refresh token storage and validation
1 parent efbcb24 commit 8103aac

10 files changed

+203
-19
lines changed

.env.sample

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Database Configuration
2+
MONGODB_NAME=database_name # Replace 'database_name' with your MongoDB database name
3+
MONGODB_URI=mongodb://localhost:27017/db_name # Replace 'db_name' with your MongoDB database name; use the correct URI
4+
5+
# JWT Configuration
6+
JWT_SECRET=your_secret_key # Generate a secret key using a random string generator
7+
JWT_TOKEN_AUDIENCE=your_domain # Replace 'your_domain' with your domain, e.g., 'localhost:3000'
8+
JWT_TOKEN_ISSUER=your_domain # Replace 'your_domain' with your domain, e.g., 'localhost:3000'
9+
JWT_ACCESS_TOKEN_TTL=access_token_ttl # Set access token TTL in seconds, e.g., '3600' for 1 hour
10+
JWT_REFRESH_TOKEN_TTL=refresh_token_ttl # Set refresh token TTL in seconds, e.g., '604800' for 1 week

docker-compose.yml

+6-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@ services:
88
ports: # expose ports in “host:container” format
99
- 27017:27017
1010
environment: #env variables to pass into the container
11-
MONGODB_DATABASE: ${MONGODB_NAME} # DB name as per the environment file
11+
MONGODB_DATABASE: ${MONGODB_NAME} # DB name as per the environment file
12+
redis:
13+
image: redis
14+
ports:
15+
- '6379:6379'
16+
restart: always

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"bcrypt": "^5.1.1",
3131
"class-transformer": "^0.5.1",
3232
"class-validator": "^0.14.1",
33+
"ioredis": "^5.4.1",
3334
"mongoose": "^8.8.1",
3435
"reflect-metadata": "^0.2.0",
3536
"rxjs": "^7.8.1"

src/iam/auth/auth.controller.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Body, Controller, Post } from '@nestjs/common';
22
import { AuthService } from './auth.service';
3-
import { SignUpDto } from './dto/sign-up.dto';
43
import { SignInDto } from './dto/sign-in.dto';
4+
import { SignUpDto } from './dto/sign-up.dto';
55
import { Auth } from '../decorators/auth.decorator';
66
import { AuthType } from '../enums/auth-type.enum';
7+
import { RefreshTokenDto } from './dto/refresh-token.dto';
78

89
@Auth(AuthType.None)
910
@Controller('auth')
@@ -19,4 +20,9 @@ export class AuthController {
1920
signIn(@Body() signInDto: SignInDto) {
2021
return this.authService.signIn(signInDto);
2122
}
23+
24+
@Post('refresh-tokens')
25+
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
26+
return this.authService.refreshTokens(refreshTokenDto);
27+
}
2228
}

src/iam/auth/auth.service.ts

+69-14
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
UnauthorizedException,
66
} from '@nestjs/common';
77
import { InjectModel } from '@nestjs/mongoose';
8-
import { Model } from 'mongoose';
8+
import { Model, Types } from 'mongoose';
99
import { User } from 'src/users/entities/user.entity';
1010
import { HashingService } from '../hashing/hashing.service';
1111
import { SignUpDto } from './dto/sign-up.dto';
@@ -14,6 +14,9 @@ import { JwtService } from '@nestjs/jwt';
1414
import { ConfigType } from '@nestjs/config';
1515
import jwtConfig from '../config/jwt.config';
1616
import { ActiveUserData } from '../interfaces/active-user.data.interface';
17+
import { RefreshTokenDto } from './dto/refresh-token.dto';
18+
import { RefreshTokenIdsStorage } from '../storage/refresh-token-ids.storage/refresh-token-ids.storage';
19+
import { randomUUID } from 'crypto';
1720

1821
@Injectable()
1922
export class AuthService {
@@ -23,6 +26,7 @@ export class AuthService {
2326
private readonly jwtService: JwtService,
2427
@Inject(jwtConfig.KEY)
2528
private readonly jwtConfiguration: ConfigType<typeof jwtConfig>,
29+
private readonly refreshTokenIdsStorage: RefreshTokenIdsStorage,
2630
) {}
2731

2832
async signUp(signUpDto: SignUpDto): Promise<User> {
@@ -57,21 +61,72 @@ export class AuthService {
5761
if (!isEqual) {
5862
throw new UnauthorizedException('Password does not match');
5963
}
60-
const accessToken = await this.jwtService.signAsync(
61-
{
62-
sub: user.id,
63-
email: user.email,
64-
} as ActiveUserData,
65-
{
66-
audience: this.jwtConfiguration.audience,
67-
issuer: this.jwtConfiguration.issuer,
68-
secret: this.jwtConfiguration.secret,
69-
expiresIn: this.jwtConfiguration.accessTokenTtl,
70-
},
71-
);
72-
return { accessToken };
64+
const { accessToken, refreshToken } = await this.generateTokens(user);
65+
66+
return { accessToken, refreshToken };
7367
} catch (error) {
7468
return error;
7569
}
7670
}
71+
72+
async refreshTokens(refreshTokenDto: RefreshTokenDto) {
73+
try {
74+
const { sub, refreshTokenId } = await this.jwtService.verifyAsync<
75+
Pick<ActiveUserData, 'sub'> & { refreshTokenId: string }
76+
>(refreshTokenDto.refreshToken, {
77+
secret: this.jwtConfiguration.secret,
78+
audience: this.jwtConfiguration.audience,
79+
issuer: this.jwtConfiguration.issuer,
80+
});
81+
82+
const user = await this.userModel
83+
.findById({ _id: new Types.ObjectId(sub) })
84+
.exec();
85+
const isValid = await this.refreshTokenIdsStorage.validate(
86+
user.id,
87+
refreshTokenId,
88+
);
89+
if (isValid) {
90+
await this.refreshTokenIdsStorage.invalidate(user.id);
91+
} else {
92+
throw new Error('Invalid refresh token');
93+
}
94+
95+
return await this.generateTokens(user);
96+
} catch (error) {
97+
console.log(error);
98+
throw new UnauthorizedException('Invalid refresh token');
99+
}
100+
}
101+
102+
async generateTokens(user: User) {
103+
const refreshTokenId = randomUUID();
104+
const [accessToken, refreshToken] = await Promise.all([
105+
this.signToken<Partial<ActiveUserData>>(
106+
user.id,
107+
this.jwtConfiguration.accessTokenTtl,
108+
{ email: user.email },
109+
),
110+
this.signToken(user.id, this.jwtConfiguration.refreshTokenTtl, {
111+
refreshTokenId,
112+
}),
113+
]);
114+
this.refreshTokenIdsStorage.insert(user.id, refreshTokenId);
115+
return { accessToken, refreshToken };
116+
}
117+
118+
private async signToken<T>(userId: number, expiresIn: number, payload?: T) {
119+
return await this.jwtService.signAsync(
120+
{
121+
sub: userId,
122+
...payload,
123+
},
124+
{
125+
audience: this.jwtConfiguration.audience,
126+
issuer: this.jwtConfiguration.issuer,
127+
secret: this.jwtConfiguration.secret,
128+
expiresIn,
129+
},
130+
);
131+
}
77132
}

src/iam/auth/dto/refresh-token.dto.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { IsNotEmpty } from 'class-validator';
2+
3+
export class RefreshTokenDto {
4+
@IsNotEmpty()
5+
refreshToken: string;
6+
}

src/iam/config/jwt.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export default registerAs('jwt', () => {
66
audience: process.env.JWT_TOKEN_AUDIENCE,
77
issuer: process.env.JWT_TOKEN_ISSUER,
88
accessTokenTtl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600', 10),
9+
refreshTokenTtl: parseInt(process.env.JWT_REFRESH_TOKEN_TTL ?? '86400', 10),
910
};
1011
});

src/iam/iam.module.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { HashingService } from './hashing/hashing.service';
33
import { BcryptService } from './hashing/bcrypt.service';
44
import { AuthController } from './auth/auth.controller';
55
import { AuthService } from './auth/auth.service';
6-
import { MongooseModule } from '@nestjs/mongoose';
76
import { User, UserSchema } from 'src/users/entities/user.entity';
7+
import { MongooseModule } from '@nestjs/mongoose';
88
import { JwtModule } from '@nestjs/jwt';
99
import jwtConfig from './config/jwt.config';
1010
import { ConfigModule } from '@nestjs/config';
11-
import { APP_GUARD } from '@nestjs/core';
11+
import { AuthGuard } from './guards/auth/auth.guard';
1212
import { AccessTokenGuard } from './guards/access-token/access-token.guard';
13+
import { APP_GUARD } from '@nestjs/core';
14+
import { RefreshTokenIdsStorage } from './storage/refresh-token-ids.storage/refresh-token-ids.storage';
1315

1416
@Module({
1517
imports: [
@@ -22,7 +24,9 @@ import { AccessTokenGuard } from './guards/access-token/access-token.guard';
2224
provide: HashingService,
2325
useClass: BcryptService,
2426
},
25-
{ provide: APP_GUARD, useClass: AccessTokenGuard },
27+
{ provide: APP_GUARD, useClass: AuthGuard },
28+
AccessTokenGuard,
29+
RefreshTokenIdsStorage,
2630
AuthService,
2731
],
2832
controllers: [AuthController],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
2+
import { Redis } from 'ioredis';
3+
4+
export class RefreshTokenIdsStorage
5+
implements OnApplicationBootstrap, OnApplicationShutdown
6+
{
7+
private redisClient: Redis;
8+
onApplicationBootstrap() {
9+
this.redisClient = new Redis({
10+
host: 'localhost',
11+
port: 6379,
12+
});
13+
}
14+
15+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
16+
onApplicationShutdown(signal?: string) {
17+
this.redisClient.quit();
18+
}
19+
20+
async insert(userId: number, tokenId: string): Promise<void> {
21+
await this.redisClient.set(this.getKey(userId), tokenId);
22+
}
23+
24+
async validate(userId: number, tokenId: string): Promise<boolean> {
25+
const storedTokenId = await this.redisClient.get(this.getKey(userId));
26+
if (storedTokenId !== tokenId) {
27+
throw new Error('Invalid refresh token');
28+
}
29+
return storedTokenId === tokenId;
30+
}
31+
32+
async invalidate(userId: number): Promise<void> {
33+
await this.redisClient.del(this.getKey(userId));
34+
}
35+
36+
private getKey(userId: number): string {
37+
return `user-${userId}`;
38+
}
39+
}

yarn.lock

+57
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@
371371
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
372372
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
373373

374+
"@ioredis/commands@^1.1.1":
375+
version "1.2.0"
376+
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11"
377+
integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==
378+
374379
"@isaacs/cliui@^8.0.2":
375380
version "8.0.2"
376381
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@@ -1854,6 +1859,11 @@ clone@^1.0.2:
18541859
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
18551860
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
18561861

1862+
cluster-key-slot@^1.1.0:
1863+
version "1.1.2"
1864+
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
1865+
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
1866+
18571867
co@^4.6.0:
18581868
version "4.6.0"
18591869
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -2076,6 +2086,11 @@ delegates@^1.0.0:
20762086
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
20772087
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
20782088

2089+
denque@^2.1.0:
2090+
version "2.1.0"
2091+
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
2092+
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
2093+
20792094
20802095
version "2.0.0"
20812096
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@@ -2950,6 +2965,21 @@ [email protected]:
29502965
strip-ansi "^6.0.1"
29512966
wrap-ansi "^6.2.0"
29522967

2968+
ioredis@^5.4.1:
2969+
version "5.4.1"
2970+
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40"
2971+
integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==
2972+
dependencies:
2973+
"@ioredis/commands" "^1.1.1"
2974+
cluster-key-slot "^1.1.0"
2975+
debug "^4.3.4"
2976+
denque "^2.1.0"
2977+
lodash.defaults "^4.2.0"
2978+
lodash.isarguments "^3.1.0"
2979+
redis-errors "^1.2.0"
2980+
redis-parser "^3.0.0"
2981+
standard-as-callback "^2.1.0"
2982+
29532983
29542984
version "1.9.1"
29552985
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -3641,11 +3671,21 @@ locate-path@^6.0.0:
36413671
dependencies:
36423672
p-locate "^5.0.0"
36433673

3674+
lodash.defaults@^4.2.0:
3675+
version "4.2.0"
3676+
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
3677+
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
3678+
36443679
lodash.includes@^4.3.0:
36453680
version "4.3.0"
36463681
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
36473682
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
36483683

3684+
lodash.isarguments@^3.1.0:
3685+
version "3.1.0"
3686+
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
3687+
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
3688+
36493689
lodash.isboolean@^3.0.3:
36503690
version "3.0.3"
36513691
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
@@ -4355,6 +4395,18 @@ readdirp@~3.6.0:
43554395
dependencies:
43564396
picomatch "^2.2.1"
43574397

4398+
redis-errors@^1.0.0, redis-errors@^1.2.0:
4399+
version "1.2.0"
4400+
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
4401+
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
4402+
4403+
redis-parser@^3.0.0:
4404+
version "3.0.0"
4405+
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
4406+
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
4407+
dependencies:
4408+
redis-errors "^1.0.0"
4409+
43584410
reflect-metadata@^0.2.0:
43594411
version "0.2.2"
43604412
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b"
@@ -4634,6 +4686,11 @@ stack-utils@^2.0.3:
46344686
dependencies:
46354687
escape-string-regexp "^2.0.0"
46364688

4689+
standard-as-callback@^2.1.0:
4690+
version "2.1.0"
4691+
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
4692+
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
4693+
46374694
46384695
version "2.0.1"
46394696
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"

0 commit comments

Comments
 (0)