
해당 포스트는 NestJS 공식문서를 읽으면서 정리한 글 입니다.
authentication은 대부분의 어플리케이션에서 필수적인 부분이다. authentication을 다루기 위해 수많은 접근법과 전략이 있는데 Passport는 가장 유명한 node.js authentication library이다.
@nestjs/passport를 사용하면 PassportStrategy 클래스를 확장하여 PassportStrategy를 구성할 수 있다.
관련 모듈을 설치해준다.
$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으로 해당 유저를 찾는 메소드를 가진다.
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를 밖에서도 사용할 수 있도록 추가해준다.
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 라이브러리를 권하고 있다.
우리는 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로username과passport를 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 {}
Guards는 request가 route handler에 의해 처리될지 말지를 결정한다.
우리의 app에서 유저는 두 가지 상태로 존재할 수 있다.
첫번째 케이스에서 우리는 두 가지 function을 실행할 필요가 있다.
POST /auth/login을 만들 것이다. 해당 route에서 passport-local strategy를 어케 호출할지 생각해보자.=> 약간씩 다른 type의 guard를 사용한다. guard에서 passport strategy를 호출하고, 언급된 과정들을 하도록 한다.
두번째 케이스는 단순히 Guard 표준 타입에 의지한다.(로그인 된 유저만 protected routes에 대한 접근 권한 얻게 하는 등)
/auth/login을 정의하자.
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를 만들어보자.
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;
}
요구사항은
$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt
POST /auth/login 해당 route를 내장된 AuthGuard로 decorate했다. 이것은
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을 생성한다. sub라는 프로퍼티 이름은 JWT 규격에 부합하기 위함이라고 한다.(토큰 제목)
auth 폴더에 constants.ts를 생성해준다.
export const jwtConstants = {
secret: 'secretKey',
};
해당 부분은 오픈 소스에 노출되면 안된다!
다음으로 auth.module.ts에 JwtModule을 import 해준다.
JwtModule.register()를 통해 configure 해줬다.
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 {}
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);
}
}
이제 유효한 JWT가 request에 존재하는지 판단해보자.
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의 프로퍼티로 붙인다.
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 클래스를 정의해준다.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
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 만료 시간을 체크해준다.