[Nestjs] Authentication (passport)

zunzero·2022년 10월 31일
0

Node.js

목록 보기
3/4

Authentication 인증

Authentication은 대부분의 application에 필수적이다.
Authentication을 처리하는 데에는 다양한 접근법과 전략이 있다.
appliction의 요구사항에 따라 접근법을 선택하게 된다.

이번 글에서는 다양한 요구사항에 적용가능한 몇몇 접근법을 소개하려 한다.

Passport는 많은 production appplication에서 성공적으로 사용되는 것으로 잘 알려진 node.js authentication library이다.
@nestjs/passport 모듈을 사용하여 이 라이브러리를 Nest 애플리케이션과 통합하는 것은 간단하다.

Passport는 다음의 단계를 거쳐 실행한다.

  • Authenticate a user be verifying their "credentials" (such as username/password, JWT, or identity toen from an Identity Provider)
  • Manage authenticated state (by issuing a portable token, such as JWT, or creating an Express session)
  • Attach information about the authenticated user to the Request object for further use in route handlers

Passport에는 다양한 인증 메커니즘을 구현하는 풍부한 전략 에코시스템이 있다.
개념은 단순하지만, 선택할 수 있는 Passport strategy(전략) 집합은 크고 다양하다.
Passport는 이러한 다양한 step을 standard pattern으로 추상화하고, @nestjs/passport module은 이러한 패턴을 Nest 구조에 친화적으로 wrap하고 standardize한다.

우리는 이러한 강력하고 flexible한 module을 사용해서 RESTful API server를 위한 완벽한 end-to-end authentication solution을 구현해 볼 것이다.

Authentication requirements

클라이언트는 username과 password를 통해 authenticate을 시도할 것이다.
한 번 authenticated되면, 서버는 이후 요청에서 인증을 증명할 수 있는 JWT를 authorization headerd에 bearer token으로 발급할 것이다.

우리는 우선 user를 authenticate할 것이다.
이후에 JWT를 발급하는 과정으로 확장하고, 유효한 JWT를 가진 요청에만 응답하는 protected route를 만들 것이다.

Implementing Passport strategies

바닐라 Passport에서, 아래 2가지를 제공함으로써 strategy를 구성해야 한다.

1. A set of options that are specific to that strategy. For example, in a JWT strategy, you might provide a secret to sign tokens.

2. A "verify callback", which is where you tell Passport how to interact with your user store (where you manage user accounts.) 
Here, you verify whether a user exits (and/or create a new user), and whether their credentials are valid.
The Passport library expects this callback to return a full user if the validation succeeds, or a null if it fails
	(failure is defined as either the user is not found, or, in the case of passport-local, the password does not match).

@nestjs/passport를 통해, PassportStrategy class를 extending하여 Passport strategy를 구성할 수 있다.
subclass에서 super() 메서드를 호출하여 strategy option (위의 1번)을 넘길 수 있다. (optionally passing in an options object.)
subclass에서 validate() 메서드를 구현하여 verify callback (위의 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()
export class UserService {
  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);
  }
}

user.module.ts

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

@Module({
  providers: [UsersService],
  exports: [UsersService],	// AuthService에서 사용하기 위함
})
export class UsersModule {}

AuthService는 user를 검색하고, password를 검증해야하는 책임이 있다.
그러한 목적으로 validateUser() 메서드를 생성했다.

auth.service.ts

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

@Injectable()
export class AuthService {
  constructor(private userService: UserService) {}
  
  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.userService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

user가 db에 있어 null이 아니고, password가 일치한다면 result를 반환하는 로직을 실행한다.
없다면 null을 반환한다.

user의 속성 중 password를 제외한 나머지 속성이 result에 담긴다. (userId, username)

auth.module.ts

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

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

Implementing Passport local

이제 우리는 Passport local authentication strategy를 구현할 수 있다.
local.strategy.ts라는 파일을 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이 없다. 따라서 우리의 constructor는 options object 없이 단순히 super()를 호출한다.

validate() 메서드 또한 구현해냈다.
각각의 strategy마다, Passport는 verify function(@nestjs/passport에 있는 validate() 메서드를 통해 구현된다.)을 호출할 것이다.
local-strategy에서, Passport는 validate() 메서드가 다음의 signature를 따르길 바란다.
validate(username: string, password: string): any

우리의 AuthService의 validation 작업은 거의 끝났다.
간단하다.
모든 Passport의 validate() 메서드는 표현 방식만 살짝씩 다를 뿐, 비슷한 패턴을 따른다.
User가 있고 credential이 유효하다면, user가 반환되고, 따라서 Passport는 그의 작업(예를 들면 Request 객체에 user 속성을 생성한다든지 하는)을 해낼 수 이쏙, request handling pipeline도 지속될 수 있다.
만약 user가 발견되지 않는다면, 예외를 던지고 exceptions layer에처 처리할 수 있도록 한다.

각각의 strategy의 validate()에서 중요한 차이는 user의 존재 여부와 user 검증을 어떻게 결정하느냐이다.
JWT strategy를 예로 들어, 디코딩된 token에서 빼온 userId가 우리 데이터베이스에 기록된 것과 매치하는지, 혹은 취소된 token인지를 평가할 것이다.

Passport의 기능을 사용하기 위해 아래와 같이 auth.module.ts를 바꿔준다.

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

Guard에 대해 소개할 때 Guard의 주요기능에 대해 소개했다.

  • 요청(request)이 route handle에 의해 처리될지 말지를 결정한다.

@nestjs/passport module을 사용할 때, 처음에는 조금 혼란스러울 수 있는 새로운 흐름에 대해 이야기 해보려 한다.
application은 인증관점에서 2가지의 상태로 존재할 수 있다.

  • the user/client is not logged in (is not authenticated)
  • the user/client is logged in (is authenticated)

user가 not logged in 상태에서, 우리는 다음 2가지의 기능을 할 수 있다.

  • Restrict the routes an unauthenticated user can access.
    인증되지 않은 사용자가 접근할 수 있는 route를 제한한다.
    (deny access to restricted routes.)
    protected routes에 대해 Guard를 둠으로써, 해당 기능을 처리할 수 있다.
    예상대로 이 Guard에 유효한 JWT가 있는지 확인할 것이므로 JWT를 성공적으로 발행하면 나중에 이 Guard에 대해 작업할 것이다.
  • 이전에 인증되지 않은 사용자가 로그인을 시도할 때, authentication step을 시작할 수 있다.
    이 단계가 바로 유효한 user에게 JWT를 발급하는 단계이다.
    사용자를 authenticate을 위해 username/password credential을 보낼 것이다.
    그러면 우리는 POST /auth/login으로 route를 조정할 수 있다.

그렇다면 질문, 해당 route에서 passport-local strategy를 정확히 어떻게 호출할까?
답은 간단하다. 살짝 다른 타입의 Guard를 사용하는 것이다.
@nestjs/passport module은 이러한 작업을 해주는 내장 Guard를 제공한다.

해당 Guard는 Passport strategy를 호출하고 위에 명시된 작업을 시작한다. (retreiving credentials, running the verify function, createing the user property, 등)

위에 열거된 두 번째 경우(로그인한 사용자)는 로그인한 사용자가 보호된 경로에 액세스할 수 있도록 이미 논의한 표준 유형의 Guard에 의존한다.

Login Route

import { Controller, Request, Post, UserGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

Strategy가 마련되면, 기본 /auth/login route을 구현하고, 내장 Guard를 적용하여 passport-local flow를 시작할 수 있다.

@UseGuards(AuthGuard('local'))를 활용해서 passport-local strategy를 extend할 때 @nestjs/common이 자동으로 제공하는 AuthGuard를 사용할 수 있다.
우리의 Passport local strategy는 'local' 이라는 기본이름을 가지고 있다.
passport-local package에서 지원하는 코드와 연관짓기 위해 @UseGuards() decorator에 있는 이름을 참조할 수 있다.
이것은 앱에 여러 Passport 전략이 있는 경우 호출할 전략을 명확하게 하는 데 사용된다. (각각 전략별 AuthGuard를 제공할 수 있음).

우선 간단하게 우리의 /auth/login route를 테스트하기 위해 user를 반환해볼 것이다.
Passport의 다른 기능 또한 입증해볼 것이다: Passport는 validate() 메서드를 통해 반환된 값을 기본으로 하여 자동으로 user객체를 생성하고, 이를 Request 객체에 req.user의 형태로 할당한다.
이 후, 우리는 JWT를 생성하고 반환하는 것으로 코드를 대체할 것이다.

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

strategy name을 AuthGuard()에 다이렉트로 넘기면, 뭔가 엄청난게 발생한다??

local-auth.guard.ts

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

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

/auth/login route handler 코드를 아래와 같이 바꿀 수 있다.

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

정리

위의 내용은 작성한지 며칠돼서.. 흐름이 기억 안난다.
하지만 공식 문서 정리 내용이니 다음에 공식 문서와 함께 한 번 더 수정하도록 하겠다.

1. @UseGuards() decorator에 들어갈 Guard 작성
2. 해당 Guard는 주로 AuthGuard를 extend하며, type으로 string을 받음.
3. Guard가 AuthGuard를 상속하며 넘긴 type의 strategy의 validate 호출
4. strategy의 validate에서 username과 password로 사용자 검증
5. 이 과정에서 error를 던지거나, 사용자 정보를 반환하므로써 검증 실패/성공 여부 확인
6. Guard를 뚫었다면 API 진행!!
profile
나만 읽을 수 있는 블로그

0개의 댓글