[NestJS] Authentication

Hocaron·2021년 12월 14일
3

NestJS

목록 보기
6/12
post-custom-banner

Authentication Process

  • 자격 증명
    • ID/PW, JWT 또는 아이덴티티 토큰을 확인하여 사용자 인증
  • 인증 상태 관리
    • JWT 토큰 발행, Session 생성
  • 인증된 사용자에 대한 정보를 Request 객체에 첨부

Authentication Requirements

  1. 사용자 인증
  2. JWT를 발행
  3. 요청에서 유효한 JWT를 확인하는 보호 경로를 생성

Passport

  • Node에서 가장 유명한 인증 라이브러리
  • @nestjs/passport 모듈로 Nest와 함께 사용 가능
  • 다양한 인증/상태 관리 방식을 추상화해서 뼈대는 유지한 채로 커스텀하게 구현을 바꿀 수 있다. (전략 패턴)

Vanilla Passport

Passport 설정을 할 때 다음의 두 가지가 필요하다.

  1. Strategy-specific options
  2. A callback to verify credentials and manage user info
// passport-local 
declare class Strategy extends PassportStrategy {
  constructor(options: IStrategyOptions, verify: VerifyFunction);
}

// passport-jwt
declare class Strategy extends PassportStrategy {
  constructor(opt: StrategyOptions, verify: VerifyCallback);
}

Passport local strategy

  • passport 에서의 local strategy는 가장 기본적인 emailpassword 를 가지고 인증하는것이다. (로그인할 때 사용됨)

nest 에서도 @nest/passportpassport-local 을 사용하여 local strategy를 구현할 수 있다.

들어가기전 일단 local strategy가 동작하는 흐름을 보자.

  1. id , password 로 로그인 요청
  2. AuthGuard 를 통해 사용자 인증
  3. AuthGuard 가 호출되면 LocalStrategyvalidate 호출
  4. 유저가 인증되면 AuthGuardcanActivatetrue 리턴

Step1. 라이브러리 설치

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local
  • bcrypt 라이브러리
$ npm install bcrypt
$ npm install @types/bcrypt -D

Step2. local.strategy.ts 구현

  • authlocal.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) {
// 내부에 필드 작성하는거 잊지말기
// 문서 예시에는 안써있는데 안써주면 validate가 호출되지 않습니다.
    super({ usernameField: 'email', passwordField: 'password' }); 
  }

  async validate(email: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(email, password);

    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
  • authservice 에 위에서 사용할 validateUser 구현
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User) private usersRepository: Repository<User>,
  ) {}

  async validateUser(email: string, pass: string): Promise<any> {
    const user = await this.usersRepository.findOne({
      where: { email },
    });

    const password = await bcrypt.compare(pass, user.password);
    if (password) {
      const { password, ...userWithoutPassword } = user;
      return userWithoutPassword;
    }
    return null;
  }
}
  • auth.module.tsPassportModule , localStrategy 추가
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../user/entities/user.entity';

@Module({
  imports: [UserModule, PassportModule, TypeOrmModule.forFeature([User])],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Built-in Passport Guards

언제 쓰는 걸까?

@nestjs/passport 안에 AuthGuard 라는 내장 가드가 있다.

이 가드는 Passport strategry를 호출하고, 인증에 필요한 과정들을 시작하도록 한다.

  1. 인증 안된 유저가 인증이 필요한 routes에 접근할 때: 못하게 막도록 가드 사용.
    • passport jwt strategy 채택
  2. 인증 안된 유저가 로그인을 시도할 때: 인증을 시작하는 가드 사용.
    • passport local strategy 채택

Login route

어떻게 사용하는 걸까?

  1. app.controller 에 가서 /auth/login 라우트를 만든다.
  2. 이 때 가드를 사용. 가드는 아까 얘기했든듯 passport-local strategy를 사용할 것이므로 @UseGuards(AuthGuard('local')) 라고 작성한다.
//app.controller.ts
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

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

이렇게 해주기만 하면 passport가 알아서 다 해준다. (validate() 메서드로 user object를 생성, Request object에 user 할당 등...)

잘 하는지 보자

curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"

잘 나온다

{"userId":1,"username":"john"}

그리고 직접 넣는 대신에 클래스를 만들어 import할 수도 있다.

// 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
import { Controller, Request, Post, UseGuards } from '@nestjs/common';
// import { AuthGuard } from '@nestjs/passport'; 아까 한 거
import { LocalAuthGuard } from './auth/local-auth.guard';

@Controller()
export class AppController {
  // @UseGuards(AuthGuard('local')) 아까 한 거
  @UseGuards(LocalAuthGuard) 
  @Post('auth/login') // 1번
  async login(@Request() req) {
    return req.user;
  }
}

JWT functionality

앞으로 구현하는 부분

  • JWT를 발행하는 코드를 작성 -> 지금 이 파트를 구현한다.
  • 베어러(Bearer) 토큰으로 유효한 JWT의 존재를 기반으로 보호되는 API 경로 생성 -> 이건 다음 파트에서 구현한다.

Step1. 라이브러리 설치

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

Step2. JWT 발행 구현

  • authservice 에 위에서 사용할 login 구현
  • sign() 함수를 사용하여 user 객체 속성의 하위 집합에서 JWT를 생성한 다음 단일 access_token 속성이 있는 간단한 객체로 반환
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> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}
  • JWT 서명 및 확인 단계간에 키를 공유
// auth/constants.ts
export const jwtConstants = {
  secret: 'secretKey',
};
  • auth.module.tsJwtModule 추가
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 {}

Guard를 사용하여 JWT를 발행해보자

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

잘 나온다

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

Implementing Passport JWT

  • 베어러(Bearer) 토큰으로 유효한 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 };
  }
}
  • jwtFromRequest : Request 에서 jwt를 추출하는 방법
    • Authorization: bearer ${token} 표준 방식 사용
  • ignoreExpiration : JWT 만료시간 체크
    • 만료시에 401 Unauthorized response로 보냄
  • secretOrKey : token에 발급에 사용될 secret key
    • pem의 encoded public key를 권장

Validate

  async validate(payload: any) {
		try {
			const user = await UserModel.findOne(payload.sub)
			if (lodash.isNil(user) { throw new Error(Unauthorized, ~~~~) }
			return { user };		
		catch (err) {
			throw new Error
		}
  }

stateless와 stateful
stateful : 기존 방식으로 로그인시 서버에서 유저의 session을 저장하는 형태
stateless : session 없이 인증 하는 방식으로 유저와 서버가 서로를 인식하는 수단을 공유하는 형태, 대표적으로 사용하는 JWT

// auth/auth.module.ts

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' }, // expiration : 60초
    }),
  ],
  providers: [AuthService, LocalStrategy, **JwtStrategy**],
  exports: [AuthService, **JwtModule**],
})
export class AuthModule {}
  • sign단계, verify단계의 비밀키를 공통으로 사용함으로써 검증시 사용

JwtAuthGuard

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
  • 해당 클래스를 정의하여 실질적인 검증 route에 사용

Implement protected route and JWT strategy guards

  • JWT의 존재를 기반으로 보호되는 API 경로 생성해보자.
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// @nestjs/passport 모듈이 자동으로 프로비저닝한 모듈 사용
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;
  }
}
  • auth/login 을 통해서 얻은 access_token 을 통해 @UseGuards(JwtAuthGuard)를 데코레이팅 한 곳에서는 req.user 객체를 통해 유저 정보를 가져올 수 있게 된다.
  • 아닐 경우 기본적으로 401 Unauthorized 에러
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const can = await super.canActivate(context);
    if (can) {
      const request = context.switchToHttp().getRequest();
      console.log('login for cookie');
      await super.logIn(request);
    }

    return true;
  }
}

// AuthGuard의 모양새
import { CanActivate } from '@nestjs/common'; // CanActivate 메소드를 가진 Interface
import { Type } from './interfaces';
import { IAuthModuleOptions } from './interfaces/auth-module.options';
export declare type IAuthGuard = CanActivate & {
    logIn<TRequest extends {
        logIn: Function;
    } = any>(request: TRequest): Promise<void>;
    handleRequest<TUser = any>(err: any, user: any, info: any, context: any, status?: any): TUser;
    getAuthenticateOptions(context: any): IAuthModuleOptions | undefined;
};
export declare const AuthGuard: (type?: string | string[]) => Type<IAuthGuard>;
  • authGuard를 extends한 후 직접 커스터마이징도 당연히 가능!
  • canActivate 함수를 구현해주면 된다!

Extending guards, Enable authentication globally

Extending guards

  • 대부분의 경우 제공된 AuthGuard 클래스를 사용하는 것으로 충분
  • 단순히 기본 오류처리 또는 인증 논리를 확장할 때, 내장 클래스를 확장하고 하위 클래스 내에서 메서드 오버라이딩 가능
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 들을 거치도록 할 수 있음
  • 성공, 리다이렉션, 오류에 대한 첫번째 Strategy 는 체인을 중지
  • 인증 실패는 일련의 각 전략을 통해 진행
  • 모든 전략이 실패하면 최종적으로 실패
export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

Enable authentication globally

모든 엔드포인트를 기적으로 보호해야 하는 경우 각 컨트롤러 상단에서 @UseGuarlds() 데코레이터를 사용하는 대신 전역 가드로 인증 가드를 등록할 수 있다.

providers: [
	{
		provide: APP_GUARD,
		useClass: JwtAuthGuard,
	},
],
  • 경로를 공개로 선언하는 메커니즘을 제공
  • SetMetadata 데코레이터 팩토리 함수를 사용하여 커스텀 데코레이터를 만들 수 있음
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Public()
@Get()
findAll() {
  return [];
}
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
		// reflector 으로 IS_PUBLIC_KEY 의 메타데이터 값을 가져옴
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

Request-scoped strategies, Customize Passport, Named strategies

Request-Scoped Strategies

Passport는 Default-Scope 즉, 글로벌 인스턴스에 전략을 등록하는 것을 기반으로 한다.
따라서, 요청 종속 옵션을 갖거나 요청마다 동적으로 인스턴스화하지 않는다.
공식문서에서 예로써 등장하는 AuthService는 글로벌 Default- Scope이다.
🤔만약 해당 프로바이더가 Request-Scope로 설정되어있다면, 이를 어떻게 참조할 수 있을까?
🖐모듈 참조 기능을 활용하면 된다.

먼저 local.strategy.ts 파일의 생성자에 다음과 같이 ModuleRef를 참조하고, passReqToCallback 속성을 true로 설정한다.

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}

그 다음 validate() 메서드를 다음과 같이 정의한다.

async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request);
  // "AuthService" is a request-scoped provider
  const authService = await this.moduleRef.resolve(AuthService, contextId);
  ...
}

일반적으로 모듈 참조 기능을 이용할 때, 범위가 지정된 프로바이더를 참조했던 것처럼 하면 된다.
요청 객체를 이용해 컨텍스트 ID를 생성하고, 이를 이용해 resolve()를 호출하면 된다.
위 예에서 resolve() 메서드는 AuthService 프로바이더의 요청 범위 인스턴스를 비동기적으로 반환한다.

Customize Passport

register() 메서드를 이용해 Passport를 커스텀하게 사용할 수 있다.

PassportModule.register({ session: true });

또한, 전략을 구성하기 위해 생성자에 옵션 객체를 전달할 수 있다.
아래는 local strategy의 예이다.

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

기본적으로 local strategy는 username과 password 필드로 검사하도록 설정되어 있다.
email과 password 필드로 검사하고자 한다면, 위와 같이 해주면 된다.

Named Strategies

전략의 이름을 지정해 줄수도 있다.
전략을 구현할 때 PassportStrategy 함수의 두번째 인수에 지정하고픈 이름을 전달하면 된다.
만약 따로 지정해주지 않는다면, 기본 이름이 지정된다.(ex: jwt-strategy -> jwt)

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

그런 다음 @UseGuards(AuthGuard('myjwt'))와 같이 참조하면 된다.

DOCS 방식대로 소셜 로그인과 토큰들을 구현해보자

https://github.com/hocaron/social-login

profile
기록을 통한 성장을
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 2월 24일

잘 읽었씁니다 !

답글 달기