[NestJS docs] Authentication

nakkim·2022년 7월 18일
0

NestJS docs

목록 보기
5/10
post-custom-banner

https://docs.nestjs.com/security/authentication

Authentication

Passport를 이용해보자 (@nestjs/passport)

실행 순서

  1. id/pw, JWT, identity token 같은 걸로 유저 검증

  2. 인증 상태 관리

    토큰(JWT)이나 세션 이용

  3. 유저 정보를 Request 객체에 추가


Authentication requirements

요구사항

  • username/password를 이용한 인증
  • 인증이 완료되면 JWT 발급
  • 유효한 JWT를 포함한 요청만 접근할 수 있는 라우트 생성

패키지 설치

passport-local은 username/password 인증 메커니즘을 구현한 strategy

@nestjs/passport는 passport를 Nest의 구조에 맞춤

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

Implementing Passport strategies

일단 vanilla Passport 전략의 흐름을 살펴보자

바닐라 패스포트에서는 두 가지를 제공함으로써 전략을 설정한다.

  1. 옵션들.. (JWT 전략이면 토큰에 사인할 secret)
  2. “verify callback” - 응답을 받을 콜백

@nestjs/passport를 사용하면 PassportStrategy 클래스를 extend 해서 설정

  • super()를 호출해서 strategy option 전달
  • validate()를 구현해서 verify callback 제공

예제를 작성해보자..

  1. 일단 AuthModule, AuthService, UsersModule, UsersService 생성

    $ nest g module auth
    $ nest g service auth
    $ nest g module users
    $ nest g service users
  2. user.service.ts 파일 내용을 아래와 같이 변경

    import { Injectable } from '@nestjs/common';
    
    // This should be a real class/interface representing a user entity
    export type User = any;
    
    @Injectable()![](https://velog.velcdn.com/images/kdkeiie8/post/ee3d844f-e6d9-4ffe-b4e7-e7b469a90b11/image.png)
    
    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);
      }
    }
  3. UsersModule 밖에서 UsersSevice를 사용할 수 있도록 export

    import { Module } from '@nestjs/common';
    import { UsersService } from './users.service';
    
    @Module({
      providers: [UsersService],
      exports: [UsersService],
    })
    export class UsersModule {}
  4. AuthService는 유저를 검색하고 패스워드를 검증한다.

    validateUser() 메서드 생성 (나중에 passport local strategy에서 사용할 거임)

    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;
          return result;
        }
        return null;
      }
    }
  5. AuthModule에서 UsersModule 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

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) {
		// passport-local을 쓰는 지금 예시에서는 옵션 없음
    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은 기본적으로 usernamepassword 속성을 찾는다. 이걸 바꾸고 싶으면 옵션을 넘겨주면 됨.
super({ usernameField: 'email' })

validate() 호출 후 유효한 유저를 찾았으면, 패스포트가 작업을 완료할 수 있도록 해당 유저를 리턴함 (Request 객체에 user 프로퍼티를 생성한다던지..)

그리고 다음엔 리퀘스트 핸들링 파이프라인이 올 수 있다. 만약 유저가 없다면, 예외를 던진 후 exceptions layer에서 처리하도록 할 수 있음

보통 각 전략마다 validate() 메서드의 유일한 차이점은 유저의 존재와 유효성을 어떻게 확인할지 결정하는 방법이다. 예를 들어, JWT 전략에서는 디코드된 토큰의 userId가 DB의 유저와 일치하는지 평가함.

패스포트 기능을 쓰기 위해 AuthModule 설정을 바꾸자.

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 챕터 읽은 후 수정.. 아직 안 읽음)

Guards는 요청에 대한 처리 여부를 결정한다. 하지만 @nestjs/passport 모듈을 사용하는 상황에서의 새로운 기능도 있다.

인증 관점에서 어플리케이션은 두 가지 상태로 존재 가능

  • 유저가 로그인하지 않음(인증 X)
  • 로그인함(인증 O)

첫 번째 경우, 두 가지 기능을 수행해야 한다.

  1. 인증되지 않은 유저의 액세스 경로 제한

    해당 기능을 하는 가드에서 JWT 확인

  2. 인증 X 유저가 로그인 시도할 경우 인증 단계 시작

    POST로 username/password를 받아야 시작 → 이를 처리하기 위해 POST /auth/login 경로 설정 → 어떻게 passport-local strategy를 호출할 것인가?

    ⇒ 가드를 사용하면 됨! @nestjs/passport 모듈은 이런 역할을 하는 빌트인 가드를 제공한다. 이 가드는 패스포트 전략을 시작함

두 번째 경우, 로그인한 사용자에 대해 가드가 보호하는 경로에 액세스할 수 있도록 함


Login route

자 이제 /auth/login 라우트를 구현하고, passport-local을 시작하기 위한 빌트인 가드도 적용할 준비가 됐다.

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

AuthGuard가 바로 위에서 언급한 @nestjs/passport가 우리에게 준 선물..

passport local strateg는 ‘local’이라는 디폴트 이름을 가짐

@UseGuards() 데코레이터는 해당 이름을 참조하여 passport-local 패키지가 제공하는 코드와 연결

일단 테스트를 위해 간단하게 /auth/login 경로가 사용자를 반환하게 하자. (나중에 이 코드는 JWT를 만들고 리턴하는 코드로 대체)

passport는 validate() 메서드가 반환한 값으로 유저 객체를 만들고, Request 객체에 req.user로 할당한다.


JWT functionality - JWT 토큰 받기

JWT 부분을 추가해봅시다.

우리의 요구사항이 뭐였지?

  • username/password 이용해서 인증 후 JWT를 반환

필요 패키지 설치

$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt
  • @nestjs/jwt - JWT 생성을 도와줌
  • passport-jwt - JWT strategy 구현

POST /auth/login 요청 후 인증된 유저만 라우터 핸들러를 호출 할 수 있고, Request 객체에 user 프로퍼티가 있다고 했음

이걸 잊지 말고.. JWT를 생성하러 가보자.

우리는 authService 안에서 생성할거임.

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

  async validateUser(username: string, pass: string): Promise<any> {
    ...
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

JWT 스탠다드와 일치하도록 userIdsub이라는 프로퍼티로 들고있음!

JWT 생성을 위해 @nestjs/jwt가 제공하는 sign() 함수를 이용한다.

이제 JwtModule을 설정하고 AuthModule에 import해보자 (JwtService를 사용해야 하니까?)

  1. constants.ts 파일을 auth 폴더 내에 생성

    export const jwtConstants = {
      secret: 'secretKey',  // JWT 사인/검증 단계에서 사용
    };
  2. AuthModule에 JwtModule 추가 & AuthService export (AppController에서 사용)

    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';
    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 {}
  3. /auth/login 라우트가 JWT를 리턴하도록 수정해보자.

    import { Controller, Post, Request, UseGuards } from '@nestjs/common';
    import { AuthService } from './auth/auth.service';
    import { LocalAuthGuard } from './auth/local-auth.guard';
    
    @Controller()
    export class AppController {
      constructor(private authService: AuthService) {}
    
      @UseGuards(LocalAuthGuard)
      @Post('auth/login')
      async login(@Request() req) {
        return this.authService.login(req.user);
      }
    }
  4. 끝!

요청을 보내면????

짜잔.. 액세스 토큰을 받았습니다~


Implementing Passport JWT

드디어 우리의 최종 요구사항인.. 유효한 JWT를 요구함으로써 라우트 보호하기^^

또 패스포트가 도와줄겁니다.

auth 폴더 내에 jwt.strategy.ts 파일을 생성해보자.

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()를 옵션과 함께 호출해줍니다.

  • jwtFromRequest: Request에서 JWT를 추출하는 방법
    • 우리는 Authorization 헤더에 bearer token을 공급하는 표준 방식 사용
  • ignoreExpiration: 이름만 봐도 알겠죠..
  • secretOrKey: 이름만 봐도 알겠죠..

validate()

jwt-strategy의 경우, 패스포트는 먼저 JWT의 서명을 확인하고 JSON을 디코딩한다. 그런 다음 디코딩된 JSON을 validate()의 매개변수로 전달해서 호출.

이렇기 때문에.. validate() 콜백은.. 그저 객체를 반환할 뿐..

패스포트가 validate() 메서드의 반환값을 기반으로 Request 객체에 유저 프로퍼티를 붙이는 걸 잊지마..

패스포트가 준 payload를 기반으로 단순히 유저 객체를 반환하는 방식은.

반환 전에 다른 처리를 추가할 수 있도록 한다.

예를 들어, DB를 검색해서 유저에 대한 정보를 추가할 수 있고, 취소된 토큰 리스트에서 userId를 조회하여 토큰을 취소하도록 하는 추가 검증도 가능하다.

JwtStrategy를 AuthModule에 추가합시다.

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에 사인한 secret과 동일한 걸 적어준다. (검증에 필요 - 대칭키)


Implement protected route and JWT strategy guards

자 이제! 가드를 사용하는 보호된 라우트를 구현할수잇음

JWT 가드를 만들어봅시다.

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

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

그다음 AppController에 JWT 가드로 보호할 경로를 추가해보자

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

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

passport-jwt 모듈을 설정하고, @nestjs/passport 모듈이 제공하는 AuthGuard를 적용했다.

Get /profile 요청이 들어오면 가드는 자동으로 passport-jwt 로직(JWT를 검증하고 유저 프로퍼티를 Request 객체에 추가함)을 시작함

짜란~

Authorization 헤더에 Bearer ${access_token}을 추가하고 요청을 보내면 성공

토큰 만료 시간이 60초인데.. 이건 너무 짧을 수 있음. 예제라서 그렇게 함.
만료 후 요청을 시도하면 401 Unauthorized 응답을 받는다.
왜냐? JWT가 만료 시간을 자동으로 검사하기 때문


Extending guards

대부분의 경우, 제공된 AuthGuard 클래스를 사용하면 충분하다.

글치만 에러 핸들링이나 인증 로직을 추가하고 싶을 수도 있다.

이를 위해 빌트인 클래스를 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;
  }
}

(어떻게 하라는거임?)

그리고 이거 말고도, strategy 체인을 통과시켜서 인증을 처리할 수도 있다.

첫 전략의 성공, 리다이렉트, 오류는 체인을 중지시킨다.

인증 실패는 각 전략을 타고 순차적으로 진행되며(?) 모든 전략이 실패할 경우 최종적으로 실패함

export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

Enable authentication globally

컨트롤러 위에 @UseGuards() 데코레이터를 사용해서 해당 컨트롤러의 전체 라우터를 보호할 수 있다.

전체 경로에 대해 인증을 수행하고 싶으면 JwtAuthGuard를 글로벌 가드로 등록한다. (아무 모듈에서나)

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

이렇게 하면 모든 엔드포인트에 JwtAuthGuard가 바인드된다.

이제 우리는 라우트를 퍼블릭으로 선언하기 위한(모든 라우트를 JwtAuthGuard로 막았으니.. 회피하는 방법) 메커니즘을 제공해야 한다.

이를 위해, SetMetadata decorator factory 함수를 사용하여 커스텀 데코레이터를 만들 수 있음

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

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
  • IS_PUBLIC_KEY: 메타데이터 키
  • Public: 데코레이터(SkipAuth, AllowAnon 등 이름 변경 가능)

이제 @Public() 데코레이터가 생겼으니 사용 가능..

@Public()
@Get()
findAll() {
  return [];
}

마지막으로, JwtAuthGuard에서 “isPublic” 메차데이터가 발견되면 true를 리턴하게 수정해야 한다. → Reflector 클래스 사용

import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from 'src/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

Request-scoped strategies

패스포트 API는 라이브러리의 전역 인스턴스에 strategy를 등록하는 것을 기반으로 한다.

→ 요청별로 전략이 달라지거나 동적으로 인스턴스화되지 못함

“request-scoped” 전략을 만드는 물리적인 방법은 없다!

그러나.. 전략 내에서 “request-scoped” provider를 동적으로 리졸브하는 방법이 있지..

→ 모듈 참조 기능 활용..

필요하면 찾아 보자.. 아직은 오바인거같음

profile
nakkim.hashnode.dev로 이사합니다
post-custom-banner

0개의 댓글