캡스톤 설계 [건물별 소통 플랫폼 BBC]

[백엔드] 회원가입,로그인(JWT토큰&리프레시 토큰) [1]

softmoca__ 2024. 3. 7. 18:09
목차

https://softmoca.tistory.com/294

 

세션, 쿠키, JWT 토큰 및 인증과 인가 개념 정리

로그인기능을 구현한는 것도 어렵지만 무엇보다 로그인 상태를 '유지'하는거도 만만치 않게 어려운 일이다. 예로 들어 naver에 로그인을 했을 때 메일함을 들어가고 나올때, 보낸 메일함과 받은

softmoca.tistory.com

전반적인 플로우를 정리한 포스트이니 참조하세요 ~

 

 

 

기본 부모 엔티티 및 유저 엔티티 생성

Base.entity.ts

import {
  CreateDateColumn,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

export abstract class BaseModel {
  @PrimaryGeneratedColumn()
  id: number;

  @UpdateDateColumn()
  updatedAt: Date;

  @CreateDateColumn()
  createdAt: Date;
}

모든 테이블에 공통으로 들어갈 PK와 생성일자, 수정 일자를 가진 엔티티.

차후 생성할 다른 엔티티들의 부모 클래스이다.

 

 

User.entity.ts

import { Column, Entity } from "typeorm";
import { BaseModel } from "./base.entity";

@Entity()
export class UsersModel extends BaseModel {
  @Column()
  email: string;

  @Column()
  password: string;

  @Column()
  nickName: string;
  
  @Column()
  phone: string;

  @Column()
  university: string;
}

유저 엔티티 생성

그럼 자동으로 데이터베이스에 테이블이 생성된다.

 

 

 

회원가입, 로그인 인증 인가 구현

npm i @nestjs/jwt

인증 토큰 생성을 위한 jwt 라이브러리 설치

npm i bcrpty

비밀번호  암호화를 위한 bcrpty

> nest g resource
? What name would you like to use for this resource (plural, e.g., "users")? auth
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? (Y/n) n

인증과 인가에 대한 로직을 관리할 auth 모듈 생성

 

 

기능별로  사용하고 구현할 함수들

1) registerWIthEmail(회원가입)

- email,nickname,password,university,phone을 입력 받고 사용자를 생성한다.

-생성이 완료되면 accessToken과 refreshToken을 반환한다. ( '회원가입 후 다시 로그인해주세요' 과 같은 불필요한 과정을 방지하기 위해서)

 

2) loginWithEmail(로그인)

- email,password를 입력하면 사용자 검증을 진행한다.

- 검증이 완료되면 accessToken과 refreshToken을 반환한다.

 

3) loginUser(토큰반환)

- (1), (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직

 

4) signToken (토큰생성)

- email,토큰의 타입,사용자의 id를 Payload에 담아 (3)에서 필요한 accessToken과 refreshToken을 생성하는 로직

 

5) authenticateWithEmailAndPassword (사용자 유효성체크)

- (2)에서 로그인을 진행할 때 필요한 기본적인 검증을 진행한다.

    1. email로 사용자가 존재하는지 확인

    2. 비밀번호가 맞는지 확인

    3. 모두 통과되면 찾은 사용자 정보 반환

 

6) extractTokenFromHeader (토큰 추출)

- 로그인혹은 토큰 재발급시 사용

 

 

7) decodeBasicToken (Basic 토큰 디코딩)

- 프론트엔드에서 사용자의 이메일과 비밀번호로 base64로 인코딩한 값을 디코딩하여 email,password를 추출

 

8) verifyToken(가드에서 토큰 검증)

- 서버에서 가지고 있는 sercet값과 비교해서 유효기간이 지나지 않았는디 유효하지 않은 사용자 인지 확인

 

9) rotateToken(토큰 재발급)

- 리프레시 토큰을 받아 엑세스 토큰과 리프레시 토큰을 다시 발급

  

 

register-user.dto.ts

import { PickType } from "@nestjs/mapped-types";
import { UsersModel } from "src/entites/user.entity";

export class RegisterUserDto extends PickType(UsersModel, [
  "nickName",
  "email",
  "password",
  "university",
  "phone",
]) {}
npm i @nestjs/swagger

사용자의 회원가입을 위해 body값을 받아올때 이메일,닉네임,비밀번호,대학교 이름, 휴대폰 번호이 필요하다.

하지만 해당 값들은 이미 UserModel에 있기 때문에 중복으로 다시 속성들을 생성해줘야한다.

그래서 userModel에서 PickType으로 필요한 속성들만 가져와서 사용한다.

 

 

 

 

4) signToken

- email,토큰의 타입,사용자의 id를 Payload에 담아 (3)에서 필요한 accessToken과 refreshToken을 생성하는 로직

signToken(user: Pick<UsersModel, "email" | "id">, isRefreshToken: boolean) {
    const payload = {
      email: user.email,
      sub: user.id,
      type: isRefreshToken ? "refresh" : "access",
    };

    return this.jwtService.sign(payload, {
      secret: this.configService.get<string>("SECRET_KEY"),
      expiresIn: isRefreshToken ? 3600 : 300,
    });
  }

사용자의 email과 id와 토큰의 타입을 인자로 받는다.

email과 id로 생성한 payload와 환경변수에 저장되있는 SECRET_KEY를 사용해 토큰을 발급한다.

또한 리프레시 토큰인지 확인해서 리프레시 토큰일 경우 만료기간을 100시간(5일), 엑세스 토큰일 경우 1시간으로 생성한다.

 

 

3) loginUser

- (1), (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직

  loginUser(user: Pick<UsersModel, "email" | "id">) {
    return {
      accessToken: this.signToken(user, false),
      refreshToken: this.signToken(user, true),
    };
  }

 

 

5) authenticateWithEmailAndPassword

- (2)에서 로그인을 진행할 때 필요한 기본적인 검증을 진행한다.

    1. email로 사용자가 존재하는지 확인

    2. 비밀번호가 맞는지 확인

    3. 모두 통과되면 찾은 사용자 정보 반환

  async authenticateWithEmailAndPassword(
    user: Pick<UsersModel, "email" | "password">
  ) {

    const existingUser = await this.usersService.getUserByEmail(user.email);

    if (!existingUser) {
      throw new UnauthorizedException("존재하지 않는 사용자입니다.");
    }

    const passOk = await bcrypt.compare(user.password, existingUser.password);

    if (!passOk) {
      throw new UnauthorizedException("비밀번호가 틀렸습니다.");
    }

    return existingUser;
  }

1. 입력을 받은 email을 사용해서 해당 email을 가진 사용자가 있는데 DB에서 확인한다.

2. 그후 없으면 에러를 던진다.

3. 입력한 비밀번호와 DB에 있는 사용자의 비밀번호를 비교해서 확인한다.

4. 비밀번호가 틀렸다면 에러를 던진다.

5. 에러에 걸리지 않고 모두 확인이 끝났다면 사용자 정보들을 반환한다.

 

 

 

 

2) loginWithEmail

- email,password를 입력하면 사용자 검증을 진행한다.

- 검증이 완료되면 accessToken과 refreshToken을 반환한다.

  async loginWithEmail(user: Pick<UsersModel, "email" | "password">) {
    const existingUser = await this.authenticateWithEmailAndPassword(user);

    return this.loginUser(existingUser);
  }

 

 

 

 

1) registerWIthEmail

- email,nickname,password,university,phone을 입력 받고 사용자를 생성한다.

-생성이 완료되면 accessToken과 refreshToken을 반환한다. ( '회원가입 후 다시 로그인해주세요' 과 같은 불필요한 과정을 방지하기 위해서)

  async registerWithEmail(user: RegisterUserDto) {
    const hash = await bcrypt.hash(
      user.password,
      parseInt(this.configService.get<string>("HASH_ROUND"))
    );

    const newUser = await this.usersService.createUser({
      ...user,
      password: hash,
    });

    return this.loginUser(newUser);
  }

사용자가 가입을 위해 입력한 비밀번호를 해시로 암호화 한뒤 createUser로 회원 정보를 DB에 저장.

 

 

1) createUser

 async createUser(user: RegisterUserDto) {
    const nicknameExists = await this.userRepository.exists({
      where: {
        nickName: user.nickName,
      },
    });

    if (nicknameExists) {
      throw new BadRequestException("이미 존재하는 nickname 입니다!");
    }

    const emailExists = await this.userRepository.exists({
      where: {
        email: user.email,
      },
    });

    if (emailExists) {
      throw new BadRequestException("이미 가입한 이메일입니다!");
    }

    const userObject = this.userRepository.create({
      nickName: user.nickName,
      email: user.email,
      password: user.password,
      university: user.university,
      phone: user.phone,
    });

    const newUser = await this.userRepository.save(userObject);

    return newUser;
  }

1. 입력 받은 닉네임이 DB에 있는지 중복 확인.

2. 중복이 있다면 에러를 던진다.

3. 입력받은 이메일이 DB에 있는지 중복 확인.

4. 중복이 있다면 에러를 던진다.

5. 에러가 없다면 입력받은 데이터들을 DB에 저장.

6. 저장한 사용자 정보를 반환

 

 

 

 

 

 

6) extractTokenFromHeader (토큰 추출)

- 로그인혹은 토큰 재발급시 사용

  extractTokenFromHeader(header: string, isBearer: boolean) {
    const splitToken = header.split(" ");
    const prefix = isBearer ? "Bearer" : "Basic";

    if (splitToken.length !== 2 || splitToken[0] !== prefix) {
      throw new UnauthorizedException("잘못된 토큰입니다!");
    }

    const token = splitToken[1];

    return token;
  }
header로 올수 있는 두가지 타입

1. Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Im1vY2FhQG5hdmVyLmNvbSIsInN1YiI6NSwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTcwOTc4ODcxMCwiZXhwIjoxNzA5Nzg5MDEwfQ.E7iZqxCf7rb56F8jYreWkIW6HOH-wbVxeF5xqvR4pKc
2. Basic bW9jYWFAbmF2ZXIuY29tOjAwMDAwMA==

헤더의 종류를 확인할 수 토큰을 추출한다.

 

 

 

7) decodeBasicToken (Basic 토큰 디코딩)

- 프론트엔드에서 사용자의 이메일과 비밀번호로 base64로 인코딩한 값을 디코딩하여 email,password를 추출

 

[프론트엔드 ]

1. 사용자가 이전에 가입한 이메일과 비밀번호로 로그인을 한다.

2. 프론트엔드에서 사용자의 이메일과 비밀번호를 base64인코딩을 한다.

3. 요청의 헤더에 인코딩한 값을 authorization 키값의  벨류로 같이 보낸다.

 

인코딩 예시 

이메일 -  moca@naver.com
비밀번호 - 1234
인코딩 값 : bW9jYUBuYXZlci5jb206MTIzNA==

 

  decodeBasicToken(base64String: string) {
    const email_password = Buffer.from(base64String, "base64").toString("utf8");

    const split = email_password.split(":");

    if (split.length !== 2) {
      throw new UnauthorizedException("잘못된 유형의 토큰입니다.");
    }

    const email = split[0];
    const password = split[1];

    return {
      email: email,
      password: password,
    };
  }

즉, 백엔드에서는 인코딩된 bW9jYUBuYXZlci5jb206MTIzNA==로 사용자의 정보를 찾는다.

인코딩된 값을 디코딩 하여 사용자의 이메일과 비밀번호를 추출 후 반환

 

 

 

8) verifyToken(가드에서 토큰 검증)

- 서버에서 가지고 있는 sercet값과 비교해서 유효기간이 지나지 않았는디 유효하지 않은 사용자 인지 확인

  verifyToken(token: string) {
    try {
      return this.jwtService.verify(token, {
        secret: this.configService.get<string>("SECRET_KEY"),
      });
    } catch (e) {
      throw new UnauthorizedException("토큰이 만료됐거나 잘못된 토큰입니다.");
    }
  }

 

아래와 같은  사용자 정보를 가진payload값을 반환한다.

{
  email: 'moca@naver.com',
  sub: 6,
  type: 'refresh',
  iat: 1709801968,
  exp: 1709805568
}

 

 

 

9) rotateToken(토큰 재발급)

- 리프레시 토큰을 받아 엑세스 토큰과 리프레시 토큰을 다시 발급

  rotateToken(token: string, isRefreshToken: boolean) {
    const decoded = this.verifyToken(token);

    if (decoded.type !== "refresh") {
      throw new UnauthorizedException(
        "토큰 재발급은 Refresh 토큰으로만 가능합니다!"
      );
    }

    return this.signToken(
      {
        ...decoded,
      },
      isRefreshToken
    );
  }

 

1. verifyToken으로 디코딩해 사용자의 정보와 토큰의 타입추출

2.에러 없이 유효한 토큰일 경우 해당 토큰 반환(리프레시 or 엑세스)

 

회원가입 컨트롤러 라우트 

  @Post("register/email")
  registerEmail(@Body() body: RegisterUserDto) {
    return this.authService.registerWithEmail(body);
  }

POSTMAN API 테스트 결과 토큰이 잘 반환되며 데이터베이스에 회원의 정보가 잘 저장된다.

 

처음 해당 라우트를 통해 API 요청이 오면

1.registerWithEmail로 가서 비밀번호 해시 암호화를 한다.

2. createUser로 존재하는 이메일,닉네임이 있는지 확인하고 없으면 DB에 저장을한다.

3. loginUser에서 signToken에서 사용자의 email,id로 payload를 생성해서 토큰을 생성해서 반환해준다.

 

에처 처리 테스트

중복된 닉네임과 이메일이 있는지 확인을 해서 에러를 잘 던져 준다.

 

로그인 컨트롤러 라우트 

 [프론트엔드 ]

1. 사용자가 이전에 가입한 이메일과 비밀번호로 로그인을 한다.

2. 프론트엔드에서 사용자의 이메일과 비밀번호를 base64인코딩을 한다.

3. 요청의 헤더에 인코딩한 값을 authorization 키값의  벨류로 같이 보낸다.

[인코딩 형식]

이메일:비밀번호  ===>bW9jYUBuYXZlci5jb206MTIzNA==

즉, 백엔드는 인코딩된 bW9jYUBuYXZlci5jb206MTIzNA== 로 사용자의 정보를 찾는다.

 

이전 회원가입한 사용자의 아이디와 비밀번호

이메일 -  moca@naver.com
비밀번호 - 1234
인코딩 값 : bW9jYUBuYXZlci5jb206MTIzNA==

 

  @Post("login/email")
  loginEmail(@Headers("authorization") rawToken: string) {

    const token = this.authService.extractTokenFromHeader(rawToken, false);
    const email_password = this.authService.decodeBasicToken(token);

    return this.authService.loginWithEmail(email_password);
  }

 

로그인시 header에 담겨 오는 authorization

 Basic bW9jYWFAbmF2ZXIuY29tOjAwMDAwMA==

 

1. 헤더의 authorization키로 저장된 벨류인' Basic bW9jYUBuYXZlci5jb206MTIzNA=='를 rawToken의 변수로 가져온다.

2. extractTokenFromHeader에서  Bearer인지,Basic인지 확인하고 Basic 토큰을 반환한다.

3. decodeBasicToken에서 사용자의 이메일과 비밀번호를 추출

4. loginWithEmail에서 authenticateWithEmailAndPassword를 사용해서 존재하는 사용자인지, 비밀번호가 DB에 저장된 값과 일치한지 확인후 일치하면 사용자의 정보 반환

5. (4)에서 반환받은 사용자의 정보로 loginUser에서 signToken으로 토큰을 생성하여 반환

 

 

 

POSTMAN API 테스트 결과 토큰이 잘 반환된다.

 

 

잘못된 비밀번호 입력 테스트

# 유효한 사용자
이메일 -  moca@naver.com
비밀번호 - 1234
인코딩 값 : bW9jYUBuYXZlci5jb206MTIzNA==

# 잘못된 비밀번호
이메일 -  moca@naver.com
비밀번호 - 4321
인코딩 값 : bW9jYUBuYXZlci5jb206NDMyMQ==

비밀번호가 틀렸다는 에러 문구가 잘 나온다.

 

 

 

토큰을 사용하게 되는 로직

 1) 사용자가 로그인 또는 회원가입을 진행하면  accessToken refreshToken 발급받는다.

 

 2) 로그인 할때는 Basic 토큰과 함께 요청을 보낸다. Basic 토큰은 '이메일:비밀번호' Base64 인코딩한 형태이다. ) {authorization: 'Basic {token}'}

 

 3) 아무나 접근 없는 정보 (private route) 접근 할때는 accessToken Header 추가해서 요청과 함께 보낸다. - ) {authorization: 'Bearer {token}'}

 

 4) 토큰과 요청을 함께 받은 서버는 토큰 검증을 통해 현재 요청을 보낸 사용자가 누구인지 있다.예를들어서 현재 로그인한 사용자가 작성한 포스트만 가져오려면 토큰의 sub 값에 입력돼있는 사용자의 포스트만 따로 필터링  있.  특정 사용자의 토큰이 없다면 다른 사용자의 데이터를 접근 못한다.

 

 5) 모든 토큰은 만료 기간이 있다. 만료기간이 지나면 새로 토큰을 발급받아야한다. 그렇지 않으면 jwtService.verify()에서 인증이 통과 안된다. 그러니 access 토큰을 새로 발급 받을 있는 /auth/token/access와 refresh 토큰을 새로 발급 받을 있는 /auth/token/refresh 필요하다.

 

 6) 토큰이 만료되면 각각의 토큰을 새로 발급 받을 있는 엔드포인트에 요청을 해서 새로운 토큰을 발급받고 새로운 토큰을 사용해서 private route 접근한다.

 

토큰 재발급

엑세스 토큰 재발급  컨트롤러 라우트 

  @Post("token/access")
  tokenAccess(@Headers("authorization") rawToken: string) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);
    const newToken = this.authService.rotateToken(token, false);

    return {
      accessToken: newToken,
    };
  }

 

1. extractTokenFromHeader에서 토큰을 추출한다.

2. rotateToken에서 verifyToken를 사용해 토큰을 검증하고 디코딩을 한다.

3. 디코딩 후 tpye이 리프레쉬 토큰인지 확인한다.

4. 모두 에러가 없다면 signToken으로 디코딩해서 나온 payload에 있는 사용자의 정보를 추출해 토큰을 생성하고 반환해준다.

 

다시 로그인을 해서 리프레시 토큰을 얻는다.

 

로그인시 얻은 리프레시 토큰을 헤더에 넣어 성공적으로 새로운 엑세스 토큰을 재발급 받았다.

 

 

리프레시 토큰 재발급  컨트롤러 라우트 

  @Post("token/refresh")
  tokenRefresh(@Headers("authorization") rawToken: string) {
    const token = this.authService.extractTokenFromHeader(rawToken, true);
    const newToken = this.authService.rotateToken(token, true);

    return {
      refreshToken: newToken,
    };
  }

rotateToken의 두번째 인자값을 true로 줘서 유효기간이 긴 리프레시 토큰을 생성 하는것 이외엔 엑세스 토큰 재발급과 같은 로직을 거쳐 발급해준다.

 

로그인시 얻은 리프레시 토큰을 헤더에 넣어 성공적으로 새로운 리프레시 토큰을 재발급 받았다.