NestJS를 사용해 카카오톡 알림 기능을 만들어보자!
이번에는 User에 AccessToken과 RefreshToken을 사용해보겠습니다!
Guard를 사용해서 AccessToken, RefreshToken을 관리해보겠습니다!
먼저 AccessToken와 RefreshToken을 관리하기 위해 필요한 패키지들을 전부 설치하고 시작하겠습니다.
$ yarn add passport @nestjs/passport @nestjs/jwt passport-jwt @types/passport-jwt cookie-parser @types/cookie-parser
AccessToken과 RefershToken 인증을 위한 Strategy를 정의하겠습니다.
먼저 AccessTokenStrategy부터 만들겠습니다.
// accessToken.strategy.ts
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { MyConfigType } from 'src/common/config/config.type';
import { User } from 'src/user/entity/user.entity';
import { CookieKeys, JwtPayload } from '../constant/auth.type';
import { UserService } from 'src/user/user.service';
@Injectable()
// 1번
export class AccessTokenStrategy extends PassportStrategy(
Strategy,
'jwt-access',
) {
// 2번
constructor(
@Inject(UserService) private readonly userService: UserService,
private configService: ConfigService<MyConfigType>,
) {
super({
secretOrKey: configService.get('JWT_ACCESS_SECRET'),
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req && req?.cookies[CookieKeys.ACCESS_TOKEN],
]),
passReqToCallback: true,
});
}
// 3번
async validate(req: Request, payload: JwtPayload): Promise<User> {
const { email } = payload;
const user = await this.userService.getUserByEmail(email);
if (!user || !user.refreshToken) throw new UnauthorizedException();
return user;
}
}
하나하나씩 코드를 살펴봅시다!
PassportStrategy
를 extend하는 AccessTokenStrategy
라는 전략을 만드는 코드입니다. 이때, 'jwt-access'
가 이 전략의 이름입니다.
생성자에 userService
와 configService
를 생성하는 로직을 작성합니다. userSerivce
는 로직 사용을 위해, configService
는 .env
파일을 사용하기 위해 선언합니다.
환경변수 ( configService
)에 선언해놓은 엑세스 토큰 시크릿 키를 AccessToken의 시크릿 키로 사용합니다.
jwtFromRequest
를 사용해 쿠키에서 CookieKeys
타입에 정의해놓은 엑세스 토큰의 이름으을 가진 쿠키를 가져오고 인증합니다.
인증을 거쳐 넘어온 데이터를 가지고 비즈니스 로직을 작성합니다.
req
와 payload
를 가져옵니다. getUserByEmail()
은 eamil을 사용해 DB에서 user를 하나 가져옵니다. 만약 user가 존재하지 않거나, user의 refreshToken
값이 null값이라면 Unauthorized
오류를 내보냅니다.
타입을 정의하기 위해 auth.type.ts
에 auth에서 사용할 type을 정해놓겠습니다.
//src/auth/constant/auth.type.ts
export type Tokens = {
accessToken: string;
refreshToken: string;
};
export interface JwtPayload {
email: string;
}
type RefreshTokenObj = { refreshToken: string };
export type JwtPayloadWithRefreshToken = JwtPayload & RefreshTokenObj;
export enum CookieKeys {
ACCESS_TOKEN = 'AppAT',
REFRESH_TOKEN = 'AppRT',
}
export type UserPayload = {
email: string;
}
다음은 RefreshTokenStrategy입니다.
// refreshToken.strategy
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { MyConfigType } from 'src/common/config/config.type';
import { Request } from 'express';
import { CookieKeys, JwtPayload, JwtPayloadWithRefreshToken,} from '../constant/auth.type';
@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(
private configService: ConfigService<MyConfigType>,
) {
super({
secretOrKey: configService.get('JWT_REFRESH_SECRET'),
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req && req?.cookies[CookieKeys.REFRESH_TOKEN],
]),
passReqToCallback: true,
});
}
async validate(
req: Request,
payload: JwtPayload,
): Promise<JwtPaylodWithRefreshToken> {
const refreshToken = req?.cookies[CookieKeys.REFRESH_TOKEN];
if (!refreshToken) throw new UnauthorizedException();
return { ...payload, refreshToken };
}
}
RefreshTokenStrategy코드도 AccesTokenStrategy코드와 비슷합니다.
Strategy를 정의하였으니, Guard에서 각 전략을 이용할 수 있도록 만들어줍니다.
AccessToken가드와 RefreshToken가드를 정의해주겠습니다.
// accessToken.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AccessTokenGuard extends AuthGuard('jwt-access') {}
// refreshToken.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}
Auth에서 인증에 필요한 로직을 관리하겠습니다.
Auth를 위한 AuthModule과 AuthService를 만들겠습니다.
$ nset g module auth
$ nset g service auth --no-spec
컨트롤러는 만들지 않겠습니다.
왜냐하면, UserController
같은 다른 컨트롤러의 핸들러에 가드를 달고 인증을 진행하는 구조로 만들 것 이기 때문입니다. 인증에 필요한 세세한 비즈니스 로직은 UserService
와 같은 Service단에서 AuthService
를 주입받아서 사용하겠습니다.
AuthModule
부터 정의하겠습니다!
//auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { TypeOrmExModule } from 'src/typeorm-ex.module';
import { UserRepository } from 'src/user/user.repository';
import { UserService } from 'src/user/user.service';
import { AccessTokenStrategy } from './strategy/accessToken.strategy';
import { RefreshTokenStrategy } from './strategy/refreshToken.strategy';
@Module({
imports: [
TypeOrmExModule.forCustomRepository([UserRepository]),
PassportModule.register({ defaultStrategy: 'jwt-access' }),
JwtModule.register({}),
],
providers: [
AuthService,
UserService,
JwtService,
AccessTokenStrategy,
RefreshTokenStrategy,
],
exports: [AuthService],
})
export class AuthModule { }
UserRepository
를 사용하기 위한 TypeOrmExModule
과 인증을 위한 PassportModule
, JsonWebToken
사용을 위한 JwtService
, 마지막으로 Strategy
를 사용하기 위해 AccessTokenStrategy
와 RefreshTokenStrategy
를 만들겠습니다. UserSerivce
는 Strategy
에서 사용하기 위해 주입합니다.
다음은 AuthService
입니다.
//auth.service.ts
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { CookieKeys, JwtPayload, JwtPayloadWithRefreshToken, Tokens } from './constant/auth.type';
import { AUTH_ERROR_MESSAGE } from './Error/auth.error.enum';
import { UserRepository } from 'src/user/user.repository';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { MyConfigType } from 'src/common/config/config.type';
import { ConfigService } from '@nestjs/config';
import { Response } from 'express';
import { UserLoginDto } from 'src/user/dto/user-login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private configService: ConfigService<MyConfigType>,
private readonly userRepository: UserRepository
) { }
async userSignIn(
userLoginDto: UserLoginDto,
response: Response,
): Promise<void> {
const { email, password } = userLoginDto;
const user = await this.userRepository.getUserByEmail(email);
const isValid = await bcrypt.compare(password, user.password);
if (user && isValid) {
const tokens = await this.getTokens(email);
await this.updateRefreshToken(email, tokens.refreshToken);
return this.setTokensToCookie(response, tokens);
}
throw new UnauthorizedException(AUTH_ERROR_MESSAGE.INVALID_CREDENTIAL);
}
async refreshTokens(
{ email, refreshToken, }: Partial<JwtPayloadWithRefreshToken>,
response: Response,
): Promise<void> {
const user = await this.userRepository.getUserByEmail(email);
if (!user || !user.refreshToken)
throw new ForbiddenException(AUTH_ERROR_MESSAGE.NOT_LOGINED);
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isValid) throw new ForbiddenException(AUTH_ERROR_MESSAGE.INVALID_TOKEN);
const tokens = await this.getTokens(user.email);
await this.updateRefreshToken(user.email, tokens.refreshToken);
this.setTokensToCookie(response, tokens);
}
async logout(response: Response): Promise<void> {
response.clearCookie(CookieKeys.ACCESS_TOKEN);
response.clearCookie(CookieKeys.REFRESH_TOKEN);
}
async getTokens(email: string): Promise<Tokens> {
const payload: JwtPayload = { email };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: this.configService.get('ACCESS_TOKEN_EXPIRE_SEC') + 's',
}),
this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get('REFRESH_TOKEN_EXPIRE_SEC') + 's',
}),
]);
return { accessToken, refreshToken };
}
async updateRefreshToken(id: string, refreshToken: string) {
const hashed = await bcrypt.hash(refreshToken, 10);
await this.userRepository.updateRefreshToken(id, hashed);
}
setTokensToCookie(response: Response, tokens: Tokens): void {
response.cookie(CookieKeys.ACCESS_TOKEN, tokens.accessToken, {
httpOnly: true,
expires: this.getExpiredDate(
this.configService.get('ACCESS_TOKEN_EXPIRE_SEC'),
),
});
response.cookie(CookieKeys.REFRESH_TOKEN, tokens.refreshToken, {
httpOnly: true,
expires: this.getExpiredDate(
this.configService.get('REFRESH_TOKEN_EXPIRE_SEC'),
),
});
}
getExpiredDate(expireSec: number): Date {
return new Date(+new Date() + expireSec * 1000);
}
}
JwtService
와 UserRepository
를 이 곳에서 사용합니다.
각 코드는 이따가 실제로 사용합니다!
이제 user 컨트롤러로 넘어가서 로그인, accessToken이 있을 때 사용할 수 있는 핸들러, refeshToken을 사용해서 accessToken 재발급 등을 만들어보겠습니다.
가장 먼저 의존성 주입을 위해 UserModule부터 고쳐보겠습니다.
// user.module.ts
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TypeOrmExModule } from 'src/typeorm-ex.module';
import { UserRepository } from './user.repository';
import { AuthService } from 'src/auth/auth.service';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
@Module({
imports: [
TypeOrmExModule.forCustomRepository([UserRepository]),
PassportModule.register({ defaultStrategy: 'jwt-access' }),
JwtModule.register({}),
],
controllers: [UserController],
providers: [UserService, AuthService, JwtService],
exports: [UserService]
})
export class UserModule {}
AuthService
와 JwtService
를 주입받습니다.
PassportModule
과 JwtModule
을 import합니다.
다음은 UserController입니다.
// user.controller.ts
import { Controller, Get, Post, Body, UseGuards, Req, Res } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entity/user.entity';
import { UserLoginDto } from './dto/user-login.dto';
import { AccessTokenGuard } from 'src/auth/guard/accessToken.guard';
import { RefreshTokenGuard } from 'src/auth/guard/refreshToken.guard';
import { Request, Response } from 'express';
@Controller('user')
export class UserController {
constructor(
private readonly userService: UserService,
) { }
@Post('signUp')
async signUp(
@Body() createUserDto: CreateUserDto,
): Promise<void> {
return await this.userService.signUp(createUserDto);
}
@Post('signIn')
async signIn(
@Body() userLoginDto: UserLoginDto,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return await this.userService.signIn(userLoginDto, response);
}
@UseGuards(AccessTokenGuard)
@Get('allUsers')
async getAllUsers( ): Promise<User[]> {
return await this.userService.getAllUsers();
}
@UseGuards(RefreshTokenGuard)
@Post('refresh/test')
async refreshTest(
@Req() req: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return await this.userService.refreshTest(req, response);
}
@UseGuards(AccessTokenGuard)
@Get('logout')
async logout(
@Req() req,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
return this.userService.logout(req.user, response);
}
}
모든 유저 조회와 logout은 AccessTokenGuard
를, AccessToken 재발급은 RefreshTokenGuard
를 사용하겠습니다.
AccessTokenGuard
를 사용한 코드에서는 req.user를 반환하고, RefreshTokenGuard
를 사용한 코드에서는 req를 받습니다.
다음은 세부적인 로직 구현을 위해 UserService로 넘어가겠습니다.
//userService.ts
import { Inject, Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entity/user.entity';
import { UserLoginDto } from './dto/user-login.dto';
import { AuthService } from 'src/auth/auth.service';
import { Request, Response } from 'express';
import { JwtPayloadWithRefreshToken, UserPayload } from 'src/auth/constant/auth.type';
@Injectable()
export class UserService {
constructor(
@Inject(AuthService) private readonly authService: AuthService,
private readonly userRepository: UserRepository
) { }
async signUp(createUserDto: CreateUserDto): Promise<void> {
await this.userRepository.signUp(createUserDto);
return null;
}
async signIn(userLoginDto: UserLoginDto, response: Response): Promise<void> {
await this.authService.userSignIn(userLoginDto, response);
return await this.userRepository.signIn(userLoginDto);
}
async getAllUsers(): Promise<User[]> {
return await this.userRepository.getAllUsers();
}
async refreshTest(req, response: Response): Promise<void> {
return await this.authService.refreshTokens(req.user, response);
}
async logout(
{ email, refreshToken, }: Partial<JwtPayloadWithRefreshToken>,
res: Response,
): Promise<void> {
await this.authService.logout(res);
await this.userRepository.updateRefreshToken(email, null);
}
async getUserByEmail(email: string): Promise<User> {
return await this.userRepository.getUserByEmail(email);
}
}
UserService에서는 AuthService를 주입받아 사용합니다.
signIn
부터 살펴보겠습니다.
authService
의 userSignIn
메서드를 사용해 AccessToken과 RefreshToken을 발급합니다. 아까 살펴봤던 AuthService의 userSignIn
메서드를 확인해봅시다.
async userSignIn(
userLoginDto: UserLoginDto,
response: Response,
): Promise<void> {
const { email, password } = userLoginDto;
const user = await this.userRepository.getUserByEmail(email);
const isValid = await bcrypt.compare(password, user.password);
if (user && isValid) {
const tokens = await this.getTokens(email);
await this.updateRefreshToken(email, tokens.refreshToken);
return this.setTokensToCookie(response, tokens);
}
throw new UnauthorizedException(AUTH_ERROR_MESSAGE.INVALID_CREDENTIAL);
}
email을 사용해 user
를 검색해서 받고, DB에 저장된(해싱된) password와 사용자가 입력한 password를 해싱해서 비교하고 같으면 RefreshToken을 업데이트하고, Cookie에 AccessToken과 RefreshToken을 set합니다.
자세한 로직은 updateRefreshToken()
,setTokensToCookie
코드를 읽어봐주세요!
이렇게 하면 사용자의 cookie에 AppAT와 AppRT라는 이름으로 각각의 AccessToken과 RefreshToken이 들어갑니다.
다음은 getAllUsers()
메서드를 보겠습니다.
단순히 AccessTokenGuard를 통과한 user에게 DB에 저장되어있는 모든 user 데이터의 배열을 보내주는 로직입니다.
다음은 refreshTest()
입니다.
authService.refreshTokens()
를 사용합니다.
RefreshTokenGuard
를 통과하면 refreshToken을 사용해 AccessToken을 cookie에 새롭게 정의하는 코드입니다.
마지막으로 logout()
을 보겠습니다.
authService.logout()
메서드는 cookie에서 데이터를 지워주는 역할을 합니다.
userRepository.updateRefreshToken()
메서드는 email을 기준으로 user를 찾아 해당하는 user의 refreshToken값을 null로 바꿔줍니다.
이렇게 하면 모두 정상적으로 작동될 것입니다.
딱 한가지 쿠키를 사용할 수 있도록 cookieParser를 부트스트랩 단계에서 사용한다고 명시해줍시다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { MyConfigType } from './common/config/config.type';
import { VersioningType } from '@nestjs/common';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService: ConfigService<MyConfigType> = app.get(ConfigService);
app.use(cookieParser()); // 쿠키 사용
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
await app.listen(configService.get('PORT'));
}
bootstrap();
이제 포스트맨을 사용해서 잘 돌아가는지 확인해봅시다
AccessToken이 필요한 getAllUser를 요청하면 제대로 Unauthorized를 반환하는 것을 볼 수 있습니다.
로그인을 하고 진행해보겠습니다.
로그인을 하면 다음과 같이 쿠키가 잘 들어와있는 것을 볼 수 있습니다.
이 상태에서 다시금 getAllUser를 요청해보겠습니다.
잘 되는것을 볼 수 있습니다.
AccessToken의 유효기간이 끝났을 때, RefreshToken을 발급받아보겠습니다.
AccessToken의 유효기간이 끝나고, 쿠키에서의 유효시간이 끝나면서 AccessToken이 사라졌습니다.
이 때, 쿠키에 담겨져있는 RefreshToken을 사용해서 AccessToken을 다시 받아보면,
새로운 AccessToken을 받을 수 있습니다.
마지막으로 logout을 해보겠습니다.
로그아웃을 하면 쿠키에 아무것도 남지 않게되고, RefreshToken값이 null이 되도록 설정했습니다.
DB를 조회해서 제대로 null값이 들어갔는지 확인해보겠습니다.
두 데이터 모두 RefreshToken에 null값이 들어있는 것을 확인할 수 있습니다.
대체로 RefreshToken은 유효기간을 길게 설정해두고, DB에 저장해놓고 기간이 끝날때까지 그 데이터를 계속 사용하는 것으로 알고있습니다.
그런데 제가 이번에 만든 코드는 RefreshToken을 logout시에 null로 만들어주는 방법을 체택했습니다.
계속 DB에 저장해놨다가 login시에 DB의 RefreshToken값을 빼내서 cookie에 저장해준다는 방법이 있을 것 같다고 생각했지만, 그럴 때 RefreshToken의 유효기간을 어떻게 보장할지 잘 모르겠습니다.
아마 Token값 안에 기간도 있을 것 같다고 생각은 들지만, 제가 제대로 이해하지 못하고 있기 때문에 이런 의문점이 생기는 것 같습니다ㅠㅠ
또 한가지 아쉬운 점은 Strategy의 payload입니다. 아직 payload에 대한 이해가 잘 안되었습니다.
언젠가 한번 정리해서 새로운 글을 작성해야겠습니다!