[NestJS] Redis를 사용한 JWT 인증 시스템 구현하기

허창원·2024년 4월 8일
0
post-custom-banner

1. 개요

현대의 웹 서비스에서 로그인 기능은 필수적입니다. 이전까지는 아이디와 비밀번호, 혹은 단일 Access Token만으로 인증 과정을 구현해왔습니다. 하지만 보안성을 강화하고자 Refresh Token을 도입한 인증 시스템에 대해 알아보고, 직접 구현해보고자 합니다. 본 글에서는 NestJS를 활용하여 JWT 기반으로 Refresh Token을 Redis에 저장하는 인증 시스템을 구현하는 과정을 소개합니다.

2. 설치할 모듈

우선 인증 시스템을 구현하기 위해 필요한 모듈들을 설치해야합니다.
Passport는 이름과 비밀번호, 소셜 로그인, 토큰 기반 인증 등 여러 인증 메커니즘을 지원합니다. 전략(Strategy)이라는 플러그인을 통해 이러한 다양한 인증 방식을 구현할 수 있습니다.

npm install @nestjs/passport passport passport-jwt passport-local

3. 인증 과정 개요


1. 클라이언트는 인증 서버에 인증하고 인가 부여를 제시함으로써 액세스 토큰을 요청합니다.
2. 인증 서버는 클라이언트를 인증하고 인가 부여를 검증하며, 유효한 경우 액세스 토큰과 리프레시 토큰을 발행합니다.
3. 클라이언트는 액세스 토큰을 제시하여 리소스 서버에 보호된 리소스를 요청합니다.
4. 리소스 서버는 액세스 토큰을 검증하고, 유효한 경우 요청을 수행합니다.
5. 액세스 토큰이 유효하지 않으므로 리소스 서버에서 유효하지 않은 토큰 오류를 반환합니다.
6. 클라이언트는 리프레쉬 토큰을 제시하고 인증 서버에 인증함으로써 새로운 액세스 토큰을 요청합니다. 클라이언트의 인증 요청은 클라이언트 유형과 인증 서버 정책에 기초합니다.
7. 인증 서버는 클라이언트를 인증하고 리프레시 토큰을 검증하여, 유효한 경우 새로운 액세스 토큰(그리고 선택적으로 새로운 리프레시 토큰)을 발급합니다.

4. Local Strategy 구현

// 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('비밀번호를 확인해주세요.')
    }
  }
  • Local Strategy는 클라이언트로부터 받은 이메일과 비밀번호를 사용해 유효한 유저인지 검증합니다.
  • 이 프로젝트는 email과 password로 로그인을 할 수 있습니다. LocalStrategy에서 usernameField, passwordField는 Body에 JSON으로 작성한 email, password를 말합니다.
  • validate 함수에서 getAuthenticatedUser 함수로 email과 비밀번호를 확인해서 유저 정보에서 비밀번호를 지운 객체로 반환합니다. 이메일을 못 찾으면 NoFounde 에러를, 비밀번호를 못찾으면 BadRequest 에러를 반환하도록 작성했습니다.

5. 로그인 및 토큰 발급

// 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)
  } 
  
  • auth controller에서 LocalAuthGuard로 반환된 user 객체를 @CurrentUser로 추출합니다. @CurrentUser는 NestJS 공식문서의 Custom decorators에서 확인할 수 있습니다.
  • auth service의 login 함수는 Access Token과 Refresh Token을 생성하고 반환합니다. 유저의 email과 password는 LocalStrategy에서 확인했으므로 login 함수에서 중복해서 확인할 필요가 없다고 생각했습니다.
  • setRefreshToken 함수는 Refresh Token을 Redis 서버에 userId를 key로 저장합니다.
@Post('/login')
@UsePipes(new ValidationPipe())
login(@Body() logInDto: logInDto): Promise<TokenResponse> {
  return this.authService.logIn(signInDto);
}
  • 만약 Login DTO를 사용한다면, Local Strategy를 사용할 필요없다고 이해했습니다. 위와 같은 코드로 작성하여 signInDto를 유효성 검사할 수 있습니다. 이때, 이메일과 비밀번호를 확인하여 유효한 유저인지 확인하는 로직을 login함수에 추가하면 됩니다.

6. Access Strategy

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') {}
  • Access Strategy는 올바른 Access Token인지 검증합니다.
  • jwtFromRequest는 'Authorization'헤더에 Bearer token 형식으로된 token을 추출합니다.
  • secretOrKey는 검증에 사용되는 비밀키입니다. configService를 통해 환경변수에서 Access Token의 secrete Key를 가져옵니다.
  • ignoreExpiration가 false이면 만료기간을 무시하지 않습니다.
  • validate 함수는 토큰이 유효한 경우 토큰에 포함된 Payload에서 userId를 추출합니다. 인가만 확인하므로 user 객체보다 단순한 userId만 담았습니다.
  • JwtAuthGuard는 유저가 로그인해야만 접근할 수 있는 리소스에 사용할 수 있습니다.

7. refresh Strategy

// 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
  }
  • Stratefy에서 passReqToCallback은 validate에서 client request 접근할 수 있도록 설정합니다.
  • auth service의 isRefreshTokenValid 함수는 클라이언트의 요청에 담긴 Refresh Token과 Redis에 저장된 Refresh Token을 비교합니다. 일치하면 userId를 불일치하면 null을 반환합니다.
    JwtRefreshAuthGuard는 Access Token을 재발급 받을 때, 사용할 수 있습니다.

8. Refresh Token으로 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 }
  }
  • Refresh Strategy에서 유효한 Refresh Token인지 검증했으므로 Access 토큰을 새로 생성하여 반환합니다.

9. 로그아웃 구현

// 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}`)
  }
  • Refresh Strategy로 반환한 userId를 controller에서 받습니다.
  • logOut함수는 Redis에 저장된 refresh Token을 삭제합니다.
  • 로그인 기능에 Refresh Token을 사용한다면 로그아웃 기능은 서버에 저장된 Refresh Token을 지워야합니다.
  • 로그인 기능에서 Access Token만 사용한다면 프론트에서 Access Token을 지우면 되기 때문에 로그아웃 기능을 만들 필요 없습니다.

10. 생각 정리

인증과 인가 과정을 NestJS를 통해 직접 구현하면서 전략(Strategy)과 가드(Guard)의 역할에 대해 명확히 이해할 수 있었습니다. 복잡해 보이는 인증 시스템도 단계별로 나누어 구현하니 이해가 수월했습니다. 이 글을 통해 JWT 기반 인증 시스템의 기본 원리와 구현 방법에 대해 이해할 수 있기를 바랍니다.

post-custom-banner

0개의 댓글