[NestJS] Authentication

cdwde·2022년 11월 9일
post-thumbnail

해당 포스트는 NestJS 공식문서를 읽으면서 정리한 글 입니다.

✅ Authentication

authentication은 대부분의 어플리케이션에서 필수적인 부분이다. authentication을 다루기 위해 수많은 접근법과 전략이 있는데 Passport는 가장 유명한 node.js authentication library이다.

@nestjs/passport를 사용하면 PassportStrategy 클래스를 확장하여 PassportStrategy를 구성할 수 있다.

✅ Authentication Requirements

관련 모듈을 설치해준다.

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

AuthModule과 AuthService, UsersService와 UsersModule을 만들어준다.

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

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

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

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);
  }
}
      

UserModule에서 UsersService를 밖에서도 사용할 수 있도록 추가해준다.

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

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

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

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

@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;
  }
}

당연히 비밀번호를 평문으로 저장하는 것은 절대 일어나면 안되는 일이다. Nest 공식 문서에서는 salted one-way hash algorithm인 bcrypt 라이브러리를 권하고 있다.

✅ Implementing Passport local

우리는 passport local strategy를 사용할 것이다. auth > local.strategy.ts를 만들어준다.

  • 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로 usernamepassport를 request body에서 갖는다. 만약 다른 property name을 갖게 하고 싶다면, super({usernameField: 'email'})과 같은 식으로 수정할 수 있다.

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

만약 유저의 정보가 유효하다면 Passport가 자신의 일을 마칠 수 있도록 user가 리턴된다. 유효하지 않다면 예외가 발생한다.

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

AuthModule이 방금 우리가 정의한 Passport feature를 따르도록 해야하기에 auth.module.ts에 LocalStrategy를 프로바이더로 등록해준다.

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

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

✅ Built-in Passport Guards

Guards는 request가 route handler에 의해 처리될지 말지를 결정한다.
우리의 app에서 유저는 두 가지 상태로 존재할 수 있다.

  • 유저가 로그인되지 않은 경우
  • 로그인 된 경우

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

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

=> 약간씩 다른 type의 guard를 사용한다. guard에서 passport strategy를 호출하고, 언급된 과정들을 하도록 한다.

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

✅ Login route

/auth/login을 정의하자.

  • 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에 이를 할당해준다.
(나중에는 return req.user 쪽을 JWT를 생성하고 리턴하는 코드로 교체 예정)

이를 테스트해보자.

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}

이제 @UseGuards(AuthGuard('local')처럼 직접 넣는 대신 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

요구사항은

  • username/password로 유저를 인증하고, 인증 정보를 확인하기 위해 JWT를 return한다.
  • 유효한 JWT를 사용(bearer token 기반)하는 producted API route를 만든다.
$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

POST /auth/login 해당 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에 필요한 정보들을 담아놓고, jwtServicesign() 메서드를 사용해 access_token을 생성한다. sub라는 프로퍼티 이름은 JWT 규격에 부합하기 위함이라고 한다.(토큰 제목)

auth 폴더에 constants.ts를 생성해준다.

  • auth/constants.ts
export const jwtConstants = {
  secret: 'secretKey',
};

해당 부분은 오픈 소스에 노출되면 안된다!

다음으로 auth.module.ts에 JwtModule을 import 해준다.
JwtModule.register()를 통해 configure 해줬다.

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

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService]
})
export class AuthModule {}
  • app.controller.ts
import { AuthService } from './auth/auth.service';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@neFstjs/passport';

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

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

✅ Implementing Passport JWT

이제 유효한 JWT가 request에 존재하는지 판단해보자.

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

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

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

super()에서 option object를 pass함으로써 JwtStrategy에 필요한 초기화를 했다. 사용된 옵션은

  • jwtFromRequest: Request에서 JWT를 추출하는 방법을 제공한다. 우리는 표준인 API request의 Authrization 헤더에서 bearer token을 가져오는 방법을 사용한다.
  • ignoreExpiration: fasle로 지정는데, 이는 JWT가 만료되지 않았음을 보증하는 책임을 Passport 모듈에 위임함을 의미한다. 이것은 만약 만료된 JWT를 받았을 경우 request는 거부되고 401 Unauthorized를 보낼 것이다.
  • secretOrKey: token 발급에 쓰일 시크릿 키를 의미한다. 절대로 이 키를 노출시키면 안된다!

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

우리는 userId와 username 프로퍼티를 가진 object를 리턴한다. Passport는 validate() 메서드의 결과값에 기초해서 user object를 만들고, 그것을 Request object의 프로퍼티로 붙인다.


AuthModule에서 JwtStrategy를 사용하기 위해 provider로 추가해준다.
import { JwtStrategy } from './jwt.strategy';
import { jwtConstants } from './constants';
import { UsersModule } from './../users/users.module';
import { LocalStrategy } from './local.strategy';
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';

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

JWT를 sign할 때와 같은 secret key를 import하면서, verify 단계가 Passport를 통해 이뤄진다는 것과 common secret을 이용한 우리의 AuthService에서 sign이 이뤄진다는 것을 보증한다.(뭔소리지)

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

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

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

✅ Implement protected route and JWT strategy guards

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

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

  //@UseGuards(AuthGuard('local'))
  @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;
  }
}

이제 만료 시간인 60s 이후 다시 request를 하면 401 Unauthorized가 뜨는 것을 확인할 수 있다. Passport가 자동으로 JWT 만료 시간을 체크해준다.

참고
NestJS 공식문서
NestJS 노트 (3) : Authentication

0개의 댓글