Skip to content

Commit ea4fd45

Browse files
authored
feat(Feat/refresh token): Implement refresh token (#29)
* Feat: Implement refresh token - Add refresh token column in user entity - Create refresh token related strategy and guard - The refresh token secret needs to be stored in an environment variable. * Feat: Add sign out function
1 parent 4dad58e commit ea4fd45

File tree

9 files changed

+161
-13
lines changed

9 files changed

+161
-13
lines changed

.example.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ JWT_PUBLIC_KEY= "-----BEGIN PUBLIC KEY-----
1313
YOUR_KEY
1414
-----END PUBLIC KEY-----"
1515

16+
JWT_REFRESH_TOKEN_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
17+
YOUR_KEY
18+
-----END RSA PRIVATE KEY-----"
19+
1620
AWS_S3_ACCESS_KEY=some_access_key
1721
AWS_S3_SECRET_KEY=some_secret_key
1822
AWS_S3_REGION=some_region

src/auth/auth.module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { UserModule } from 'src/user/user.module';
77

88
import { AuthResolver } from './auth.resolver';
99
import { AuthService } from './auth.service';
10+
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
1011
import { JwtStrategy } from './strategies/jwt.strategy';
1112
import { LocalStrategy } from './strategies/local.strategy';
1213

@@ -31,6 +32,12 @@ import { LocalStrategy } from './strategies/local.strategy';
3132
ConfigModule,
3233
UserModule,
3334
],
34-
providers: [AuthResolver, AuthService, JwtStrategy, LocalStrategy],
35+
providers: [
36+
AuthResolver,
37+
AuthService,
38+
JwtStrategy,
39+
LocalStrategy,
40+
JwtRefreshStrategy,
41+
],
3542
})
3643
export class AuthModule {}

src/auth/auth.resolver.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';
33

44
import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input';
55
import { CurrentUser } from 'src/common/decorators/user.decorator';
6+
import { RefreshGuard } from 'src/common/guards/graphql-refresh.guard';
67
import { SignInGuard } from 'src/common/guards/graphql-signin.guard';
78
import { User } from 'src/user/entities/user.entity';
9+
import { UserService } from 'src/user/user.service';
810

911
import { AuthService } from './auth.service';
1012
import { JwtWithUser } from './entities/auth._entity';
1113

1214
@Resolver()
1315
export class AuthResolver {
14-
constructor(private readonly authService: AuthService) {}
16+
constructor(
17+
private readonly authService: AuthService,
18+
private readonly userService: UserService,
19+
) {}
1520

1621
@Mutation(() => JwtWithUser)
1722
@UseGuards(SignInGuard)
@@ -23,4 +28,19 @@ export class AuthResolver {
2328
signUp(@Args('input') input: SignUpInput) {
2429
return this.authService.signUp(input);
2530
}
31+
32+
@Mutation(() => Boolean)
33+
@UseGuards(RefreshGuard)
34+
async signOut(@CurrentUser() user: User) {
35+
await this.userService.update(user.id, { refreshToken: null });
36+
return true;
37+
}
38+
39+
@Mutation(() => JwtWithUser)
40+
@UseGuards(RefreshGuard)
41+
refreshAccessToken(@CurrentUser() user: User) {
42+
const jwt = this.authService.generateAccessToken(user, user.refreshToken);
43+
44+
return { jwt, user };
45+
}
2646
}

src/auth/auth.service.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BadRequestException, Injectable } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
23
import { JwtService } from '@nestjs/jwt';
34

45
import * as bcrypt from 'bcrypt';
@@ -15,10 +16,43 @@ export class AuthService {
1516
constructor(
1617
private readonly userService: UserService,
1718
private readonly jwtService: JwtService,
19+
private readonly configService: ConfigService,
1820
) {}
1921

20-
private signJWT(user: User) {
21-
return this.jwtService.sign(pick(user, ['id', 'role']));
22+
private async generateRefreshToken(userId: string) {
23+
const refreshToken = this.jwtService.sign(
24+
{ id: userId },
25+
{
26+
secret: this.configService.get('JWT_REFRESH_TOKEN_PRIVATE_KEY'),
27+
expiresIn: '7d',
28+
},
29+
);
30+
await this.userService.update(userId, { refreshToken });
31+
32+
return refreshToken;
33+
}
34+
35+
async verifyRefreshToken(
36+
userId: string,
37+
refreshToken: string,
38+
): Promise<User> {
39+
try {
40+
this.jwtService.verify(refreshToken, {
41+
secret: this.configService.get('JWT_REFRESH_TOKEN_PRIVATE_KEY'),
42+
});
43+
return this.userService.getOne({ where: { id: userId, refreshToken } });
44+
} catch (err) {
45+
if (err.message === 'jwt expired') {
46+
this.userService.update(userId, { refreshToken: null });
47+
}
48+
}
49+
}
50+
51+
generateAccessToken(user: User, refreshToken: string) {
52+
return this.jwtService.sign({
53+
...pick(user, ['id', 'role']),
54+
refreshToken,
55+
});
2256
}
2357

2458
async signUp(input: SignUpInput): Promise<JwtWithUser> {
@@ -35,8 +69,9 @@ export class AuthService {
3569
return this.signIn(user);
3670
}
3771

38-
signIn(user: User) {
39-
const jwt = this.signJWT(user);
72+
async signIn(user: User) {
73+
const refreshToken = await this.generateRefreshToken(user.id);
74+
const jwt = this.generateAccessToken(user, refreshToken);
4075

4176
return { jwt, user };
4277
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { PassportStrategy } from '@nestjs/passport';
4+
5+
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
6+
7+
import { AuthService } from '../auth.service';
8+
9+
@Injectable()
10+
export class JwtRefreshStrategy extends PassportStrategy(
11+
Strategy,
12+
'jwt-refresh',
13+
) {
14+
constructor(
15+
private readonly configService: ConfigService,
16+
private readonly authService: AuthService,
17+
) {
18+
super({
19+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
20+
secretOrKey: configService.get('JWT_PUBLIC_KEY'),
21+
});
22+
}
23+
24+
async validate(
25+
payload: { id: string; refreshToken: string },
26+
done: VerifiedCallback,
27+
) {
28+
try {
29+
const userData = await this.authService.verifyRefreshToken(
30+
payload.id,
31+
payload.refreshToken,
32+
);
33+
34+
done(null, userData);
35+
} catch (err) {
36+
throw new UnauthorizedException('Error', err.message);
37+
}
38+
}
39+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ExecutionContext, Injectable } from '@nestjs/common';
2+
import { GqlExecutionContext } from '@nestjs/graphql';
3+
import { AuthGuard } from '@nestjs/passport';
4+
5+
@Injectable()
6+
export class RefreshGuard extends AuthGuard('jwt-refresh') {
7+
constructor() {
8+
super();
9+
}
10+
11+
getRequest(context: ExecutionContext) {
12+
const ctx = GqlExecutionContext.create(context);
13+
const req = ctx.getContext().req;
14+
return req;
15+
}
16+
}

src/graphql-schema.gql

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
input CreateUserInput {
66
nickname: String!
77
password: String!
8+
refreshToken: String
89
role: String!
910
username: String!
1011
}
@@ -15,7 +16,9 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
1516
scalar DateTime
1617

1718
input GetManyInput {
18-
"""count or data or all, default = data"""
19+
"""
20+
count or data or all, default = data
21+
"""
1922
dataType: String
2023

2124
"""
@@ -36,10 +39,14 @@ type GetUserType {
3639
}
3740

3841
input IPagination {
39-
"""Started from 0"""
42+
"""
43+
Started from 0
44+
"""
4045
page: Int!
4146

42-
"""Size of page"""
47+
"""
48+
Size of page
49+
"""
4350
size: Int!
4451
}
4552

@@ -57,7 +64,9 @@ type Mutation {
5764
createUser(input: CreateUserInput!): User!
5865
deleteFiles(keys: [String!]!): Boolean!
5966
deleteUser(id: String!): JSON!
67+
refreshAccessToken: JwtWithUser!
6068
signIn(input: SignInInput!): JwtWithUser!
69+
signOut: Boolean!
6170
signUp(input: SignUpInput!): JwtWithUser!
6271
updateUser(id: String!, input: UpdateUserInput!): JSON!
6372
uploadFile(file: Upload!): String!
@@ -84,18 +93,22 @@ input SignUpInput {
8493
input UpdateUserInput {
8594
nickname: String
8695
password: String
96+
refreshToken: String
8797
role: String
8898
username: String
8999
}
90100

91-
"""The `Upload` scalar type represents a file upload."""
101+
"""
102+
The `Upload` scalar type represents a file upload.
103+
"""
92104
scalar Upload
93105

94106
type User {
95107
createdAt: DateTime!
96108
id: ID!
97109
nickname: String!
110+
refreshToken: String
98111
role: String!
99112
updatedAt: DateTime!
100113
username: String!
101-
}
114+
}

src/user/entities/user.entity.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export class User {
4949
})
5050
updatedAt: Date;
5151

52+
@Field(() => String, { nullable: true })
53+
@Column({ nullable: true })
54+
refreshToken?: string;
55+
5256
@BeforeInsert()
5357
@BeforeUpdate()
5458
async beforeInsertOrUpdate() {

src/user/inputs/user.input.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Field, InputType } from '@nestjs/graphql';
22

33
import { IsNotEmpty, IsOptional } from 'class-validator';
44

5+
import { User } from '../entities/user.entity';
6+
57
@InputType()
6-
export class CreateUserInput {
8+
export class CreateUserInput implements Partial<User> {
79
@Field(() => String)
810
@IsNotEmpty()
911
username: string;
@@ -19,10 +21,14 @@ export class CreateUserInput {
1921
@Field(() => String)
2022
@IsNotEmpty()
2123
role: 'admin' | 'user';
24+
25+
@Field(() => String, { nullable: true })
26+
@IsOptional()
27+
refreshToken?: string;
2228
}
2329

2430
@InputType()
25-
export class UpdateUserInput {
31+
export class UpdateUserInput implements Partial<User> {
2632
@Field(() => String, { nullable: true })
2733
@IsOptional()
2834
username?: string;
@@ -38,6 +44,10 @@ export class UpdateUserInput {
3844
@Field(() => String, { nullable: true })
3945
@IsOptional()
4046
role?: 'admin' | 'user';
47+
48+
@Field(() => String, { nullable: true })
49+
@IsOptional()
50+
refreshToken?: string;
4151
}
4252

4353
@InputType()

0 commit comments

Comments
 (0)