현대의 웹 서비스에서 로그인 기능은 필수적입니다. 이전까지는 아이디와 비밀번호, 혹은 단일 Access Token만으로 인증 과정을 구현해왔습니다. 하지만 보안성을 강화하고자 Refresh Token을 도입한 인증 시스템에 대해 알아보고, 직접 구현해보고자 합니다. 본 글에서는 NestJS를 활용하여 JWT 기반으로 Refresh Token을 Redis에 저장하는 인증 시스템을 구현하는 과정을 소개합니다.
우선 인증 시스템을 구현하기 위해 필요한 모듈들을 설치해야합니다.
Passport는 이름과 비밀번호, 소셜 로그인, 토큰 기반 인증 등 여러 인증 메커니즘을 지원합니다. 전략(Strategy)이라는 플러그인을 통해 이러한 다양한 인증 방식을 구현할 수 있습니다.
npm install @nestjs/passport passport passport-jwt passport-local
1. 클라이언트는 인증 서버에 인증하고 인가 부여를 제시함으로써 액세스 토큰을 요청합니다.
2. 인증 서버는 클라이언트를 인증하고 인가 부여를 검증하며, 유효한 경우 액세스 토큰과 리프레시 토큰을 발행합니다.
3. 클라이언트는 액세스 토큰을 제시하여 리소스 서버에 보호된 리소스를 요청합니다.
4. 리소스 서버는 액세스 토큰을 검증하고, 유효한 경우 요청을 수행합니다.
5. 액세스 토큰이 유효하지 않으므로 리소스 서버에서 유효하지 않은 토큰 오류를 반환합니다.
6. 클라이언트는 리프레쉬 토큰을 제시하고 인증 서버에 인증함으로써 새로운 액세스 토큰을 요청합니다. 클라이언트의 인증 요청은 클라이언트 유형과 인증 서버 정책에 기초합니다.
7. 인증 서버는 클라이언트를 인증하고 리프레시 토큰을 검증하여, 유효한 경우 새로운 액세스 토큰(그리고 선택적으로 새로운 리프레시 토큰)을 발급합니다.
// local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Strategy } from 'passport-local'
import { User } from 'src/user/entities/user.entity'
import { AuthService } from '../services/auth.service'
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({
usernameField: 'email',
passwordField: 'password',
})
}
async validate(
email: string,
password: string,
): Promise<User | UnauthorizedException> {
const user: User = await this.authService.getAuthenticatedUser(
email,
password,
)
return user
}
}
// auth.service.ts
async getAuthenticatedUser(
email: string,
plainTextPassword: string,
): Promise<User> {
const user = await this.usersRepository.findOneBy({ email })
if (!user) {
throw new NotFoundException('이메일을 확인해주세요.')
}
await this.verifyPassword(plainTextPassword, user.password)
user.password = undefined // 비밀번호는 안보여줌
return user
}
private async verifyPassword(
plainTextPassword: string,
hashedPassword: string,
) {
const isPasswordMatching = await bcrypt.compare(
plainTextPassword,
hashedPassword,
)
if (!isPasswordMatching) {
throw new BadRequestException('비밀번호를 확인해주세요.')
}
}
usernameField
, passwordField
는 Body에 JSON으로 작성한 email, password를 말합니다.getAuthenticatedUser
함수로 email과 비밀번호를 확인해서 유저 정보에서 비밀번호를 지운 객체로 반환합니다. 이메일을 못 찾으면 NoFounde 에러를, 비밀번호를 못찾으면 BadRequest 에러를 반환하도록 작성했습니다.// auth.controller.ts
@Post('/login')
@UseGuards(LocalAuthGuard)
login(@CurrentUser() user: User): Promise<TokenResponse> {
return this.authService.logIn(user)
}
// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { User } from 'src/user/entities/user.entity'
export const CurrentUser = createParamDecorator(
(data, ctx: ExecutionContext): User => {
const req = ctx.switchToHttp().getRequest()
return req.user
},
)
// token-response.interface.ts
export interface TokenResponse {
accessToken: string
refreshToken: string
}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
@Inject(ICACHE_SERVICE)
private readonly cacheService: ICacheService,
) {}
async logIn(user: User): Promise<TokenResponse> {
const accessToken = await this.generateAccessToken(user.id)
const refreshToken = await this.generateRefreshToken(user.id)
await this.setRefreshToken(user.id, refreshToken)
return { accessToken, refreshToken }
}
private async generateAccessToken(userId: string): Promise<string> {
const token = this.jwtService.sign(
{ userId },
{
secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'),
expiresIn: Number(
this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME'),
),
},
)
return token
}
// refresh token 생성
private async generateRefreshToken(userId: string): Promise<string> {
const token = this.jwtService.sign(
{ userId },
{
secret: this.configService.get('JWT_REFRESH_TOKEN_SECRET'),
expiresIn: Number(
this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME'),
),
},
)
return token
}
async setRefreshToken(userId: string, refreshToken: string): Promise<void> {
const ttl = this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME') // TTL 값 설정
await this.cacheService.set(`refreshToken:${userId}`, refreshToken, +ttl)
}
setRefreshToken
함수는 Refresh Token을 Redis 서버에 userId를 key로 저장합니다.@Post('/login')
@UsePipes(new ValidationPipe())
login(@Body() logInDto: logInDto): Promise<TokenResponse> {
return this.authService.logIn(signInDto);
}
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Payload } from './jwt.payload'
@Injectable()
export class JwtAccessTokenStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_ACCESS_TOKEN_SECRET'),
ignoreExpiration: false,
})
}
async validate(payload: Payload) {
return payload.userId
}
}
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
jwtFromRequest
는 'Authorization'헤더에 Bearer token 형식으로된 token을 추출합니다.secretOrKey
는 검증에 사용되는 비밀키입니다. configService를 통해 환경변수에서 Access Token의 secrete Key를 가져옵니다.ignoreExpiration
가 false이면 만료기간을 무시하지 않습니다.JwtAuthGuard
는 유저가 로그인해야만 접근할 수 있는 리소스에 사용할 수 있습니다.// jwt-refresh.strategy.ts
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { AuthService } from '../services/auth.service'
import { Payload } from './jwt.payload'
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(
private configService: ConfigService,
private authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_REFRESH_TOKEN_SECRET'),
passReqToCallback: true,
ignoreExpiration: false,
})
}
async validate(req: Request, payload: Payload) {
const refreshToken = req.headers['authorization'].split(' ')[1] // client request의 헤더에서 토큰값 가져오기('Bearer ' 제거)
const isTokenValid = await this.authService.isRefreshTokenValid(
refreshToken,
payload.userId,
)
if (!isTokenValid) {
throw new UnauthorizedException('유효한 토큰이 아닙니다.')
}
return payload.userId
}
}
// jwt-refesh.guard.ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
// auth.service.ts
async isRefreshTokenValid(
refreshToken: string,
userId: string,
): Promise<string | null> {
const storedRefreshToken = await this.getRefreshToken(userId)
if (!storedRefreshToken) {
return null
}
const isMatch = storedRefreshToken === refreshToken
return isMatch ? userId : null
}
passReqToCallback
은 validate에서 client request 접근할 수 있도록 설정합니다.isRefreshTokenValid
함수는 클라이언트의 요청에 담긴 Refresh Token과 Redis에 저장된 Refresh Token을 비교합니다. 일치하면 userId를 불일치하면 null을 반환합니다.JwtRefreshAuthGuard
는 Access Token을 재발급 받을 때, 사용할 수 있습니다.// auth.contorller.ts
@Get('/refresh')
@UseGuards(JwtRefreshAuthGuard)
refreshAccessToken(
@CurrentUser() userId: string,
): Promise<{ accessToken: string }> {
return this.authService.refreshAccessToken(userId)
}
// auth.service.ts
async refreshAccessToken(userId: string): Promise<{ accessToken: string }> {
const newAccessToken = await this.generateAccessToken(userId)
return { accessToken: newAccessToken }
}
// auth.controller.ts
@Post('/logout')
@UseGuards(JwtAuthGuard)
async logOut(@CurrentUser() userId: string): Promise<{ message: string }> {
await this.authService.logOut(userId)
return { message: '로그아웃 성공.' }
}
// auth.service.ts
async logOut(userId: string): Promise<void> {
await this.cacheService.del(`refreshToken:${userId}`)
}
logOut
함수는 Redis에 저장된 refresh Token을 삭제합니다.인증과 인가 과정을 NestJS를 통해 직접 구현하면서 전략(Strategy)과 가드(Guard)의 역할에 대해 명확히 이해할 수 있었습니다. 복잡해 보이는 인증 시스템도 단계별로 나누어 구현하니 이해가 수월했습니다. 이 글을 통해 JWT 기반 인증 시스템의 기본 원리와 구현 방법에 대해 이해할 수 있기를 바랍니다.