인증을 위한 토큰
문제점
이메일 인증시 사용되는 사용자 구분 데이터를 JWT 토큰으로 사용했다. 하지만 인증을 위해 사용되는 토큰은 일반적으로 한 번만 사용되고, 사용된 이후에는 무효화가 되어야 한다. 또한 쿼리스트링을 사용해서 URL에 토큰이 직접 보여지는 방법은 보안에 취약할 수 있다.
쿠키, 매우 짧은 토큰 유효 기간, 외부 인증 서비스, 인증 코드 입력 방법 등 다양한 해결책이 존재하지만 기존 방식을 그대로 사용하면서 문제를 해결하기 위해 일회용 토큰을 따로 생성해서 사용하는 방법으로 해결하려고 한다.
프로세스는 다음과 같다.
- 회원 가입 시 일회용 코드를 생성하고
redis
를 사용하여token-email
로 저장한다.- redis의
setex
를 사용하여 유지 기간을 설정한다.
- redis의
- 인증 메일 쿼리스트링 데이터로 일회용 토큰을 전달한다.
- 인증 메일 클릭 시
redis
에서 일회용 토큰을 사용하여 email을 가져온다.
User
데이터베이스에서email
로 유저 데이터를 가져온 후emailAuth
필드 값을true
로 변경한다.
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
자주 사용되는 기본 메서드
- 저장 :
set(key,value,callback)
redisClient.set('username', 'hanjeong', (err, reply) => { console.log(reply); });
- 가져오기 :
get(key, callback)
redisClient.get('username', (err, reply) => { console.log(reply); });
- 만료시간 설정:
setex(key, seconds, value, callback)
redisClient.setex('user123', 3600, '123', (err, reply) =>{ console.log(reply) // OK });
- 해시(HSEST,HGET)
hset(hashKey, field, value, callback)
: 해시에 값 저장
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 });
- 리스트(Lpush, LRANGE)
lpush(listKey, value, callback)
: 리스트의 왼쪽에 값 추가
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'] });
- 삭제 (DEL):
del(key, callback)
: 지정된 키와 연결된 값을 삭제합니다.
redisClient.del('username', (err, reply) => { console.log(reply); // 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
를 사용하려고 한다.- 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