NestJS 노트 (3) : Authentication

SimJungUk·2020년 12월 26일
14

앞서

이 글은 NestJS Authentication 공식 문서를 번역하며 공부하기 위한 글입니다. Passport 를 사용하지만, express 로 인증 서버를 구현해봤다면 passport 를 사용해봤을 것 같아 passport 자체에 대한 설명은 조금 생략한 부분이 있습니다. passport 공식 문서를 먼저 참고하신 후에 이 글을 보시면 도움이 될 것 같습니다.

Authentication

Authentication 은 대부분의 어플리케이션에서 필수적인 부분이다. authentication 을 다루기 위한 수많은 접근법과 전략이 있다. Passport는 가장 유명한 node.js authentication library 이고, 잘 알려졌으며 성공적으로 많은 production application 에서 사용되고 있다. @nestjs/passport 모듈을 통헤 Nest 어플리케이션과 이 라이브러리를 직관적으로 통합할 수 있다. High level에서, Passport는 다음의 단계들을 수행한다.

  • username/password, jwt, 혹은 identity provider에 의해 제공된 token identity 로 유저를 인증한다.
  • 인증 상태를 관리한다. (JWT, Session)
  • 인증된 userdml wjdqhfmf Request 오브젝트에 붙여서 route handler들에서 사용이 가능하게 한다.

이번 글에서는 RESTful API 서버에서 end-to-end 인증 솔루션을 구현할 것이다.

Authentication Requirements

요구사항을 결정해보도록 하자. 이번 예제에서, client는 username 과 password 를 통해 인증을 시작할 것이다. 한번 인증됐다면, server는 특정 request 에서 인증 상태를 확인하기 위해서, JWT 를 발급할 것이다. 또한 유효한 JWT를 가지고 접근한 request 들만 접근 가능한, protected routes를 만들어 볼 것이다.

먼저 첫 번째 요구사항 : 유저 인증 부터 시작하자. 그 뒤 JWT 발급 기능을 구현하고, 마지막으로 유효한 JWT 를 가지고 있는 지 체크할 수 있는 protected route를 만들어 볼 것이다.

먼저 설치부터 시작하자. Passport 는 username/password 인증 매커니즘인 passport-local 이라는 startegy를 제공한다.

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

Implementing Passport strategies

이제 인증 기능을 구현할 준비가 되었다. 먼저 모든 Passport Strategy 에 사용되는 전체적인 과정을 시작해보자. Passport 그 자체가 하나의 작은 프레임워크라고 생각하는 것이 도움이 될 것이다. 이 프레임워크의 멋진 점은, 인증 과정을 몇 개의 과정들로 추상화하여, 우리가 가져온 strategy에 기반하여 커스터마이징 할 수 있다는 것이다. 사용자가 지정한 매개 변수(JSON 객체로)D와 Passport 가 적절한 때에 호출하는 콜백 함수의 형태로, 사용자가 만든 코드를 제공하여 구성하는 점 또한 프레임워크와 같다.

vanilla passport에서, 두 가지를 제공함으로써 전략을 수정할 수 있다.
1. 해당 전략에 필요한 option들(JWT Strategy의 경우, token sign 에 필요한 secret)
2. Passport가 유저 정보가 저장된 저장소와 상호작용 하는 방법을 말해주는 verify callback. 여기서, 유저가 존재하는지, 그들의 정보가 유효한지 확인한다. Passport는 이 콜백함수에게, 유저가 인증되을 경우 user의 정보를, 실패했을 경우 null을 리턴하도록 예상한다.

이제 AuthModuleAuthService를 만들고, UsersServiceUsersModule까지 만들어 보자.

$ nest g module auth
$ nest g service auth
$ nest g module users
$ nest g service users

이 예제에서, UsersService는 하드 코딩된 유저 리스트를 가지고 있고, username으로 해당 유저를 찾는 메소드를 가진다.

import { Injectable } from '@nestjs/common';

// 예제에서는 하드 코딩 되었지만
// 이 부분은 반드시 user entity를 표현하는 class/interface여야 한다.
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

UsersModule에서 유일한 변경점은, exports array에 UsersService를 추가해주는 것 뿐이다. 이 모듈의 밖에서도 사용 가능하도록 말이다.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

AuthService는 유저가 존재하는지, 비밀번호가 맞는지 확인하기 위해서validateUser() 메서드를 만들어준다.

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      // result는 password 를 제외한 user의 모든 정보를 포함한다.
      return result;
    }
    return null;
  }
}

WARNING
당연히, 위 처럼 비밀번호를 평문으로 저장하는 것은 실제 서비스에서 절대로 일어나면 안된다. Nest 공식 문서에서는 salted one-way hash algorithm인 bcrypt 라이브러리를 권하고 있다. hashed 된 비밀번호만을 DB에 저장하고, 입력받은 비밀번호를 암호화한 뒤 비교하면 된다. 예제에서는 편의를 위해 평문으로 한 것이다. 다시 말하지만, 절대로 실제 서비스에서 이러지 말자! 서버 개발자에겐 당연한 일이다.

이제, AuthModuleUsersModule을 import하도록 한다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
})
export class AuthModule {}

Implementing Passport local

이제 우리는 Passport local strategy를 사용할 것이다. auth 폴더에 local.strategy.ts를 만들어주고, 다음의 코드를 추가한다.

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

passport-local 에서는 다른 configuration options가 없기 때문에, super() 만을 호출한다.

HINT
passport strategy의 기능을 수정하기 위해서, super()에 options object를 넣을 수 있다. 이 예제에서, passport-local strategy는 default로 usernamepassword를 request body 에서 갖는다. 만약 다른 property name을 갖게 하고 싶다면, super({ usernameField : 'email'}) 과 같은 방식으로 수정할 수 있다.

우리는 또한 validate() 메서드를 실행했다. 모든 strategy에서, Passport는 verify function 을 특정 parameter들과 실행한다. local-strategy에서의 형태는 validate(usename : string, password : string) : any 이다.

만약 유저의 정보가 유효하다면, Passport 가 자신의 일을 마칠 수 있도록 user 가 리턴된다. (user 프로퍼티를 request object에 붙이는 등의 일) 만약 유효하지 않다면, 예외를 발생시킨다.

일반적으로, 각 strategy에서 validate() 메서드의 다른 점은, 유저가 존재하고 유효한지 결정하는 방법이다. 예를 들어 JWT Strategy 에서, 우리는 decoded 된 token 내에 저장된userId가 database에 저장된 것과 일치한 지 확인할 수도 있고, 만료되거나 blacklist화 된 token(본문에서는 revoked)들의 리스트 내에 토큰이 있는지 확인할 수도 있다.

AuthModule이 방금 우리가 정의한 Passport feature를 따르도록 해야한다. auth.module.ts를 다음과 같이 수정한다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

Built-in Passport Guards

Guards 는 request가 route handler에 의해 처리될지 말지를 결정한다. 이는 true로 유지되고, 곧 standard capability를 사용할 것이다. 그러나, @nestjs/passport 모듈의 context 에서, 처음에는 헷갈릴 수 있지만, 좋은 생각 하나를 소개하고자 한다. 너의 app에서 유저는 두 가지 상태로 존재할 수 있다고 가정해보자.

  1. 유저가 로그인 되지 않은 경우
  2. 로그인 된 경우

첫 번째 케이스에서, 우리는 두 가지의 function을 실행할 필요가 있다.

  • 인증되지 않은 유저가 인증이 필요한 routes에 접근할 수 없도록 한다. 이것을 위해서, protected routes 에 guard를 둘 것이다. 이 guard 내에서, 유효한 JWT의 존재를 체크할 수 있도록 할 것이다. 이는 jwt 의 발급 기능을 구현한 뒤 해보도록 하자.
  • 인증되지 않은 유저가 로그인을 시도할 때, 인증 과정을 시작하도록 해야 한다. 우리가 JWT를 발급하는 과정이 될 것이다. 우리는 인증을 시작하기 위해서 username/password 를 POST해야 한다는 것을 알고, 따라서 POST /auth/login route 를 만들 것이다. 여기서 궁금증이 생긴다. 어떻게 해당 route 에서 passport-local strategy를 호출할 수 있을까?

답은 간단하다. 약간씩 다른 type의 guard를 사용하는 것이다. @nestjs/module은 이를 위해 내장된 guard를 제공한다. 이 guard 는 Passport strategy를 호출하고, 위에서 언급된 과정들을 시작하도록 한다.(정보를 DB에서 가져오고, verify function 을 실행하고, user property를 만들고 하는 등등)

두 번째 케이스는 단순히 Guard의 표준 타입에 의지한다. (로그인 된 유저만 protected routes에 대한 접근 권한을 얻게 하는 등)

Login route

이제 우리는, /auth/login route 를 정의할 것이다.

//app.controller.ts
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class AppController {
  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req) {
    return req.user;
  }
}

@UseGuards(AuthGuard('local')) 를 통해, passport-local strategy를 채택하도록 한다. 또한 Passport 가 validate() 메서드를 통해 자동으로 user object를 생성해주고, Request object 에 이를 할당해준다.(req.user) 나중에 이 코드를 JWT를 생성하고 리턴하는 코드로 교체할 것이다.

이를 테스트하기 위해, curl 을 사용하거나 postman, insomnia 등의 프로그램을 사용하면 된다. 테스트 부분은 생략하도록 하겠다.

이제, @UseGuards(AuthGuard('local')) 처럼 string을 직접 넣는 대신에 class 를 직접 만들어보자.

// auth/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

그 뒤 app.controller.ts에서

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

로 고치면 된다.

JWT functionality

이제 JWT를 적용해보자. 요구사항을 다시 정의해보면,

  • username/password로 유저를 인증하고, 인증 정보를 확인하기 위해 JWT를 리턴한다.
  • 유효한 JWT 가 bearer token를 기반으로 protected API rotue를 만든다.
$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

이제 POST /auth/login 이 어떻게 handle 되는지 다시 보자. 우리는 해당 route를 내장된 AuthGuard로 decorate 하였다. 이것은 곧

  • route handler 가 오직 유저가 인증되었을 때만 호출됨
  • req 파라미터는 user 프로퍼티를 가짐
    을 의미한다. 이를 염두하면, 우리는 마지막에 JWT를 생성하고 return 해주면 된다. 완전한 모듈화를 위해, AuthService 내에서 JWT 생성을 다루도록 한다.
//auth.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  // validateUser 부분 생략(local)
  
  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

payload 에 필요한 정보들을 담아 놓고, jwtService 내의 sign() 메서드를 이용해 access_token을 생성한다. userId를 담는 sub 라는 프로퍼티의 이름은 JWT 규격에 부합하기 위함이다. (토큰 제목)

auth 폴더에 constants.ts를 생성하고,

export const jwtConstants = {
  secret: 'secretKey',
};

를 추가하자. 이 key 는 절대로 github 같은 오픈 소스에 노출되면 안된다.
이제 auth.module.ts 에서,

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

우리는 JwtModuleregister() 를 통해 configure 해줬다. 이제 app.controller.ts에서

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

Implementing Passport JWT

이제 마지막 요구사항 : 유효한 JWT가 request에 존재하는지 판단 하고 endpoint 보호하기 로 가보자. jwt.strategy.tsauth 폴더에 만들고, 다음 코드를 추가한다.

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

super() 의 호출부에서 options object를 pass함으로써JwtStrategy에 필요한 초기화를 했다. 더 많은 option은 여기 에서 찾아볼 수 있다. 우리의 예제의 옵션은

  • jwtFromRequest : Request에서 JWT를 추출하는 방법을 제공한다. 우리는 표준인 API request 의 Authorization 헤더에서 bearer token을 가져오는 방식을 사용한다.
  • ignoreExpiration : 우리는 false로 했는데, 이는 JWT가 만료되지 않았음을 보증하는 책임을 Passport 모듈에 위임함을 의미한다. 이것은 만약 만료된 JWT를 받았을 경우, request 는 거부되고 401 Unauthorized response를 보낼 것이다.
  • secretOrKey : token 발급에 쓰일 시크릿 키를 의미한다. Production app을 위해서는 PEM-encoded public key 등이 권장된다. 어떤 경우든, 절대로 이 key를 노출시켜서는 안된다.

validate() 메서드에 대해 이야기해보자. JWT-Strategy에서, Passport는 먼저 JWT의 서명부를 확인하고 JSON을 decode한다. 이후 decode된 JSON을 단일 파라미터로 가지는 validate() 메서드를 호출한다. JWT signing works의 방법에 기반해서, 우리는 우리가 이전에 sign 후 발급해줬던 토큰이 유효하다는 것을 보증받는다.

이 모든 것의 결과로, validate() 콜백에 대한 우리의 response는 간단해진다 : 우리는 userIdusername 프로퍼티를 가진 object를 리턴한다. Passport는validate() 메서드의 결과값에 기초해서 user object를 만들고, 그것을 Request object의 프로퍼티로 붙인다는 것을 기억하자.

또한 이러한 시도는 다른 비즈니스 로직이 프로세스에 주입될 수 있는 여지를 남긴다는 것을 알아둘 필요가 있다. 예를 들어, 사용자에 대한 추가 정보를 추출하기 위해, 데이터베이스 조회를 수행하여 Request 에 더욱 풍부한 정보를 가지게 된 user를 붙이는 것이 가능해진다. 또한 token validation 을 더 할수 있는 장소가 된다. 예를들면, 취소되거나 만료된 토큰들 중에userId가 있는지, 혹은 토큰 취소(블랙리스트화를 말하는 것 같다.)를 가능하게 할 수 있다. 우리가 여기서 구현한 것은 빠른, "stateless JWT" 모델이다. 모든 API call은 유효한 JWT의 존재를 바탕으로 빠른 인증을 거치고, requester의 간략한 정보들(userId, username등)이 우리의 Request Pipeline에서 이용가능하도록 하는 것이다.

JwtStrategyAuthModule에 provider로 추가한다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

JWT를 sign할 때와 같은 sercret key를 import함으로써, verify 단계가 Passport를 통해 이뤄진다는 것과 common secret을 이용한 우리의 AuthService에서 sign이 이뤄진 다는 것을 보증한다.

마지막으로, 우리는 JwtAuthGuard 클래스를 정의해준다.

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Implement protected route and JWT strategy guards

이제 우리는 protected route와, 그것과 연관된 Guard를 구현할 수 있다.
app.controller.ts를 다음과 같이 수정한다.

import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

이제 테스트를 해보면, 토큰의 만료시간인 60초 이후에 다시 request를 하면 401 Unauthorized가 뜨는 것을 볼 수 있다. Passport가 자동으로 JWT의 만료 시간을 체크해준다는 것을 알 수 있다.

Extending guards

대부분의 경우에서 AuthGuard 클래스가 충분하지만, default error handling이나 인증 로직같은 것을 extend 할수도 있다.

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

마치며

공식 문서는 몇개의 내용이 더 있지만, 필수적이지 않은 거 같아서 포스팅에서는 뺐다. 나중에 내가 필요하다면 내용을 추가해보겠다.

나는 공식 문서에서 내용을 조금 비틀어서,
App 아래에 Users와 Auth를 나누어서, users controller에서 auth가 실행되도록 했다.

즉, auth 에서도 user를 import 하고, user에서도 auth 를 import 해야되는 상황이었고, 여기서 circular dependencies 문제가 발생했다. forwardRef를 통해 해결했으나, 뭔가 찜찜하다... 나중에 의존성 주입에 대해서 확실히 알고 나면 다시 문제점을 볼 수 있을 것 같다.

1개의 댓글

comment-user-thumbnail
2021년 3월 9일

궁금한게 있습니다
import {Request} from "express";
@Req() req: Request
이렇게 선언하면
req.user.id User 타입에 id가 없다고 나오는데
Request 선언을 안해줘도 괜찮은건가요??

답글 달기