룰루랄라 코딩기록장

[Node.js] Issue28_설계 변경 후 회원가입 기능 #2 본문

YAMPLI

[Node.js] Issue28_설계 변경 후 회원가입 기능 #2

Jeonnnng 2023. 12. 22. 12:11

인증을 위한 토큰

문제점

이메일 인증시 사용되는 사용자 구분 데이터를 JWT 토큰으로 사용했다. 하지만 인증을 위해 사용되는 토큰은 일반적으로 한 번만 사용되고, 사용된 이후에는 무효화가 되어야 한다. 또한 쿼리스트링을 사용해서 URL에 토큰이 직접 보여지는 방법은 보안에 취약할 수 있다.

쿠키, 매우 짧은 토큰 유효 기간, 외부 인증 서비스, 인증 코드 입력 방법 등 다양한 해결책이 존재하지만 기존 방식을 그대로 사용하면서 문제를 해결하기 위해 일회용 토큰을 따로 생성해서 사용하는 방법으로 해결하려고 한다.

프로세스는 다음과 같다.

  1. 회원 가입 시 일회용 코드를 생성하고 redis를 사용하여 token-email로 저장한다.
    1. redis의 setex를 사용하여 유지 기간을 설정한다.
  1. 인증 메일 쿼리스트링 데이터로 일회용 토큰을 전달한다.
  1. 인증 메일 클릭 시 redis에서 일회용 토큰을 사용하여 email을 가져온다.
  1. User 데이터베이스에서 email로 유저 데이터를 가져온 후 emailAuth 필드 값을 true로 변경한다.
  1. redis에 저장된 일회용 토큰 key과 value 값을 삭제한다.

Redis

redis에 저장된 데이터는 서버 메모리에 저장되기 때문에 서버가 종료되거나 재시작을 하게 되면 저장된 데이터가 모두 삭제된다.

설치

  • Homebrew 사용해서 redis 설치 → mac 설치
$ brew install redis
  • 프로젝트에서 사용할 redis 라이브러리 설치 → redis 사용할 프로젝트 위치에 설치
$ npm install redis

실행

  • 포그라운드 실행
$ redis-server
  • 백그라우드실행
$ brew services start redis 

백그라운드 실행 중 명령어 입력

$ redis-cli
> '명령어 입력'

상태확인

$ brew services info redis

레디스에 연결

$ redis-cli
  • 연결 후 명령어 실행해서 테스트 해보기
127.0.0.1:6379> lpush demos redis-macOS-demo
OK
127.0.0.1:6379> rpop demos
"redis-macOS-demo"

종료

  • 포그라운드 실행 시 control + c
  • 백그라운드 실행
$ brew services stop redis
  • 자주 사용되는 기본 메서드
    1. 저장 : set(key,value,callback)
      redisClient.set('username', 'hanjeong', (err, reply) => {
      	console.log(reply);
      });
    1. 가져오기 : get(key, callback)
      redisClient.get('username', (err, reply) => {
      	console.log(reply);
      });
    1. 만료시간 설정: setex(key, seconds, value, callback)
      redisClient.setex('user123', 3600, '123', (err, reply) =>{
      	console.log(reply)   // OK
      });
    1. 해시(HSEST,HGET)
      1. hset(hashKey, field, value, callback) : 해시에 값 저장
      1. hget(hashKey, field, callback) : 해시 값 가져오기
      redisClient.hset('user:123', 'name', 'Alice', (err, reply) => {
      	console.log(reply);  // OK
      });
      
      redisClient.hget('user:123' ,'name', (err,reply) =>{
      	console.log(reply);  // Alice
      });
    1. 리스트(Lpush, LRANGE)
      1. lpush(listKey, value, callback) : 리스트의 왼쪽에 값 추가
      1. lrange(listKey, start, end, callback) : 리스트 범위에 해당하는 값 가져오기
      redisClient.lpush('recent:users', 'user123', (err, reply) => {
        console.log(reply); // 1
      });
      
      redisClient.lrange('recent:users', 0, 2, (err, reply) => {
        console.log(reply); // ['user123']
      });
    1. 삭제 (DEL):
      • del(key, callback): 지정된 키와 연결된 값을 삭제합니다.
      redisClient.del('username', (err, reply) => {
        console.log(reply); // 1
      });
    1. 사용자 정의 명령 실행 (SEND_COMMAND):
      • send_command(command, [args, ...], callback): Redis에서 지원하지 않는 특수한 명령을 실행할 때 사용합니다.
      redisClient.send_command('ECHO', ['Hello, Redis!'], (err, reply) => {
        console.log(reply); // Hello, Redis!
      });

Node.js에서 Redis 연결 및 테스트

레디스 연결 및 데이터 저장, 연결 종료와 같은 메서드를 정의하는 모듈을 생성하고, 서비스 계층에서 모듈을 불러와서 사용하는 방식이다.

서비스 계층에서 레디스 작업을 수행하고 항상 disconnect를 실행해야 소켓 충돌이 발생하지 않는다.

  • redisClinet
const { createClient } = require('redis');

const client = createClient();

client.on('error', (err) => console.log('Redis Client Error', err));

const clientConnect = async () => {
  await client.connect();
  console.log('Redis connected');
  return client;
};
const disconnect = async () => {
  await client.disconnect();
  console.log('Redis disconnect');
  return;
};
module.exports = {
  redisClient: clientConnect,
  disconnect,
};
  • userService

나중에 로그인한 유저의 refreshToken을 레디스를 이용하여 관리하기 위해서 두 개의 데이터 베이스를 사용해야한다.

select 메서드를 사용하여 0번과 1번 저장소를 사용하는 테스트를 진행했다.

const { redisClient, disconnect } = require('../config/redisClient');

const redisTestSet = async () => {
  const connectedClient = await redisClient(); // 수정된 부분
  await connectedClient.select(0);
  await connectedClient.set('index0', 0);

  await connectedClient.select(1);
  await connectedClient.set('index1', 1);
  disconnect();
};

const redisTestGet = async () => {
  const connectedClient = await redisClient(); // 수정된 부분
  await connectedClient.select(0);
  const value1 = await connectedClient.get('index0');
  await connectedClient.select(1);
  const value2 = await connectedClient.get('index1');
  disconnect();

  const end = {
    value1,
    value2,
  };

  return end;
};
  • authController & authRoute
// controller
const redisSet = async (req, res) => {
  const redisS = await userService.redisTestSet();
  res.status(200).json({ data: redisS });
};

const redisGet = async (req, res) => {
  const redisG = await userService.redisTestGet();
  res.status(200).json({ data: redisG });
};

// route
router.route('/redis-set').get(asyncWrap(authController.redisSet));
router.route('/redis-get').get(asyncWrap(authController.redisGet));

promisify 도입

  • node.js에서 사용되는 redis4.0.0 버전부터 모든 명령이 기본적으로 Promise를 반환하도록 업데이트 되었다. 즉 util.promisify 혹은 new Promise를 사용해서 Promise를 반환하도록 설계할 필요가 없어졌다.
    const { createClient } = require('redis');
    
    const client = createClient();
    
    client.on('error', (err) => console.log('Redis Client Error', err));
    
    const clientConnect = async () => {
      await client.connect();
      console.log('Redis connected');
      return client;
    };
    
    const setData = async (key, value) => {
      await client.set(key, value);
    };
    
    const getData = async (key) => {
      const value = await client.get(key);
      return value;
    };
    const disconnect = async () => {
      await client.disconnect();
      console.log('Redis disconnect');
      return;
    };
    module.exports = {
      redisClient: clientConnect,
      disconnect,
      setData,
      getData,
      // 다른 유틸리티 함수 등도 필요하다면 추가할 수 있습니다.
    };

    레디스에서 사용되는 함수들이 Promise를 사용하고 있지만 명시적으로 반환을 하지 않는다.

    또한 연결에 문제가 발생했을 때 적절한 조취를 취할 수 있도록 에러 핸들링이 필요하다.

    • 수정
    const { createClient } = require('redis');
    
    const client = createClient();
    
    client.on('error', (err) => console.error('Redis Client Error', err));
    
    const clientConnect = async () => {
      return new Promise((resolve, reject) => {
        client.on('ready', () => {
          console.log('Redis connected');
          resolve(client);
        });
    
        client.on('error', (err) => {
          console.error('Error connecting to Redis', err);
          reject(err);
        });
      });
    };
    
    const setData = async (key, value) => {
      return new Promise((resolve, reject) => {
        client.set(key, value, (err, reply) => {
          if (err) {
            reject(err);
          } else {
            resolve(reply);
          }
        });
      });
    };
    
    const getData = async (key) => {
      return new Promise((resolve, reject) => {
        client.get(key, (err, reply) => {
          if (err) {
            reject(err);
          } else {
            resolve(reply);
          }
        });
      });
    };
    
    const disconnect = async () => {
      return new Promise((resolve, reject) => {
        client.quit((err, reply) => {
          if (err) {
            reject(err);
          } else {
            console.log('Redis disconnected');
            resolve(reply);
          }
        });
      });
    };
    
    module.exports = {
      redisClient: clientConnect,
      disconnect,
      setData,
      getData,
    };

    적용되는 메서드마다 Promise 함수를 사용하기 때문에 가독성이 떨어진다. 이를 해결하기 위해 util 라이브러리의 promisify를 사용하려고 한다.

    💡
    promisify 는 콜백 형태의 비동기 함수를 호출하는 함수를 프로미스화 해주는 헬퍼 함수

    • redisClient.js

    clientConnect() 메서드는 비동기 콜백을 사용하기 때문에 promisify를 적용하기 어렵다.

    const { createClient } = require('redis');
    const { promisify } = require('util');
    const client = createClient();
    
    const clientConnect = async () => {
      return new Promise((resolve, reject) => {
        client.on('ready', () => {
          console.log('redis connected');
          resolve(client);
        });
    
        client.on('error', (err) => {
          reject(err);
        });
      });
    };
    
    const setDataAsync = promisify(client.set).bind(client);
    const getDataAsync = promisify(client.get).bind(client);
    const quitAsync = promisify(client.quit).bind(client);
    
    const setData = async (key, value) => {
      try {
        await setDataAsync(key, value);
        return 'OK';
      } catch (err) {
        throw new Error('Error connecting to Redis', err);
      }
    };
    
    const getData = async (key) => {
      try {
        return await getDataAsync(key);
      } catch (err) {
        throw new Error('Error connecting to Redis', err);
      }
    };
    const disconnect = async () => {
      try {
        await quitAsync();
        console.log('Redis disconnected');
        return 'OK';
      } catch (err) {
        throw new Error('Error connecting to Redis', err);
      }
    };
    module.exports = {
      redisClient: clientConnect,
      disconnect,
      setData,
      getData,
    };

최종 테스트 코드

  • redisClient.js
const { createClient } = require('redis');

let client;

const createRedisClient = () => {
  if (!client) {
    client = createClient({
      enable_offline_queue: false,
    });
    client.on('error', (err) => console.error('Redis Client Error', err));
  }
};

const clientConnect = async () => {
  try {
    await client.connect();
    console.log('Redis connected');
    return client;
  } catch (err) {
    console.error('Redis Client Error', err);
    throw new Error('Redis Client Error111', err);
  }
};

const setData = async (key, value) => {
  try {
    console.log('Setting data');
    const result = await client.set(key, value);
    console.log('Data set:', key, value);
  } catch (err) {
    console.error('Error in setAsync:', err);
    throw new Error('Error setting data in Redis', err);
  }
};
const getData = async (key) => {
  try {
    return await client.get(key);
  } catch (err) {
    console.log('Error getting data in redis', err);
    throw new Error('Error getting data in redis', err);
  }
};

const disconnect = async () => {
  try {
    await client.quit();
    console.log('Redis disconnected');
  } catch (err) {
    console.error('Error disconnecting Redis database:', err);
    throw new Error('Error disconnecting Redis database:', err);
  }
};

const selectDataBase = async (number) => {
  try {
    await client.select(number);
    console.log('Selected Redis database:', number);
  } catch (err) {
    console.error('Error selecting Redis database:', err);
    throw new Error('Error selecting Redis database:', err);
  }
};

module.exports = {
  createRedisClient,
  redisClient: clientConnect,
  disconnect,
  setData,
  getData,
  selectDataBase,
  // 다른 유틸리티 함수 등도 필요하다면 추가할 수 있습니다.
};
  • userService
const redisTestSet = async () => {
  try {
    await redisClient();
    await selectDataBase(0);
    await setData('index0', 0);
    await selectDataBase(1);
    await setData('index1', 1);
    await disconnect();
  } catch (err) {
    console.error('Error in redisTestSet:', err);
    throw err;
  }
};

const redisTestGet = async () => {
  const connectedClient = await redisClient(); // 수정된 부분
  try {
    await selectDataBase(0);

    const value1 = await getData('index0');
    await selectDataBase(1);
    const value2 = await getData('index1');
    const end = {
      value1,
      value2,
    };

    return end;
  } finally {
    await disconnect();
  }
};
// redis-set
Redis connected
Connected to Redis
Selected Redis database: 0
Selected database 0
Setting data
Data set: index0 0
Data set in database 0
Selected Redis database: 1
Selected database 1
Setting data
Data set: index1 1
Data set in database 1
Redis disconnected
// redis-get
Disconnected from Redis
Redis connected
Redis disconnected

회원 가입 및 이메일 인증

회원가입

/**
 * 유저 데이터 생성
 * 이미 등록되고 미인증 유저인 경우 새롭게 입력된 데이터로 업데이트
 *
 * @param {Object} userData
 * @returns {Promise<User>}
 */
const createUserEmail = async (userData) => {
  const { email, password } = userData;
  const getUser = await findUserByEmail(email);
  let user;
  if (getUser && (await emailAuthCheck(getUser))) {
    throw new ConflictError(STRINGS.ALERT.CHECK_SIGN_EMAIL);
  }
  if (getUser) {
    getUser.password = password;
    getUser.nickname = createNickname();
    user = await getUser.save();
  } else {
    user = await User.create({
      email: email,
      password: password,
      nickname: createNickname(),
    });
  }

  // 일회용 토큰 생성
  const token = crypto.randomBytes(32).toString('hex');

  await redisClient.clientConnect();
  await redisClient.selectDataBase(0);
  await redisClient.setData(token, email, 3600);
  await redisClient.disconnect();

  const verificationLink = `${process.env.SERVER_URL}/auth/auth-email?token=${token}`;
  await sendAuthMail(email, verificationLink);

  return true;
};

신규 유저인 경우 DB에 새로운 데이터를 생성하고, 이미 가입되어 있지만 이메일 인증이 완료되지 않는 유저는 새로 입력받은 암호로 DB를 업데이트 한다.

이후 crypto를 사용하여 일회용 토큰을 생성하고 redis 0번 DB에 token-email을 저장한다. 이 때 유효 시간을 지정하여 경과 시 삭제되도록 한다.

이메일 인증

/**
 * 유저 이메일로 전송한 URL의 쿼리스트링 토큰 데이터 확인 후
 * 이메일 인증 상태로 변경
 * @param {String} url 이메일 인증 URL
 * @returns {Boolean}
 */
const authEmailTokenVerify = async (url) => {
  const queryParams = extractQueryParams(url).token;

  await redisClient.clientConnect();
  await redisClient.selectDataBase(0);
  const email = await redisClient.getData(queryParams);

  if (!email) {
    console.error(STRINGS.ALERT.NOT_VALID_PAGE_EXPIRE_TOKEN);
    throw new ForbiddenError(STRINGS.ALERT.NOT_VALID_PAGE_EXPIRE_TOKEN);
  }

  const user = await userService.findUserByEmail(email);
  if (!user && user.emailAuth) {
    console.error(STRINGS.ALERT.CHECK_SIGN_EMAIL);
    throw new ConflictError(STRINGS.ALERT.CHECK_SIGN_EMAIL);
  }
  user.emailAuth = true;
  await user.save();
  await redisClient.delData(queryParams);

  await redisClient.disconnect();
};

유저가 인증 링크를 클릭하면 redis에 전달된 파라미터를 key값으로 사용하여 이메일 주소를 가져온 후 User 데이터의 emailAuth 필드를 확인하여 인증 상태로 변경시킨다.

모든 작업이 완료되면 redis 데이터를 삭제시킨다.


Uploaded by N2T

'YAMPLI' 카테고리의 다른 글

[Node.js] Issue28_설계 변경 후 회원가입 기능 #1  (0) 2023.12.22
테이블 설계  (0) 2023.12.08
Comments