Team_Mate/Backend

NodeJS 로그아웃 기능 구현

_HelloWorld_ 2024. 12. 20. 17:55

기능 순서

  1. 사용자가 로그인을 하면 Token 테이블에 Token을 저장한다
    1. 만약 이전에 로그인 했었던 기록이 있다면 해당 행(Row) 는 삭제하며 새로운 Token으로 행을 추가한다 
  2. 사용자가 보내는 모든 요청에는 토큰을 검증하는 미들웨어로 올바른 토큰인지 ( 인증 / 인가 ) 확인
  3. 사용자가 로그인 이후 1시간이 지나 토큰이 만료 되었다면 미들웨어에서 특정 행을 삭제하여 새롭게 로그인을 하도록 유도 
  4. 로그아웃 시에는 토큰을 저장했었던 행을 삭제하여서 사용자가 특정 토큰으로 서버에 요청을 보낼 수 없도록 수정 

예외

  1. 토큰이 없는 상태로 요청을 보낸다면 ? [ OK ]
  2. 토큰이 있지만 해당 토큰이 토큰 테이블에 없다면 ? [ OK ]
  3. 토큰이 있고 토큰이 테이블에도 있지만 만료 되었다면 ? [ OK ]
  4. 두 명의 사용자가 하나의 계정으로 로그인해서 작업을 하려고 시도한다면?
    1. 마지막에 로그인한 사용자의 토큰으로 작업이 가능하게 구현
    2. 처음 로그인한 사람은 재로그인 해야지 작업이 되도록 구현

Token 테이블 추가

@Entity('user_tokens')
export class UserTokenEntity {
  @PrimaryGeneratedColumn('increment')
  id!: number;

  // 사용자 테이블과의 관계 설정
  @ManyToOne(() => UserEntity, (user) => user.userToken)
  @JoinColumn({ name: 'user_id' })
  user!: UserEntity;

  @Column({ type: 'varchar', length: 255 })
  token!: string;

  @Column({ type: 'timestamp' })
  expires_at!: Date;
}
@Entity('users')
export class UserEntity {
  ... 
  
  // 사용자 로그인 기록 토큰을 저장하는 테이블과의 관계 설정
  @OneToOne(() => UserTokenEntity, (userToken) => userToken.user)
  @JoinColumn()
  userToken?: UserTokenEntity;
}
  • 사용자 테이블과 토큰 테이블의 관계는 One : One 으로 하나의 사용자당 로그인 시에 하나의 토큰만을 저장하도록 구현
    • 이는 동일한 계정으로 여러명의 사용자가 작업을 하는 것을 방지하기 위함인데 여러명의 사용자가 동일한 토큰 하나로 작업을 한다면 IP도 저장해야 할 수 있음 

로그인 로직 수정

export const signIn = async (signInProps: SignInProps): Promise<{ token: string, user: UserEntity }> => {
  try {
    const findUser = await userRepository.findOne({
      where: { email: signInProps.email }
    });
    if (!findUser) {
      throw new Error('User not found');
    }

    // compare password
    if (!comparePassword({password: signInProps.password, hashedPassword: findUser.password})) {
      throw new Error('Password is incorrect');
    }

    // create Token
    const token = generateToken({userId: findUser.id, role: findUser.role})

    // delete old token 
    await userTokenRepository.delete({ user: findUser });

    // save token to Database
    const userToken = userTokenRepository.create({
      token,
      user: findUser,
      expires_at: new Date(Date.now() + 3600 * 1000),
    });
    await userTokenRepository.save(userToken);

    return { token, user: findUser };
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(error.message);
    }
    throw new Error('Internal Server Error');
  }
}
  • 기존 코드에서 수정된 점이라면 토큰을 생성하고 -> 기존에 로그인 했었던 사용자의 로그인 기록을 지움
    • 이전에 로그인 했었던 사용자가 있다면 해당 사용자가 발급 받았던 토큰으로 다른 작업을 할 수 없도록 구현
  • 토큰과, 사용자 객체, 만료일 (1시간)을 토큰 테이블에 저장

Controller

// 로그아아웃
// @route post /users/logout
// @header Authorization Bearer token
export const signOut = async (req: Request, res: Response) => {
  try {
    const token = req.headers.authorization?.split('Bearer ')[1];
    if (!token) {
      responseError(res, 'Unauthorized', 'Token is required', 401);
      return
    }
    await signOutService(token);
    responseSuccess(res, undefined, 'User logged out successfully', 200);
    return
  } catch (error) {
    if (error instanceof Error) {
      responseError(res, error.message, 'Failed to login', 400);
      return
    }
    responseError(res, 'Internal Server Error', 'Failed to login', 500);
    return
  }
}
  • 로그아웃 컨트롤러에서는 사용자 로그아웃 요청 시에 토큰을 해더로 받음
    • 토큰이 있는 지 확인하는데 토큰이 만료 되어도 요청은 이루어짐 ( 토큰의 존재만 확인 )

Service

// 사용자 로그아웃 함수
export const signOut = async (token: string): Promise<void> => {
  try {
    const decodedToken = decodeToken(token);
    if (!decodedToken) {
      throw new Error('Invalid token');
    }
    const { userId } = decodedToken;
    await userTokenRepository.delete({ user: { id: userId } });
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(error.message);
    }
    throw new Error('Internal Server Error');
  }
}
  • 요청을 받은 토큰으로 사용자의 정보를 가져옴
    • 토큰 발급 시에 사용자 ID와 사용자 권한 Role 을 생성하는데 사용 하였었음 
  • 토큰 테이블에서 사용자 ID로 만들어진 행을 삭제하여 해당 토큰으로 어떠한 작업도 할 수 없도록 추가

Middleware

import { Request, Response, NextFunction } from 'express';

import { Database } from '../db/index';
import { UserTokenEntity } from '../db/entities/userTokenEntity';

import { responseError } from "../utils/response";

const userTokenRepository = Database.getRepository(UserTokenEntity);

export const verifyTokenMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const token = req.headers.authorization?.split('Bearer ')[1];
    if (!token) {
      responseError(res, 'Unauthorized', 'Token is required', 401);
      return
    }

    const existingToken = await userTokenRepository.findOne({
      where: { token: token }
    });
    if (!existingToken) {
      responseError(res, 'Unauthorized', 'Invalid token', 401);
      return
    }

    // 요청 시간 기준으로 1시간이 지났다면 토큰을 삭제하고 에러를 반환
    const currentTime = new Date().getTime();
    if (existingToken.expires_at.getTime() < currentTime) {
      await userTokenRepository.remove(existingToken);
      responseError(res, 'Unauthorized', 'Token expired', 401);
      return
    }

    next();
  } catch (error) {
    if (error instanceof Error) {
      responseError(res, error.message, 'Failed to login', 400);
      return
    }
    responseError(res, 'Internal Server Error', 'Failed to login', 500);
    return
  }
};
  • 요청 시에 토큰이 있는 지 확인
  • 토큰이 있지만 토큰 테이블에도 해당 토큰이 있는 지 확인 ( 로그아웃 하여 삭제 된 토큰일 수 있으니 이도 확인 하여야 함 )
  • 토큰이 만료 되었는 지 확인 ( 만들어진지 1시간이 되었는 지 확인 ) 
  • 위 과정에서 아무런 에러도 없었다면 요청을 수락하여 Controller 함수로 전달

결과

토큰이 토큰 테이블에 없어서 생기는 응답 

 

토큰 없이 요청을 하면 생기는 응답 

 

유효한 토큰으로 요청을 하면 생기는 응답

 

만료된 토큰으로 요청을 하면 생기는 응답

'Team_Mate > Backend' 카테고리의 다른 글

Service에서의 에러 핸들링 방법  (0) 2024.12.22
NodeJS 로그인 기능 구현  (0) 2024.12.20
NodeJS 회원가입 기능 구현  (1) 2024.12.20