NestJS | JWT 발행 및 만료처리, 기능 권한 제한

Stellar·2021년 7월 28일
2

JS Runtime

목록 보기
5/13
post-thumbnail

# JWT, GUARD, STRATEGY 프로세스

controller -> Guard -> Strategy -> Service


# JWT 발행 및 만료처리

NestJS-기초세팅 완료 후 진행

유저 CRUD 없이 바로 진행한 상태라 간단하게 CRUD 작성 후 진행함.
NestJS | NestJS CRUD

## passport 설치

$ npm install passport-local
$ npm i --legacy-peer-deps @nestjs/passport
$ npm i passport-jwt

## jwt 설치

$ npm i --legacy-peer-deps @nestjs/jwt
$ npm i --legacy-peer-deps @types/passport-jwt

## auth 폴더, 모듈, 서비스, 컨트롤러 생성

//src/auth
$ nest g resource auth --no-spec

=======================================================
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? No

## login DTO 작성

기존에 users 폴더에 login dto를 작성하였는데 auth로 옮겼다.
users/dto -> auth/dto

// ./src/auth/dto/login-user.dto.ts
import { IsString } from "class-validator";

export class LoginUserDto {
    @IsString()
    email: string;

    @IsString()
    password: string;
}

## auth.service 작성

로그인 시 email, password 일치 검증

//auth.service.ts
import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { LoginUserDto } from 'src/auth/dto/login-user.dto';
import { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm';
import * as bcrypt from "bcrypt";

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

    async validateUser(loginUserDto: LoginUserDto): Promise<any> {
        // DB ID 일치 검증
        const userInfo = await this.userRepository.findOne({email: loginUserDto.email});
        if(!userInfo) {
            throw new UnauthorizedException({
                statusCode: HttpStatus.UNAUTHORIZED,
                message: ['USER_INFORMATION_DOES_NOT_MATCH'],
                error: 'Unauthorized'
            })
        }

        // DB PASSWORD 일치 검증
        const isMatch = await bcrypt.compare(loginUserDto.password, userInfo.password);
        if(isMatch) {
            const { password, ...result } = userInfo;
            return result;
        } else {
            throw new UnauthorizedException({
                statusCode: HttpStatus.UNAUTHORIZED,
                message: ['USER_INFORMATION_DOES_NOT_MATCH'],
                error: 'Unauthorized'
        })

    }
}
// 토큰 생성 후 반환
    async login(data: any) { //이름이 꼭 user일 필요는 없다
        const payload = { email: data.email, type: data.type};
        
        return {
            email: payload.email,
            accessToken: this.jwtService.sign(payload)
        }
    }
}

## local 스트레이트지 작성

사용자의 EMAIL & PASSWORD 유효성 검사를 한다.

import { HttpStatus, Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { LoginUserDto } from "src/auth/dto/login-user.dto";
import { AuthService } from "../auth.service";

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
    constructor(private authService: AuthService) {
        super({
            usernameField: 'email'
        });
    }

    async validate(email: string, password: string): Promise<any> {
        const loginUserDto: LoginUserDto = { email, password }

        const userInfo = await this.authService.validateUser(loginUserDto);
        if(!userInfo) {
            throw new UnauthorizedException({
                statusCode: HttpStatus.UNAUTHORIZED,
                message: ['USER_INFORMATION_DOES_NOT_MATCH'],
                error: 'Unauthorized'
            })
        }
        return userInfo;
    }
}

## local-auth Guard 작성

LocalAuthGuard는 Passport의 local-strategy를 수행하는 Guard이다.

//auth/guards/local-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

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

## JWT 시크릿 키 설정

RandomKeygen에서 256-bit 형식의 CodeIgniter Encryption Keys를 사용한다.

해당 파일은 꼭 .gitignore 파일에서 숨김 처리

//src/constants.ts
export const bcryptConstant = {
    saltOrRounds: 10,
  };

  export const jwtConstants = {
    secret: '랜덤 시크릿 키',
};

## auth.controller 작성

로그인 기능

//auth.controller.ts
import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from 'src/auth/guards/local-auth.guard'
import { authPublic } from 'src/auth/authPublic.decorator';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(LocalAuthGuard) // EMAIL&PASSWORD 유효성 검사 가드
  @authPublic()
  @Post('login')
  @HttpCode(200) //Post statusCode는 기본값이 201이므로 200을 별도로 지정해줘야함.
    async login(@Req() req) {
      return this.authService.login(req.user); 
      //Passport는 validate() 메서드에서 반환한 값을 기반으로 user 객체를 자동으로 생성하고 req.user로 Request 객체에 할당
  }
}

## AuthModule 작성

모듈과 스트레이트지, JWT를 사용할 수 있도록 설정한다.

//auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/users/entities/user.entity';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from 'src/constants'

@Module({
  imports: [
    TypeOrmModule.forFeature([User]), 
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' }, //토큰 만료 시간 지정
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService]
})
export class AuthModule {}

# JWT 만료 설정 - s초, m분, h시간, d일 별로 설정 가능

JWT 발행 성공!

## jwt 스트레이트지 작성

만료된 토큰은 사용 불가하게 기능 구현

//jwt.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { jwtConstants } from "src/constants";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor() {
        super({
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //생성자에서 검증에 사용할 토큰의 종류 선택
            ignoreExpiration: false, //만료된 토큰을 사용하지 못하도록 하려면 true로 저장하기.
            secretOrKey: jwtConstants.secret,
        });
    }

    async validate(data: any) {
        return { email: data.email, type: data.type } //로그인 시 사용되는 ID, 추후에 역할 제한을 위한 type정보
    }
}

Error: Unknown authentication strategy "jwt"

## AuthModule에 JwtStrategy 추가

//auth.module.ts
...
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
 ...
  providers: [AuthService, LocalStrategy, JwtStrategy],
  ...
})
export class AuthModule {}

## jwt-auth 가드 작성

JwtAurhGuard는 Passport의 jwt-strategy를 수행하는 Guard이다.

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

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

## jwt-auth 가드 기능에 적용

적용할 기능별로 지정이 가능하다.

//users.controller.ts
@UseGuards(JwtAuthGuard) //만료된 토큰은 사용불가
  @Get()
  findAll() {
    return this.usersService.findAll();
  }
auth.module에서 expiresIn에 설정된 시간이 지난 토큰은 만료되어 사용 불가

## jwt-auth 가드 전역 가드 지정

기능별이 아닌 만료된 토큰은 전체 기능에서 사용 불가로 지정한다.

//app.module.ts
...
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
...
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';

@Module({
  ...,
  { //jwt-auth 가드 전역
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],
})
export class AppModule {}

# JWT 만료 전역 가드 지정 후 로그인도 같이 설정되어 버리는 문제 수정

## 로그인 기능 기준 로직 프로세스

auth.controller @authPublic 데코레이터가 실행 -> authPublic.decorator에서 지정한 'isPublic' MetaData를 가드로 전달 -> jwt-auth.guard파일에서 MetaData를 감지 후 true를 리턴하여 전역 가드를 통과 시켜준다.

## authPublic Decorator 작성

MetaData 전달을 위해 데코레이터 작성

//auth/authPubilc.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const authPublic = () => SetMetadata('isPublic', true)

## main 설정

//main.ts
...
import { NestFactory, Reflector } from '@nestjs/core';
...
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const reflector = app.get(Reflector); //추가

  app.useGlobalGuards(new JwtAuthGuard(reflector)); //추가
  ...
  await app.listen(3006);
}
bootstrap();

## Guard 작성

참고한 글에서는 jwt-auth.guard.ts 파일에 canActivate 로직을 구현하는데 나는 strategy를 따로 생성했으니 스트레이트지 파일에 작성해야하는 줄 알았지만 jwt-auth.guard.ts 파일에 로직을 구현하니 성공했다.

//jwt-auth.guard.ts
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";

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

    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const isPublic = this.reflector.get<boolean>( 
            'isPublic',
            context.getHandler()
        );
        if (isPublic) {
            return true;
        }

        return super.canActivate(context);
    }
}

canActivate
해당 가드는 라우터에 대해서 접근이 가능한지 불가능 한지 접근 권한을 체크 하는 기능을 한다. 해당 가드를 사용 하고자 한다면 구현하고자 하는 가드 클래스에 CanActivate 인터페이스 모듈을 구현 해야하며 해당 인터페이스에는

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) :boolean | Observable<boolean> | Promise<boolean> 

메소드가 있으며 해당 메소드 안에서 라우터 접근 로직을 구현 하거나 구현된 서비스를 사용 하면 된다.

  • 반환값이 true이면 요청이 처리, 반환값이 false이면 Nest는 요청을 거부

#### 참고

## 기능별로Public 데코레이터 추가

...
import { authPublic } from "./authPublic.decorator";

@Controller('auth')
export class AuthController {
    constructor(private authService: AuthService) {}
    
    @Post('login')
    @authPublic() // 데코레이터 추가
    ...
    async login(@Req() req) {
        return this.authService.login(req.user);
    }
}

## 테스트

참고한 사이트


# 기능별 사용 유저 권한 구분 (Role)

## entity 수정

계정 별로 권한을 생성해 준다.

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({comment: 'manager, user'})  //컬럼 추가
    type: string;

    @Column({unique: true})
    email: string;

    ...
}

## enum 작성

entity에 작성해도 되지만 구분을 위해 새 파일을 생성한다.

//auth/enums/role.enum.ts
export enum UserType {
    USER = 'user',
    ADMIN = 'manager'
  // 상수는 대문자로 지정
}

## role 데코레이터 작성

MetaData 전달을 위해 데코레이터 작성

//auth/role.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const Role = (type: string) => SetMetadata('role', type);

## jwt_decode 설치

$ npm install --save @types/jwt-decode

## role 가드 작성

role 가드는 NestJS 내장된 기능으로 스트레이트지가 필요없다.

//auth/guards/role.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, HttpCode, HttpStatus, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import jwt_decode from "jwt-decode";

@Injectable()
export class RoleGuard implements CanActivate {
    constructor( private reflector: Reflector) {}

    canActivate(context: ExecutionContext): boolean {
        const requireRoles = this.reflector.getAllAndOverride<string[]>( 'role', [
            context.getHandler(),
            context.getClass()
        ]);

        if(!requireRoles) {
            return true;
        }

        const request = context.switchToHttp().getRequest();
        const token = jwt_decode(request.headers.authorization);

        if(requireRoles === token['type']) {
            return true
        } else {
            throw new ForbiddenException({
                statusCode: HttpStatus.FORBIDDEN,
                message: ['FORBIDDEN'],
                error: 'Forbidden'
            })
        }
    }
}

Unable to decode jwt with jwt-decode

When you import the jwt_decode, you should surpass a rule from tslint, your code will look exactly like this (with commented line above)

 // @ts-ignore  
import jwt_decode from "jwt-decode";

jwt의 authorization을 header에서 가져오기 - tabnine

//참고한 코드
//src/guards/auth.guard.ts/AuthGuard/canActivate
async canActivate(context: ExecutionContext): Promise<any> {
  const request = context.switchToHttp().getRequest();
  const token = request.headers.authorization; 
  
  try {
   request.user = await this.authService.verifyToken(token);
  } catch (e) {
   throw new UnauthorizedException(e);
  }
  return true;
 }

## 가드 전역 지정

@Role 데코로 지정한 기능만 권한제한 설정

//app.module.ts
...
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
...
import { RolesGuard } from './auth/guards/roles.guard';

@Module({
  imports: [
   ...
  ],
  controllers: [AppController],
  providers: [AppService,
...
  { //role 가드 전역
    provide: APP_GUARD,
    useClass: RolesGuard,
  },
],
})
export class AppModule {}

## 권한 둘 기능에 데코레이터 적용

guard 파일에서 canActivate 기능으로 별도의 @Role을 지정하지 않으면 권한제한이 없다.

//users.controller.ts
...
import { Role } from 'src/auth/role.decorator';
import { UserType } from 'src/auth/enums/role.enum';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Role(UserType.ADMIN) //권한 지정. ADMIN 계정만 사용 가능
  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
  • 권한 없는 유저가 기능을 사용한 경우 403

참고

0개의 댓글