개발이야기/Node&Nest

Nest.js 에서 JWT 인증 구현하기

Roslyn 2024. 1. 22. 11:24
반응형

일단 기본적으로 간단하게 헤더에 토큰이 있는지 없는지만 검증하는 interceptor를 만들 겁니다.

다음 코드를 참고하세요.

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const accessToken = request.headers['access_token'];
    console.log("=====================================================");
    console.log('accessToken : ', accessToken);

    if (!isValidAccessToken(accessToken)) {
      throw new UnauthorizedException('Invalid access token');
    }

    request.accessToken = accessToken;

    return next.handle();
  }
}

function isValidAccessToken(token: string|null|undefined): boolean {
  if (token !== null && token !== undefined && token !== '') {
    return true;
  } else {
    return false;
  }
}

 

해당 코드는 사용자가 사용권한이 있는지 검증하는 코드를 작성하기 위한 NestInterceptor를 상속받은 클래스입니다.

사용자의 토큰이 존재하는지 확인해서, 해당 토큰을 request 개체에 넣어주기만 하면 됩니다.

해당 클래스를 사용하고자 하는 곳에 UseInterceptors를 이용해 선언해 주시면 됩니다.

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

@ApiTags("member")
@ApiBearerAuth('AccessToken')
@UseInterceptors(AuthInterceptor)
@Controller('member')
export class MemberController {
//기존 코드
}

 

이렇게 해서 Swagger 상에서 Authorize에 "test"라고 입력하면 그 값에 accessToken 변수에 담기게 됩니다.

 

자 이제 사용자가 로그인하여 JWT 토큰을 받아서 API 요청 권한을 획득하는 과정을 같이 탐험해 봅시다.

먼저 로그인 과정에서 필요로하는 DTO 모델이 2개가 필요합니다.

하나는 로그인때 사용자가 입력하는 id와 password이고, 로그인 완료 후 토큰에 암호화하여 넣어둘 정보가 필요합니다.

다음과 같이 모델을 만들어 봅시다.

 

import { ApiProperty } from '@nestjs/swagger';

class LoginUser {
    @ApiProperty({ description: 'UserID' })
	public id:string;

	@ApiProperty({ description: 'Password' })
	public password:string;

    constructor() {
        this.id = "";
        this.password = "";
    }
}

class UserInfo {
	@ApiProperty({ description: 'MemberIDX' })
	public memberIDX:number;
    
    @ApiProperty({ description: 'MemberID' })
	public memberID:string;

	@ApiProperty({ description: 'Name' })
	public name:string;

    constructor() {
        this.memberIDX = -1;
        this.memberID = "";
        this.name = "";
    }
}

export { LoginUser,UserInfo }

 

만들었으면 이번에는 암호 이슈를 처리하기 위해 추가로 필요한 라이브러리를 설치해 줍니다.

npm install bcryptjs jsonwebtoken uuid4

 

암호 관련 이슈는 다양한 곳에서 사용될 수 있으니, 별도의 서비스를 만들어 구현합니다.

crypto.service.ts 파일을 만들어서 작성해 봅시다.

import * as jwt from 'jsonwebtoken';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../config';
import { UserInfo } from '../dto';
import { Injectable } from '@nestjs/common';

@Injectable()
export class CryptoService {
  constructor(
  ) {}

  generateJwtToken(info: UserInfo): string {
    let data = { memberIDX : Number(info.memberIDX), name : String(info.name), memberID : String(info.memberID) };
    const expired = { expiresIn: '7d' };
    const token = jwt.sign(data, config.secret, expired);
    return token;
  }

  extendJwtToken(token: string | null | undefined): string | null {
    if (token !== null && token !== undefined && token !== '') {
      if (token === 'test-token') {
        return token;
      } else {
        const payload = this.reverseJwtToken(token);
        const currentTimestamp = Math.floor(Date.now() / 1000);
        payload.exp = currentTimestamp + 24 * 60 * 60 * 7;
        const newToken = jwt.sign(payload, config.secret);
        return newToken;
      }
    }
    return null;
  }

  reverseJwtToken(token: string): any {
    return jwt.decode(token, config.secret);
  }

  async hashAsync(password: string): Promise<string> {
    return await bcrypt.hash(password, 10);
  }

  async compareAsync(password: string, passwordHash: string): Promise<boolean> {
    return bcrypt.compare(password, passwordHash);
  }

  hash(password: string): Promise<string> {
    return new Promise((resolve, reject) => {
      bcrypt.hash(password, 10, (err, hashresult) => {
        if (err) {
          reject(err);
        } else {
          resolve(hashresult);
        }
      });
    });
  }

  randomTokenString(): string {
    return crypto.randomBytes(40).toString('hex');
  }

  tokenGet(header: string | null | undefined): UserInfo {
    let token: UserInfo = new UserInfo();

    try {
      if (header !== null && header !== undefined) {
        const tmp = String(header).trim();

        if (tmp !== null && tmp !== undefined && tmp !== '') {
          if (tmp === 'test-token') {
            token = new UserInfo();
            token.companycode = '';
            token.memberID = 'tester';
            token.sitekey = '';
            token.name = '홍길동';
          } else {
            token = this.reverseJwtToken(tmp);
          }
        }
      }
    } catch (e) {
      console.log('tokenGet Error : ', e.message);
    }

    return token;
  }

  uuid(): string {
    return uuidv4();
  }

  cryptoEncode(plaintext: string): string {
    const key = crypto.randomBytes(32);
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    let encrypted = cipher.update(plaintext, 'utf8', 'base64');
    encrypted += cipher.final('base64');
    return iv.toString('hex') + encrypted + key.toString('hex');
  }

  cryptoDecode(encrypted: string): string {
    const iv = Buffer.from(encrypted.slice(0, 32), 'hex');
    const key = Buffer.from(encrypted.slice(-64), 'hex');
    encrypted = encrypted.slice(32, -64);
    const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
    let decrypted = decipher.update(encrypted, 'base64', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
  }
}

 

특정 서비스를 다른 서비스에서 불러와 사용하려면, 해당 서비스는 별도의 모듈에 등록해야 합니다.

껍떼기(?)로 사용할 모듈을 작성해 봅시다.

import { Module } from "@nestjs/common";
import { CryptoService } from "src/services";

@Module({
    imports: [

    ],
    controllers: [],
    providers: [CryptoService],
    exports: [CryptoService],
  })
  export class CryptoModule {}

 

 

물론 CryptoService를 사용하려면 app.module.ts에 등록해 줘야 합니다.

import { Module } from '@nestjs/common';
import { MemberController,UserController } from './controllers';
import { MemberService,UserService,CryptoService } from './services';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Member } from './entities';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      logging:false,
      type: 'mssql',
      host: String(process.env.DATABASE_HOST),
      port: Number(process.env.DATABASE_PORT),
      username: String(process.env.DATABASE_USERNAME),
      password: String(process.env.DATABASE_PASSWORD),
      database: String(process.env.DATABASE_NAME),
      entities: [Member],
      synchronize: Boolean(process.env.DATABASE_SYNC),
      extra: {
        options: {
          encrypt: false,
          TrustServerCertificate: true,
        },
      },
    }),
    TypeOrmModule.forFeature([Member]),
  ],
  controllers: [MemberController,UserController],
  providers: [MemberService,UserService,CryptoService],  //여기에 추가해 주세요.
  
})
export class AppModule {}

 

이제 해당 서비스를 이용해서 jwt 토큰을 만들어 봅시다.

사용하고자 하는 서비스에서 의존성 주입을 통해 CryptoService를 불러오고, this 접근자를 이용해 해당 서비스의 함수를 호출할 수 있습니다.

 

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Member } from '../entities';
import { ReturnValue,ReturnValues } from '../models';
import { LoginUser,UserInfo } from '../dto';
import { CryptoService } from './crypto.service';

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(Member)
        private readonly repository: Repository<Member>,
        private crypto:CryptoService
    ){};

    async LoginUser(user:LoginUser):Promise<ReturnValues> {
        let result = new ReturnValues();

        if (user !== null && user !== undefined && user.id !== null && user.id !== undefined && user.password !== null && user.password !== undefined && user.id !== '' && user.password !== '') {
            let member = await this.repository.findOne({
                where: { memberID: user.id },
            });

            if (member !== null && member !== undefined && member.memberIDX > 0) {
                let chk = await this.crypto.compareAsync(user.password, member.password);
                if (chk)
                {
                    let info:UserInfo = new UserInfo();
                    info.memberID = member.memberID;
                    info.memberIDX = member.memberIDX;
                    info.name = member.name;
                    const jwtToken = this.crypto.generateJwtToken(info);
                    result.Success(member.memberIDX, member, jwtToken, "");
                }
                else
                {
                    result.Error("NoPermission Account");
                }
            } else {
                result.Error("NotFound User Info");
            }
        } else {
            result.Error("Required User Info");
        }

        return result;
    }
}

 

이제 이걸로 Swagger에서 로그인을 시도하면 정상적으로 로그인 성공시 JWT 토큰이 반환되는 것을 볼 수 있습니다.

 

그럼 반환된 jwt 토큰을 복사해서 Authorize에 집어 넣어 봅시다.

이제 조회 쿼리를 실행해서 토큰이 복호화가 잘 되는지 확인해 봅니다.

 

먼저 컨트롤로에서 request로 넘어온 토큰 값을 service의 메소드에 넘겨줘야 합니다.

  @Get("list")
  async findAllUsers(@Req() request): Promise<Member[]> {
    return await this.service.findAllUsers(request.accessToken);
  }

 

아까 우리가 accessToken이란 이름으로 request에 담은거 기억하시죠?

이제 이걸 서비스 함수에서 해석해서 정당한 사용자인지를 검증합니다.

    async findAllUsers(accessToken:string): Promise<Member[]> {
        if (accessToken !== null && accessToken !== undefined && accessToken !== '') {
            let user = this.crypto.tokenGet(accessToken);
            if (user !== null && user !== undefined && user.memberIDX > 0) {
                return await this.repository.find();
            } else {
                throw new UnauthorizedException('Invalid access token');
            }
        } else {
            throw new UnauthorizedException('Required access token');
        }
        
    }

 

이제 권한이 있는 사용자만이 사용자 전체 목록을 볼 수 있게 됐습니다.

의도한대로 동작하는지 확인해 볼까요?

개인정보보호를 위해 실제 조회된 데이터는 캡처하지 않았어요

 

네 Curl 부분에 보면 요청한 access_token에 우리가 Authorize에 입력한 토큰 값이 전달되는게 보일 겁니다.

그리고 정상적으로 조회가 되고 있습니다.

 

 

반응형