인증 기능 구현을 위한 준비

AuthModule

  • AuthController
  • UserEntity
  • AuthService
  • UserRepository
  • JWT, Passport

명령어를 통해 생성

nest g module auth 
nest g controller auth --no-spec 
nest g service auth --no-spec 

명령어를 통해 생성해주면 자동으로 의존성이 적용된다.

 

 

User를 위한 Entity 생성

유저에 대한 인증을 하는 것이니 유저가 필요하다.

user.entity.ts 생성

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

@Entity()
export class User extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;
    
    @Column()
    username: string;

    @Column()
    password: string;
}

user.repository.ts생성

import { DataSource, Repository } from "typeorm";
import { User } from "./user.entity";
import { Injectable } from "@nestjs/common";

@Injectable()
export class UserRepository extends Repository<User> {
  constructor(dataSource: DataSource) {
    super(User, dataSource.createEntityManager());
  }
}

auth module에 repository import

@Module({
  imports: [
    TypeOrmModule.forFeature([User])
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    UserRepository
  ]
})
export class AuthModule {}

auth service에 repository 추가

@Injectable()
export class AuthService {
    constructor (
        @InjectRepository(UserRepository) 
        private userRepository: UserRepository,
    ) {}
}

 

회원가입기능 구현

dto 추가

export class AuthCredentialDto {
    username: string;
    password: string;
}

repository 함수 추가

  async createUser(authCredentialDto: AuthCredentialDto): Promise<void> {
    const {username, password} = authCredentialDto;
    const user = this.create({username, password});
    
    await this.save(user);
  }

service 함수 추가

    async signUp(authCredentialDto: AuthCredentialDto): Promise<void> {
        return this.userRepository.createUser(authCredentialDto);
    }

controller 함수 추가

    @Post('/signup')
    signUp(@Body() authCredentialDto: AuthCredentialDto): Promise<void> {
        return this.authService.signUp(authCredentialDto);
    }

 

 

테스트

user라는 테이블로 postgresql에 생성하려고하니 예약어?라고하여 생성이 안됩니다.

CREATE TABLE "user" ( id SERIAL PRIMARY KEY, username VARCHAR(30) NOT NULL, password VARCHAR(30) NOT NULL );

위와같이 사용하거나 이미 존재하는 테이블이 있다고하면 아래와 같이 테이블을 드랍한 뒤, 위 명령어를 수행하면 됩니다.

DROP TABLE IF EXISTS "user";

 

 

유저 데이터 유효성 체크

class-validator

dto에 class-validator를 이용하여 유효성을 체크해주는 데코레이터를 추가

import { IsString, Matches, MaxLength, MinLength } from "class-validator";

export class AuthCredentialDto {
    @IsString()
    @MinLength(4)
    @MaxLength(20)
    username: string;

    @IsString()
    @MinLength(4)
    @MaxLength(20)
    // 영어랑 숫자만 가능한 유효성 체크
    @Matches(/^[a-zA-Z0-9]*$/)
    password: string;
}

 

 

ValidationPipe을 controller에 추가

    @Post('/signup')
    signUp(@Body(ValidationPipe) authCredentialDto: AuthCredentialDto): Promise<void> {
        return this.authService.signUp(authCredentialDto);
    }

위처럼 controller에 추가하면 service로직으로 가지않고 controller에서 요청을 차단한다.

한국어를 포함했을 때, error message

 

 

유저 이름에 유니크한 값 주기

2가지 방법

1. repository에서 findOne 메소드를 이용해서 이미 같은 유저 이름을 가진 아이디가 있는지 확인하고 없다면 데이터를 저장하는 방법. 하지만, 해당 방법은 데이터베이스 처리를 두번 해줘야 한다.

2. 데이터베이스 레벨에서 만약 같은 이름을 가진 유저가 있다면 에러를 던져주는 방법

 

2번째 방법으로 구현

구현하는 방법은 간단하다. entity에 unique 데코레이터를 사용하면 된다.

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

@Entity()
@Unique(['username'])
export class User extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;
    
    @Column()
    username: string;

    @Column()
    password: string;
}

위처럼 username을 Unique화 하면 저장할 때 username이 있다면 저장할 수 없도록 한다.

500 error

위처럼 이미 username이 있다면, 별다른 에러 메시지 없이 500 error를 던져준다. 해당 에러 메시지는 문제를 해결하기에는 어려움을 줄 수 있다. 

500 에러를 주는 이유는 nest.js에서 try catch 구문인 catch에서 별다른 에러를 잡지 않는다면 controller레벨로 가서 500에러를 던져버리기 때문이다. 그렇기 때문에, 다른 에러 메시지와 코드를 주고싶다면 try catch 문을 추가해야 한다.

 

repository 코드 수정

  async createUser(authCredentialDto: AuthCredentialDto): Promise<void> {
    const {username, password} = authCredentialDto;
    const user = this.create({username, password});
    
    try { 
        await this.save(user);
    } catch (error) {
        if(error.code === '23505') {
            throw new ConflictException('Existing username');
        } else {
            throw new InternalServerErrorException();
        }
    }
  }

정확한 에러 메시지가 오는 모습

error.code가 23505를 해준 이유는 log를 출력해본 뒤, 해당 에러 코드를 catch해준 코드이다.

 

 

비밀번호 암호화 하기

현재는 비밀번호가 db에 노출되는 상태이다. 이것은 보안에 치명적일 수 있다.

-> 암호화

bcryptjs라는 모듈을 통해 암호화 해주려고 한다.

npm install bcryptjs --save

 

비밀번호를 db에 저장하는 방법

1. 원본 비밀번호를 저장 (최악)

2. 비밀번호를 암호화 키(Encryption Key)와 함께 암호화 (양방향)

-> 암호를 이용하여 비밀번호를 암호화하고 복화하를 하는 방법 (암호화 키가 노출되면 알고리즘은 대부분 오픈되어 있기 때문에 위험도 높음)

3. SHA256 등 Hash로 암호화해서 저장 (단방향)

  • - 1234 -> 03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4로 암호화
  • 암호화만 가능하고, 복화하는 가능하지 않다. (안정적)
  • 하지만, 레인보우 테이블을 만들어서 암호화된 비밀번호를 비교해서 알아낼 수 있음 (유저들이 비슷한 암호를 사용한다고 가정하고 여러 방법으로 비밀번호를 알아내는 방법)

4. 솔트(salt) + 비밀번호(Plain Password)를 해시로 암호화해서 저장. 암호화할 때, 원래 비밀번호에다 salt를 붙인 후에 해시로 암호화

  • 1234 ==>> salt_1234를 해시로 암호화
  • 레인보우 테이블을 통해 해킹 시도를 차단
  • bcryptjs는 위와같은 방식

 

bcrypts를 사용하여 user.repository.ts 코드 추가

import * as bcrypt from 'bcryptjs';  // bcryptjs 모듈 import

async createUser(authCredentialDto: AuthCredentialDto): Promise<void> {
    const {username, password} = authCredentialDto;

    const salt = await bcrypt.genSalt();
    const hashedPassword = await bcrypt.hash(password, salt);
    const user = this.create({username, password: hashedPassword});
    
    try { 
        await this.save(user);
    } catch (error) {
        if(error.code === '23505') {
            throw new ConflictException('Existing username');
        } else {
            throw new InternalServerErrorException();
        }
    }
  }

테스트

해시값으로 비밀번호가 저장되는 것을 확인할 수 있다.

 

 

로그인 기능 구현

service 코드 추가

    async signIn(authCredentialDto: AuthCredentialDto): Promise<string> {
        const {username, password} = authCredentialDto;
        const user = await this.userRepository.findOneBy({username});

        if(user && (await bcrypt.compare(password, user.password))) {
            return 'logIn success'
        } else {
            throw new UnauthorizedException('logIn fail')
        }
    }

controller 코드 추가

    @Post('/signin')
    signIn(@Body(ValidationPipe) authCredentialDto: AuthCredentialDto): Promise<string> {
        return this.authService.signIn(authCredentialDto);
    }

테스트

bcrypt를 테스트할 때 가입한 아이디로 로그인에 성공하는 것을 확인할 수 있다.

 

 

JWT에 대하여

로그인할 때 로그인한 고유 유저를 위한 토큰을 생성해야 하는데 그 토큰을 생성할 때 JWT라는 모듈을 사용한다.

JWT

Json Web Token의 약자로 당사자간에 정보를 JSON 개체로 안정하게 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준이다. 이 정보는 디지털 서명이되어 있으므로 확인하고 신뢰할 수 있다.
-> 간단하게 이야기하자면, 정보를 안전하게 전할 때 혹은 유저의 권한 같은 것을 체크하기 위해서 사용하는데 유용한 모듈이다.

JWT 구조

 

aaaaaaa.bbbbbbbb.ccccccccccccc

header

토큰에 대한 메타 데이터를 포함하고 있다. (타입, 해싱 알고리즘 SHA 256, RSA, ....)

payload

유저 정보, 망료기간, 주제 등등

verify Signature

토큰이 보낸 사람에 의해 서명되었으며 어떤 식으로든 변경되지 않았는지 확인하는 데 사용되는 서명이다. 서명은 헤더 및 페이로드, 서명 알고리즘, 비밀 또는 공개 키를 사용하여 생성된다.

 

JWT 사용 흐름

유저 로그인 -> 토큰 생성 -> 토큰 보관

그렇다면, 생성된 토큰으로 어떻게 인증을 할까?

생성된 토큰은 유저가 로그인할 때 헤더에 포함하여 서버에 요청한다.

서버는 aaaaa.bbbbb 부분을 통해 ccccc부분을 생성한다.

생성할 때, 위 `your-256-bit-secret`이 서버마다 다르기 때문에 서버에서 만들어진 signature가 같다면, 로그인 성공 같지 않다면 실패로 토큰을 통해 로그인을 한다.

 

 

JWT를 이용해서 토큰 생성하기

모듈 설치

npm install @nestjs/jwt @nestjs/passport passport passport-jwt --save

 

애플리케이션에 JWT 모듈 등록하기

@Module({
  imports: [
    JwtModule.register({
      secret:'Secret1234',
      signOptions:{
        expiresIn: 60 * 60,
      }
    }),
    TypeOrmModule.forFeature([User])
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    UserRepository
  ]
})
export class AuthModule {}
  • Secret
    • 토큰을 만들 때 이용하는 Secret 텍스트 (아무 텍스트나 넣어도 된다)
  • expiresIn
    • 정해진 시간 이후에는 토큰이 유효하지 않게 된다. 60 * 60은 한시간 이후에 토큰이 만료됨을 나타낸다. ( 초 단위 인듯하다.)

 

애플리케이션에 Passport 모듈 등록하기

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt'}),
    JwtModule.register({
      secret:'Secret1234',
      signOptions:{
        expiresIn: 60 * 60,
      }
    }),
    TypeOrmModule.forFeature([User])
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    UserRepository
  ]
})
export class AuthModule {}

 

로그인 성공 시 JWT를 이용해서 토큰 생성해주기

Service에 SignIn 메소드에서 생성해주면 된다. auth 모듈에 JWT를 등록해주었기 때문에 Service에서 JWT를 가져올 수 있다.

@Injectable()
export class AuthService {
    constructor (
        @InjectRepository(UserRepository) 
        private userRepository: UserRepository,
        private jwtService: JwtService
    ) {}
    ...
}

service 코드 추가

    async signIn(authCredentialDto: AuthCredentialDto): Promise<{accessToken: string}> {
        const {username, password} = authCredentialDto;
        const user = await this.userRepository.findOneBy({username});

        if(user && (await bcrypt.compare(password, user.password))) {
            // 유저 토큰 생성 (Secret + Payload)
            // 토큰을 통해 정보를 가져가기 쉽기 때문에, 중요한 정보는 넣으면 안된다.
            const payload = {username};
            const accessToken = await this.jwtService.sign(payload);

            return {accessToken};
        } else {
            throw new UnauthorizedException('logIn fail')
        }
    }

controller 코드 추가

    @Post('/signin')
    signIn(@Body(ValidationPipe) authCredentialDto: AuthCredentialDto): Promise<{accessToken: string}> {
        return this.authService.signIn(authCredentialDto);
    }

로그인할 때 토큰을 생성해주었다.

로그인할 때, 액세스 토큰이 response로 오는 모습이다. 어떤 의미인지 jwt 사이트에서 확인해볼 수 있다. 토큰을 복사하여 jwt사이트에서 확인해보자.

payload에 username이 보이는 것을 볼 수 있다.

 

 

Passport, JWT를 이용해서 토큰 인증 후 유저 정보 가져오기

 현재 payload안에 username을 넣어놓은 상태이다. 해당 username을 통해 유저 정보를 가져오는 것을 만들어 보자.

npm install @types/passport-jwt --save

jwt.strategy.ts 생성

import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { InjectRepository } from "@nestjs/typeorm";
import { ExtractJwt, Strategy } from "passport-jwt";
import { UserRepository } from "./user.repository";
import { User } from "./user.entity";

// 다른 곳에서도 해당 class를 사용할 수 있도록
@Injectable()
// Strategy는 jwt strategy를 사용하기 위해 넣어주는 것
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor (
        @InjectRepository(UserRepository)
        private userRepository: UserRepository
    ) {
        super({
            secretOrKey: 'Secret1234', // 토큰을 생성할 때 사용한 key를 유효한지 확인하기 위해 같은 key 값을 넣어 준다.
            // Bearer Token의 type으로 넘어오는 값을 추출한다는 의미
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() 
        })
    }

    async validate(payload) {
        const {username} = payload;
        const user: User = await this.userRepository.findOneBy({username});

        if(!user) {
            throw new UnauthorizedException();
        }

        return user;
    }
}

module에 추가

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt'}),
    JwtModule.register({
      secret:'Secret1234',
      signOptions:{
        expiresIn: 60 * 60,
      }
    }),
    TypeOrmModule.forFeature([User])
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    JwtStrategy,
    UserRepository
  ],
  exports: [
    JwtStrategy,
    PassportModule
  ]
})
export class AuthModule {}

 

요청안에 유저 정보(유저 객체)가 들어가게 하는 방법

validate 메소드에서 return 값을 user 객체로 주었다. 그래서 요청 값안에 user 객체가 들어있으면 하는데 현재 요청을 보낼 때는 user 객체가 없다. 어떤 방식으로 가질 수 있을까?

 

UseGuards

UseGuards안에 @nestjs/passport에서 가져온 AuthGuard()를 이용하면 요청안에 유저 정보를 넣어줄 수 있다.

Controller에서 데코레이터를 추가해주면 된다.

    @Post('/test')
    @UseGuards(AuthGuard())
    test(@Req() req) {
        console.log('req', req);
    }

로그를 보면 위처럼 user에 대한 정보가 담겨있는 모습이다.

 

nestjs의 middleware들에 대해서

nestjs에는 여러가지 미들웨어가 있다.

Pipes, Filters, Guards, Interceptors 등의 미들웨어로 취급되는 것들이 있는데, 각각 다른 목적을 가지며 사용되고 있다.

 

각 미들웨어가 불러지는 순서는 아래와 같다.

middleware -> guard -> interceptor(before) -> pipe -> controller -> service -> controller -> interceptor (after) -> filter (if applicable) -> client

 

// 다른 곳에서도 해당 class를 사용할 수 있도록
@Injectable()
// Strategy는 jwt strategy를 사용하기 위해 넣어주는 것
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor (
        @InjectRepository(UserRepository)
        private userRepository: UserRepository
    ) {
        super({
            secretOrKey: 'Secret1234', // 토큰을 생성할 때 사용한 key를 유효한지 확인하기 위해 같은 key 값을 넣어 준다.
            // Bearer Token의 type으로 넘어오는 값을 추출한다는 의미
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() 
        })
    }

    async validate(payload) {
        const {username} = payload;
        const user: User = await this.userRepository.findOneBy({username});

        if(!user) {
            throw new UnauthorizedException();
        }
        
        console.log("testtesttest");
        return user;
    }
}

위처럼 validate에 로그를 추가해보았다. 그 후 postman에서 AuthGuard()와 관련한 controller로 요청을 보내보았다.

위와 같이 가장 먼저 log가 출력되는 모습을 확인할 수 있다. guard가 가장 먼저 실행되는 것이 맞나 보다..?

 

 

커스텀 데코레이터 생성하기

위에서 req를 통해 user라는 객체가 들어가 있음을 확인했다. 그렇다면, req.user라는 로그만 출력하면 user와 관련한 log만 출력되었을 것이다. 하지만, req.user보다 user라는 파라미터로 가져오는 것이 더 편리할 것 같다고 생각한다. req.user가 아닌 user라는 파라미터로 가져올 수 있는 방법은 없을까??

-> 커스텀 데코레이터 사용

 

get-user.decorator.ts 생성

import { createParamDecorator } from "@nestjs/common";
import { ExecutionContextHost } from "@nestjs/core/helpers/execution-context-host";
import { User } from "./user.entity";

export const GetUser = createParamDecorator((data, ctx: ExecutionContextHost): User => {
    const req = ctx.switchToHttp().getRequest();
    return req.user;
})

controller 코드 수정

    @Post('/test')
    @UseGuards(AuthGuard())
    test(@GetUser() user: User) {
        console.log('user', user);
    }

 

 

인증된 유저만 게시물 보고 쓸 수 있게 만들기

board module에 auth module import

import { TypeOrmModule } from '@nestjs/typeorm';
import { Module } from "@nestjs/common";
import {Board} from "./board.entity"
import { BoardsController } from "./boards.controller";
import { BoardService } from "./boards.service";
import { BoardRepository } from './board.repository';
import { AuthModule } from 'src/auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([Board]),
    AuthModule
  ],
  controllers: [BoardsController],
  providers: [
    BoardService,
    BoardRepository,
  ]
})
export class BoardsModule {}

controller에 Guard 추가

@Controller("boards")
@UseGuards(AuthGuard())
export class BoardsController {
  ...
}

테스트

토큰없이 접근하려고하면, 접근 권한이 없다는 Message와 401 error code가 오는 것을 확인할 수 있다. 토큰을 추가하고 요청을 보내면 모든 게시물이 오는 것을 확인할 수 있다.

 

 

 

 

 

Reference

 

 

 

 

'Javascript' 카테고리의 다른 글

유저와 게시물 관계  (0) 2024.07.21
Nest.js를 이용한 게시판 API 개발  (0) 2024.07.21
Nest.js Pipes  (0) 2024.06.17
[Nest.js] Module  (0) 2024.06.11
ES6  (0) 2024.06.05

+ Recent posts