사용자 데이터를 처리 할 수 있는 User 모듈을 구현했으니 이제 JWT를 사용한 로그인 시스템을 구현한다.
구현해야 할 로직은 다음과 같다.
1. id, pw를 통한 login Request시 access token과 refresh token를 반환한다.
2. access token을 포함한 리소스 요청 응답에 리소스를 반환한다.
3. 만약 access token이 만료되었다면 Unauthorized exception을 반환한다.
4. refresh token으로 refresh 요청시 새로운 access token을 반환한다.
일반적으로 인증(Authentication)을 구현할때에 우리는 크게 세션(session)과 토큰(token) 방식이 있다. 내가 세션이 아닌 토큰 방식을 채택하는 것은 다음과 같은 이유가 있다.
세션과 토큰의 차이점
세션과 토큰의 가장 큰 차이는 상태 관리에 있다고 생각한다. 세션은 서버의 메모리에 저장해 서버에서 상태 관리를 하는 반면, 토큰은 서버에서는 그 유효성만을 확인할 뿐 따로 관리는 하지 않는다.
refresh token을 도입한 이유는 보안상의 이유이다. 토큰 방식의 특성상 access token을 발급한 순간부터 서버에서 그것을 삭제하거나 할 수는 없다. 즉 유효기간이 만료될 때 까지 기다려야 한다는 것이다.
하지만 만약 그 토큰이 유출되었는데 아직 유효기간이 많이 남았다면 아주 안타까운 상황이 발생한다. 여기서 도입된 방식이 refresh이다.
access token의 유효기간은 아주 짧게 하고, refresh token의 유효 기간은 길게 해서 발급한 뒤 access token을 계속 재발급 시키는 것이다. 이렇게 하면 access token이 유출된다 하더라도 유효기간이 짧기에 안타까운 상황이 발생할 확률이 낮다.
$ npm install --save @nestjs/jwt @nestjs/passport passport-jwt
$ npm install --save-dev @types/passport-jwt
│ app.controller.ts
│ app.module.ts
│ app.service.ts
│ main.ts
│
├─auth
│ │ auth.controller.ts
│ │ auth.module.ts
│ │ auth.service.ts
│ │
│ ├─guard
│ │ accessToken.guard.ts
│ │ refreshToken.guard.ts
│ │
│ └─strategy
│ accessToken.strategy.ts
│ refreshToken.strategy.ts
│
├─caching
│ caching.module.ts
│
├─config-project
│ config-project.module.ts
│
├─constatns
│ cache.constants.ts
│
├─custom-provider
│ filter.provider.ts
│ model.provider.ts
│
├─dto
│ auth.dto.ts
│ dtoFunction.ts
│ user.dto.ts
│ webtoon.dto.ts
│
├─exception-filter
│ dtoException.filter.ts
│
├─sequelize
│ │ mysql_sequelize.module.ts
│ │
│ ├─config
│ │ config.json
│ │
│ ├─entity
│ │ user.model.ts
│ │ userWebtoon.model.ts
│ │ webtoon.model.ts
│ │
│ ├─migrations
│ ├─models
│ │ index.js
│ │
│ └─seeders
├─types
│ auth.type.ts
│ user.type.ts
│ webtoon.type.ts
│
├─user
│ user.controller.ts
│ user.module.ts
│ user.service.ts
│
└─webtoon
webtoon.controller.ts
webtoon.module.ts
webtoon.service.ts
Module
[auth.module.ts]
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { JwtAccessTokenStrategy } from './strategy/accessToken.strategy';
import { JwtRefreshTokenStrategy } from './strategy/refreshToken.strategy';
import { JwtAccessTokenGuard } from './guard/accessToken.guard';
import { JwtRefreshTokenGuard } from './guard/refreshToken.guard';
@Module({
imports: [
UserModule,
JwtModule.register({ global: true })
],
controllers: [AuthController],
providers: [
AuthService,
JwtAccessTokenStrategy,
JwtRefreshTokenStrategy,
JwtAccessTokenGuard,
JwtRefreshTokenGuard
]
})
export class AuthModule {}
위의 providers에서 access, refresh 토큰의 strategy와 guard를 모두 주입해주었는데 이렇게 하지 않으면 useGuards를 사용하면 에러가 발생한다.
Controller
[auth.controller.ts]
import { Body, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from 'src/dto/auth.dto';
import { Request, Response } from 'express';
import { JwtRefreshTokenGuard } from './guard/refreshToken.guard';
import { JwtAccessTokenGuard } from './guard/accessToken.guard';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// access token 검증 메서드
@UseGuards(JwtAccessTokenGuard)
@Get("test")
test() {
return "ahha";
}
@Post("login")
async login(@Body() loginDto: LoginDto, @Res({ passthrough: true }) res: Response) {
// access, refresh token 발급
const tokenData = await this.authService.login(loginDto);
// 쿠키에 토큰 저장
res.setHeader("Authorization", "Bearer " + Object.values(tokenData));
res.cookie("access_token", tokenData.accessToken, { httpOnly: true });
res.cookie("refresh_token", tokenData.refreshToken, { httpOnly: true });
return tokenData;
}
@UseGuards(JwtRefreshTokenGuard)
@Post("refresh")
async refresh(@Req() req: any, @Res({ passthrough: true }) res: Response) {
const userId: string = req.user.userId;
const refreshToken = req.cookies.refresh_token;
// 새로운 access token 발급
const tokenData = await this.authService.refresh(userId, refreshToken);
// 쿠키의 access token 교체
res.setHeader("Authorization", "Bearer " + tokenData.accessToken);
res.cookie("access_token", tokenData.accessToken, { httpOnly: true });
return tokenData;
}
@UseGuards(JwtAccessTokenGuard)
@UseGuards(JwtRefreshTokenGuard)
@Post("logout")
async logout(@Req() req: any, @Res() res: Response) {
await this.authService.logout(req.user.userId);
// 쿠키 토큰 삭제
res.clearCookie("access_token");
res.clearCookie("refresh_token");
return res.send("logout complete");
}
}
구현해야 할 test, login, logout, refresh 메서드
test: access 토큰을 검증하고 리소스를 반환
login: id, pw를 검증하고 access, refresh 토큰을 반환하는 메서드 + 쿠키에 토큰 저장
logout: access, refresh 토큰을 검증하고 DB의 refresh 토큰을 삭제 및 쿠키의 토큰 삭제
refresh: access token이 만료되었을 경우 refresh token을 통해 새로운 access token을 반환
Service
[auth.service.ts]
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoginDto } from 'src/dto/auth.dto';
import { User } from 'src/sequelize/entity/user.model';
import { UserService } from 'src/user/user.service';
import { JwtService } from '@nestjs/jwt';
import { TokenData } from 'src/types/auth.type';
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService
) {}
async login(loginDto: LoginDto): Promise<TokenData> {
// 유저 인증 및 토큰 발급
const user = await this.validateUser(loginDto);
const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user);
// 유저 refresh_token 업데이트
await this.setUserCurrentRefreshToken(
user.userId,
refreshToken
);
return {
accessToken,
refreshToken
};
}
async logout(userId: string): Promise<void> {
// DB의 currentRefreshToken 을 null로 교체
await this.userService.updateUser({
userId,
currentRefreshToken: null
});
}
async refresh(userId: string, refreshToken: string): Promise<TokenData> {
// DB의 refresh token과 현재 토큰 비교
const result = this.compareUserRefreshToken(userId, refreshToken);
if (!result) {
throw new UnauthorizedException("You need to log in first");
}
// 새로운 access token 발급
const user = await this.userService.getUser(userId);
const accessToken = await this.createAccessToken(user);
return {
accessToken,
refreshToken
}
}
// 유저 id, password 확인
async validateUser(loginDto: LoginDto): Promise<User> {
const { userId, password } = loginDto;
const user = await this.userService.getUser(userId);
// 비밀번호 비교
const comparePassword = await bcrypt.compare(password, user.password);
if (!comparePassword) {
throw new UnauthorizedException("password is wrong");
}
return user;
}
// access_token 발급
async createAccessToken(user: User): Promise<string> {
const payload = {
userId: user.userId,
name: user.name,
age: user.age,
sex: user.sex
};
const access_token = await this.jwtService.signAsync(
payload,
{
secret: this.configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
expiresIn: parseInt(this.configService.get<string>("JWT_ACCESS_TOKEN_EXP"))
}
);
return access_token;
}
// refresh_token 발급
async createRefreshToken(user: User): Promise<string> {
const payload = {
userId: user.userId
};
const refreshToken = await this.jwtService.signAsync(
payload,
{
secret: this.configService.get<string>("JWT_REFRESH_TOKEN_SECRET"),
expiresIn: parseInt(this.configService.get<string>("JWT_REFRESH_TOKEN_EXP"))
}
);
return refreshToken;
}
// DB의 refresh_token과 현재 refresh_token 비교
async compareUserRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
const user = await this.userService.getUser(userId);
// 사용자에게 저장된 refresh token이 없으면 false 반환
if (!user.currentRefreshToken) return false;
// refresh_token 비교
const result = await bcrypt.compare(refreshToken, user.currentRefreshToken);
if (!result) return false;
return true;
}
// DB user 데이터에 refresh_token 저장
async setUserCurrentRefreshToken(userId: string, refreshToken: string): Promise<void> {
// refresh_token 암호화
const hashedRefreshToken = await bcrypt.hash(refreshToken, 10);
// 현재 날짜 시간 기준으로 토큰 만료 시간을 더함
const now = new Date();
const exp = parseInt(this.configService.get<string>("JWT_REFRESH_TOKEN_EXP"));
const refreshTokenExp = new Date(now.getTime() + exp);
// DB 업데이트
await this.userService.updateUser({
userId,
currentRefreshToken: hashedRefreshToken,
currentRefreshTokenExp: refreshTokenExp
});
}
}
Access Token
[accessToken.strategy.ts]
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Request } from "express";
import { ExtractJwt, Strategy } from "passport-jwt";
import { AccessTokenPayload } from "src/types/auth.type";
@Injectable()
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy, "access_token") {
constructor(private readonly configService: ConfigService) {
super({
// request의 쿠키에서 refresh token을 가져옴
jwtFromRequest: ExtractJwt.fromExtractors([
(request) => {
console.log(request.cookies);
return request?.cookies?.access_token }
]),
// access toke n secret key
secretOrKey: configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
// 만료된 토큰은 거부
ignoreExpiration: false,
// validate 함수에 첫번째 인자에 request를 넘겨줌
passReqToCallback: true
});
}
validate(req: Request, payload: AccessTokenPayload) {
// request에 저장을 해놔야 Guard후에 controller 메서드에서 사용 가능
req.user = payload;
return payload;
}
}
Refresh Token
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Request } from "express";
import { ExtractJwt, Strategy } from "passport-jwt";
import { RefreshTokenPayload } from "src/types/auth.type";
import { AuthService } from "../auth.service";
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, "refresh_token") {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
// access token strategy와 동일
jwtFromRequest: ExtractJwt.fromExtractors([
(request) => { return request?.cookies?.refresh_token }
]),
secretOrKey: configService.get<string>("JWT_REFRESH_TOKEN_SECRET"),
ignoreExpiration: false,
passReqToCallback: true
});
}
async validate(req: Request, payload: RefreshTokenPayload) {
const refreshToken = req?.cookies?.refresh_token;
// refresh token이 없을 경우 예외 발생
if (!refreshToken) {
throw new UnauthorizedException("refresh token is undefined");
}
// 저장된 refresh token과 비교
const result = await this.authService.compareUserRefreshToken(
payload.userId,
refreshToken
);
// 결과가 틀렸다면 예외 발생
if (!result) {
throw new UnauthorizedException("refresh token is wrong");
}
req.user = payload;
return payload;
}
}
PassportStrategy(Strategy, "refresh_token") 에서의 "refresh_token"으로 Guard와 Strategy를 연결할 수 있으니 중복되지 않게 명칭을 넣어줘야 한다. 그리고 생성자에 많은 옵션이 있다.
jwtFromRequest: JWT를 어떻게 가져올지 가져올 방법을 선택
secretOrKey: JWT의 secret key
ignoreExpiration: 만료된 토큰을 passport에서 확인하고 Unauthorized 예외를 클라이언트에게 전달한다.
passReqToCallback: 콜백(validate)함수의 첫번째 인자에 요청(request)를 전달한다.
여기서 힘들었던 것은 JWT의 추출 방식이었다.
다음은 passport-jwt의 JWT추출을 도와주는 ExtractJWT이다.
export declare namespace ExtractJwt {
export function fromHeader(header_name: string): JwtFromRequestFunction;
export function fromBodyField(field_name: string): JwtFromRequestFunction;
export function fromUrlQueryParameter(param_name: string): JwtFromRequestFunction;
export function fromAuthHeaderWithScheme(auth_scheme: string): JwtFromRequestFunction;
export function fromAuthHeader(): JwtFromRequestFunction;
export function fromExtractors(extractors: JwtFromRequestFunction[]): JwtFromRequestFunction;
export function fromAuthHeaderAsBearerToken(): JwtFromRequestFunction;
}
위처럼 JWT를 어떻게 가져올지는 아주 많은 방법이 있고 나는 쿠키에 토큰 값을 저장 했기에 Extract.fromExtractors()를 사용했다.
Access Token
[accessToken.guard.ts]
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAccessTokenGuard extends AuthGuard("access_token") {}
Refresh Token
[refreshToken.guard.ts]
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtRefreshTokenGuard extends AuthGuard("refresh_token") {}
Guard는 AuthGuard를 통해 Strategy에서 명칭했던 문자열로 연결만 해주면 끝이다.
리팩토링 전의 코드와는 다르게 jwt 로그인에 쿠키를 적용 했다. 하지만 예상치 못한 오류가 발생했다.
프로젝트 세팅 포스팅에서 나는 cors 문제 해결을 위해 이런 코드를 넣었다.
// Cors 활성화
app.enableCors({
origin: true, //여기에 url을 넣어도된다.
credentials: true,
});
여기서 origin은 어떤 출처의 url을 허락할 것인지 설정한다는 것을 알고 있었지만, credentials의 의미는 모르고 넘어갔다.
credentials은 인증 정보라는 의미로, 일반적으로 브라우저가 사용하는 API들은 별도의 옵션 없이 쿠키와 같은 데이터를 함부로 요청 데이터에 담지 못하게 되어 있다.
여기서 그것을 허락 해주는 별도의 옵션이 "credentials: true"이다. 클라이언트에서는 "withCredentials: true"옵션을 사용한다.
그리고 당연하게도 쿠키를 통한 요청, 응답을 할려면 서버, 클라이언트 둘 다 이 옵션을 적용해야 하기에 이것을 몰라서 한참을 헤매었다.
리팩토링 전의 코드는 userService와 authService의 로그인에 대한 역할이 분산되어 있었다. 나는 해당 모듈에서는 그 모듈의 역할만 수행해야한다고 생각하기 때문에 이것을 고쳤다.
결과로 현재는 authService에서 인증 및 토큰에 대한 역할을 모두 수행하며, userService는 사용자의 데이터를 가져오거나 변경하는 역할만을 수행한다.
리팩토링 전에는 로그인 전략이 local, access, refresh 세 가지 였다. 하지만 아무리 다시 생각해봐도 그 때의 내 생각을 이해할 수 없다. local로그인은 그냥 id,pw를 검증하기만 할 뿐인데 굳이 그것을 로그인 전략으로 만들어 Guard까지 사용했으니 비효율의 극치였다.
그래서 id, pw 검증은 login 메서드에 통합시키고 local 전략은 없애버렸다.
옛날에 JWT를 통한 로그인을 구현 했을때에는 머리로 완전히 이해도 되지 않았고, 따라치는데만 급했다. 하지만 지금은 머리로 이해가 된 상태이고 코드를 짜면서 어떻게 짜면 더 효율적이고 깔끔해 보이는지 생각하며 좀 여유로워진 것 같다.
https://docs.nestjs.com/recipes/passport - nest 공식 문서
https://velog.io/@from_numpy/NestJS-How-to-implement-Refresh-Token-with-JWT - refresh token 전략 블로그 글