상똥이의 Back-End 공부방

[Nest.js] 토큰으로 로그인, 로그아웃 구현하기 본문

Nest.JS

[Nest.js] 토큰으로 로그인, 로그아웃 구현하기

상똥백 2024. 11. 14. 19:57

목표

1. JWT를 사용해서 로그인, 로그아웃을 처리한다.

2. 로그인 시 토큰을 발급한다.

3.필요시 토큰을 새로 발급한다.

 

목차

0. 기본 설정 (스키마, 회원가입 로직)

1. 로그인 시 토큰 발급과 저장

2. 로그아웃 시 토큰 삭제

3. 로그인 유지를 위한 리프레시 토큰 발급


[0. 기본 설정]

1. 유저 스키마

model User {
  id       Int    @id @default(autoincrement())
  email    String @unique
  password String
  refreshToken String?
}

 

2. 회원가입 로직

//user.service.ts

  async createUser(dto: User) {
    try {
      const check = await this.prismaService.user.findUnique({
        where: { email: dto.email },
      });
      if (check) throw new BadRequestException('이미 사용중인 이메일입니다.');

      const salt = bcrypt.genSaltSync(12);
      const hash = bcrypt.hashSync(dto.password, salt);
      return await this.prismaService.user.create({
        data: {
          email: dto.email,
          password: hash,
        },
      });
    } catch (e) {
      console.log(e);
    }
  }
  
// user.controller.ts

  @Post('sign-up')
  async createUser(@Body() dto: User) {
    return await this.userService.createUser(dto);
  }
  
// user.dto.ts

export class User {
  email: string;
  password: string;
}

 

 


[1. 로그인 시 토큰 발급과 저장]

0. 로그인 처리 과정 요약

- 클라이언트에서 서버로 로그인 요청

- 아이디와 비밀번호 확인

- 액세스토큰, 리프레시 토큰 생성

- 리프레시 토큰 데이터베이스에 저장

- 액세스토큰, 리프레시 토큰 클라이언트에 쿠키로 전달

 

2. 토큰 발급 로직

- auth.service.ts

  // .env파일에 JWT_SECRET을 설정한다. 필자는 8자리 무작위 문자 조합으로 설정했다.
  private jwtSecret: string;

  constructor(
    private readonly jwtService: JwtService,
    private readonly prismaService: PrismaService,
  ) {
    this.jwtSecret = process.env.JWT_SECRET;
  } 
  
  // 토큰 생성 로직
  async generateToken(payload: jwt.JwtPayload, type: string): Promise<string> {
    // type이 access면 유효기간을 짧게 설정
    // type이 refresh면 유효기간을 길게 설정
    const expiresIn =
      type === 'access' ? '10m' : type === 'refresh' ? '15d' : null;
    if (!expiresIn)
      throw new BadRequestException('존재하지 않는 액세스 토큰 타입입니다.');
	
    // 토큰 생성 후 반환
    const token = jwt.sign(payload, this.jwtSecret, { expiresIn });
    return token;
  }

 

3. 로그인 로직

- user.service.ts

  async signIn(dto: User): Promise<{ accessT: string; refreshT: string }> {
    try {
      // 사용자 확인
      const user = await this.prismaService.user.findUnique({
        where: { email: dto.email },
      });
      if (!user) throw new NotFoundException('존재하지 않는 사용자입니다.');
	  
      // 비밀번호 확인
      const comparePassword = await bcrypt.compare(dto.password, user.password);
      if (!comparePassword)
        throw new BadRequestException('비밀번호가 일치하지 않습니다.');
	  
      // 페이로드(담을 정보) 설정 후 authService의 generateToken에 전달한다
      // accessToken, refreshToken에 따라서 type 설정을 다르게 해준다
      const payload = { type: 'user', id: user.id, email: dto.email };
      const accessT = await this.authService.generateToken(payload, 'access');
      const refreshT = await this.authService.generateToken(payload, 'refresh');
	  
      // 발급된 리프레시토큰을 유저 데이터베이스에 저장
      await this.prismaService.user.update({
        where: { id: user.id },
        data: { refreshToken: refreshT },
      });
      
      // 두 토큰을 모두 반환
      return { accessT, refreshT };
    } catch (e) {
      console.log(e);
    }
  }

 

- user.controller.ts

import { Body, Controller, Delete, Post, Req, Res } from '@nestjs/common';
import { UserService } from './user.service';
import { CookieOptions, Request, Response } from 'express';
import { User } from './user.dto';
import { AuthService } from '../auth/auth.service';

...

  // constructor에서 쿠키옵션을 설정해준다
  // 액세스토큰은 유효기간을 짧게 하고, 리프레시토큰은 유효기간을 길게 잡는다
  // maxAge 설정은 jwt 생성 시에 설정하는 expire보다 우선한다
  // 개발환경에서는 secure를 false로 하는게 편하지만 배포시 true로 설정하여 https와 상호작용하도록 한다
  private accessCookieOptions: CookieOptions;
  private refreshCookieOptions: CookieOptions;
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
  ) {
    this.accessCookieOptions = {
      maxAge: 1000 * 60 * 10, // 유효기간 10분
      httpOnly: true,
      secure: false,
    };
    this.refreshCookieOptions = {
      maxAge: 1000 * 60 * 60 * 24 * 15, // 유효기간 15일
      httpOnly: true,
      secure: false,
    };
  }
  
  @Post('sign-in')
  async signIn(@Body() dto: User, @Res() res: Response) {
    const token = await this.userService.signIn(dto);
	
    // Response를 사용해 쿠키를 설정한다
    res.cookie('accessToken', token.accessT, this.accessCookieOptions);
    res.cookie('refreshToken', token.refreshT, this.refreshCookieOptions);

    return res.send({ message: '토큰 발급 성공' });
  }

 

- postman으로 확인하면 아래와 같이 쿠키 두 개가 발급되었음을 확인할 수 있다

 


[2. 로그아웃 시 토큰 삭제]

1. 로그아웃 방법

- 웹 브라우저의 쿠키를 삭제한다

- 클라이언트에서 단독으로 처리할 수 있긴 하지만 그냥 같이 쓰고싶었다

 

2. 로그아웃 로직

import { Body, Controller, Delete, Post, Req, Res } from '@nestjs/common';
import { UserService } from './user.service';
import { CookieOptions, Request, Response } from 'express';
import { User } from './user.dto';
import { AuthService } from '../auth/auth.service';

...

  @Delete()
  async signOut(@Res({ passthrough: true }) res: Response) {
    res.clearCookie('accessToken');
    res.clearCookie('refreshToken');

    return { message: '토큰 삭제 완료' };
  }

 

- 쿠키 삭제가 완료된다

 


[3. 로그인 유지를 위한 리프레시 토큰 발급]

1. 액세스토큰과 리프레시토큰을 함께 발급하는 이유

- JWT는 그 자체로 정보를 담고 있어 HTTP에서 유용하게 쓰인다는 장점이 있으나

- 설정한 유효기간이 지나기 전에 인위적으로 말소시키는게 불가능하기 때문에 보안에 취약하다는 단점이 있다

- 이런 이유로 쿠키와 함께 사용하며, 액세스토큰은 유효기간을 짧게 설정하고 리프레시토큰은 유효기간을 길게 설정한다

- 주 서비스는 액세스토큰을 사용해 상호작용하고

- 액세스토큰이 만료될 때마다 리프레시토큰으로 토큰을 재발급 해준다

 

2. 리프레시 토큰 발급 로직

- auth.service.ts

  async refresh(curT: string) {
    try {
      // 토큰이 유효한지 확인
      // 유효하지 않으면 알아서 에러를 throw해준다
      const tokenInfo = this.jwtService.verify(curT, {
        secret: this.jwtSecret,
      });
      
      // tokenInfo에 담긴 정보를 사용해 사용자 정보 확인
      // 사용자가 존재하지 않으면 exception throw
      const user = await this.prismaService.user.findUnique({
        where: { id: tokenInfo.id },
      });
      
      if (!user || user.refreshToken !== curT) {
        throw new UnauthorizedException('유효하지 않은 리프레시 토큰입니다.');
      }
      
      // payload 설정 후 액세스토큰과 리프레시 토큰 재발급
      const payload = {
        type: 'refresh',
        id: tokenInfo.id,
        email: tokenInfo.email,
      };

      const accessT = await this.generateToken(payload, 'access');
      const refreshT = await this.generateToken(payload, 'refresh');

      return { accessT, refreshT };
    } catch (e) {
      console.log(e);
    }
  }

 

- user.controller.ts

import * as cookie from 'cookie';
...

  @Post('refresh')
  async refresh(
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ) {
    // request header에서 refreshToken 추출
    const cookies = cookie.parse(req.headers.cookie || '');
    const curT = cookies.refreshToken;

    const token = await this.authService.refresh(curT);

    res.cookie('accessToken', token.accessT, this.accessCookieOptions);
    res.cookie('refreshToken', token.refreshT, this.refreshCookieOptions);
	
    // res.send()를 사용하면 순환참조 오류가 발생한다
    return { message: '토큰 리프레시 완료' };
  }

 

- 토큰 리프레시가 완료된다