본 글은 Nest Docs Authentication를 토대로 공부하여 작성한 글입니다.
Overviews
Authentication
은 대부분의 애플리케이션에서 필수입니다. 직접 인증/인가를 구현할 수 있지만, nest.js
에서는 passport
를 이용하여 쉽게 구현할 수 있습니다.
@nestjs/passport
라이브러리는 passport
와 nest.js
의 인테그레이션 모듈입니다.
이번 글에서는 Passport로 인증을 구현하는 법과, 커스텀으로 인증을 구현해야하는 상황을 알아보고 대처해보겠습니다.
Passport
passport
는 다음 일련의 기능을 제공합니다.
- 사용자의
credentials
을 통해 인증여부를 파악합니다. - 인증 토큰을 발급하거나 상태를 session과 같은 저장소에 보존 및 관리할 수 있습니다.
credentials
을 통해 추가 정보를 Request에 붙일 수 있습니다. (userId
등)
인증된 유저 정보 가져오기
독스 내용을 보시면 passport
의 다양한 strategy
(local
, jwt
등)를 이용하여 credentials
을 검증하고 CurrentUser
로 유저의 정보를 가져옵니다.
다음 코드를 보며 확인해보겠습니다.
패스포트를 이용하기
먼저 jwt
를 이용하기 위해 다음 라이브러리를 설치합니다.
$ npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt
그리고 AuthModule
에 JwtModule
을 추가합니다.
import { PassportModule } from "@nestjs/passport"
import { JwtModule } from "@nestjs/jwt"
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: "SECRET",
signOptions: { expiresIn: "60s" },
}),
],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
이제 Request
내의 Jwt
토큰을 받으면 어떻게 Decode
하고, Decode
된 값을 어떻게 Request
에 붙일지 전략을 설정해야합니다.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // 헤더 Bearer 토큰을 파싱합니다.
ignoreExpiration: false, // 만료된 토큰이라면 에러를 반환합니다.
secretOrKey: "SECRET",
})
}
async validate(payload: any) {
// 이 데이터가 req.user로 접근할 수 있게 합니다.
return { userId: payload.sub, username: payload.username }
}
}
이제 JwtStrategy
를 AuthModule
에 추가합니다.
import { PassportModule } from "@nestjs/passport"
import { JwtModule } from "@nestjs/jwt"
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: "SECRET",
signOptions: { expiresIn: "60s" },
}),
],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
이제 UseGuards
데코레이터를 통해 Decode
된 jwt
유저 데이터를 가져올 수 있습니다.
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post("auth/login")
async login(@Request() req) {
return this.authService.login(req.user)
}
// JwtStrategy를 통해 credentials를 해독합니다.
// 만일 토큰이 유효하지 않다면 에러를 반환합니다.
@UseGuards(AuthGuard("jwt"))
@Get("profile")
getProfile(@Request() req) {
return req.user // Decode된 유저 정보는 req.user에 있습니다.
}
}
여기까지가 공식 문서에 있는 내용을 발췌한 것입니다. 더욱 상세한 설명은 문서에서 확인하실 수 있습니다.
다만 한가지 문제점이 존재합니다. 유저가 로그인이던, 비로그인이던 같은 API를 사용해야하는 경우의 처리가 어렵습니다.
상황을 예시로 들겠습니다.
커머스에서 상품 상세페이지를 보여준다. 상세페이지에는 상품 좋아요 여부를 보여주어야한다. 로그인 유저면
Service
에 좋아요 여부를 체크를 해야하고, 비로그인 유저면 좋아요 여부는 무조건false
이다.
아마 컨트롤러에서 코드는 대략 다음과 같을 겁니다.
@Controller()
export class ProductsController {
constructor(private productsService: ProductsService) {}
@Get(":id")
getProfile(@Request() req, @Param("id", ParseIntPipe) productId: number) {
const product = await this.productsService.findOneByProductId(productId)
const user = req.user
// 비로그인이면 false, 로그인이면 좋아요 여부 체크
const isLike = !!user
? await this.productsService.checkIsLikeProduct(user.id, productId)
: false
return { ...product, isLike }
}
}
하지만 이 코드는 문제가 있습니다. 여기에서 유저의 정보(req.user
)를 가져오려면 Controller
의 method
에 UseGuards
데코레이터가 있어야만 합니다.
UseGuards
를 통해 AuthGuard('jwt')
를 적용하고, JwtStrategy
를 실행하여 req.user
정보를 가져오기 때문이죠.
패스포트를 이용하지 않기
따라서 패스포트를 이용하지 않고도 인증하는 법을 알아보겠습니다.
기존에 nest.js
공식 문서에 나온 UseGuards
나 Interceptor
, Pipe
, Controller
, Service
등 모두 결국 Middleware
입니다.
각 미들웨어에 명칭을 부여하여 개발자가 비즈니스 로직을 구분하여 이용하기 쉽도록 만든 것이죠.
우리는 이 순서도를 참고하여 UseGuards
를 포함하여 다른 미들웨어를 이용하여 위 문제를 해결하겠습니다.
우선 문제를 해결하기 위해선 Request
에 유저 정보를 붙이는 것과, 로그인 여부를 확인하는 단계를 나눌 필요가 있습니다.
passport
처럼 로그인 여부를 확인 후, 유저 정보를 Request
에 붙이는 것이 아니라 Request
에 정보를 먼저 붙이고, 로그인 정보를 확인하는 것이죠.
이를 위해 Guard
이전에 실행되는 middleware
부분에서 Request에 정보를 붙이는 작업이 필요합니다.
import { Injectable, NestMiddleware } from "@nestjs/common"
import { Request, Response } from "express"
import { verifyToken } from "./auth.jwt.util"
@Injectable()
export class AuthTokenMiddleware implements NestMiddleware {
// token을 decode후 req.user에 붙여서 넘어갑니다.
// 만약 토큰이 없거나 유효하지 않으면 req.user에는 null 값이 들어갑니다.
public async use(req: Request, res: Response, next: () => void) {
req.user = await this.verifyUser(req)
return next()
}
private async verifyUser(req: Request): Promise<number> {
let user: User = null
try {
const { authorization } = req.headers
const token = authorization.replace("Bearer ", "").replace("bearer ", "")
const decoded = await verifyToken(token)
user = decoded
} catch (e) {}
return user
}
}
위처럼 미들웨어를 만든 후, 모든 라우팅에 해당 미들웨어를 지나가도록 합니다.
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthTokenMiddleware)
.forRoutes({ path: "*", method: RequestMethod.ALL })
}
}
이제 UseGuards
상관없이 모든 라우팅에서 req.user
에 접근할 수 있습니다. 위에서 나왔던 상품 조회 API 코드가 정상 작동합니다.
그리고 UseGuards
를 사용하면 비로그인 유저는 차단하는 로직을 작성합니다.
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common"
import { Reflector } from "@nestjs/core"
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest()
if (!req.user) throw new UnauthorizedException("권한이 없습니다")
return true
}
}
미들웨어에서 정보를 먼저 파싱해주니까 AuthGuard
로직이 간단해졌습니다.