#3. JWT 사용하기 (1)

toto9602·2022년 2월 24일
0

첫 Express 프로젝트

목록 보기
4/7

참고자료: 견고한 node.js 프로젝트 설계하기
참고자료: 쉽게 알아보는 서버 인증 1편(세션/쿠키, JWT)
참고자료: Express에서 JWT로 인증시스템 구현하기 (Access Token과 Refresh Token)
참고자료: node.js :: Bcrypt로 비밀번호 해싱(Hashing)하기

해당 참고자료들을 참고하여 코드 및 글을 작성했음을 밝힙니다 :)


이번 글에서는 JWT를 활용해 구현한 회원가입/로그인을 정리할 예정!

아마 다음 글에서 토큰 Refresh와 로그아웃 관련한 정리를 할 것 같다.

저번 글에서 Router에 관한 이야기를 했으니, 오늘 글의 순서는

  1. controllers와 services에서 사용한 utils 함수
  2. 회원가입/로그인 관련 controller
  3. 회원가입/로그인 관련 services

가 될 듯!

1. controllers와 services에서 사용한 utils 함수

.env

JWT_SECRET_ACCESS_KEY=###########
JWT_SECRET_REFRESH_KEY=############

JWT_ALGORITHM=HS256
JWT_ACCESS_EXPIRE=1h
JWT_REFRESH_EXPIRE=14d

우선 .env 파일에 다음과 같이 AccessToken, RefreshToken 발급 시에 사용할 secret key와 알고리즘, 만료 시간을 작성해 주었다!

utils/auth.js

const jwt = require('jsonwebtoken'); 
const { User } = require('../models');
require('dotenv').config();

exports.signAccess = (userData) => { //AccessToken 발급
    const payload = {
        pk:userData.pk,
        email:userData.email
    }

    return jwt.sign(
        payload,
        process.env.JWT_SECRET_ACCESS_KEY,
        {
            algorithm:process.env.JWT_ALGORITHM,
            expiresIn:process.env.JWT_ACCESS_EXPIRE
        }
    );
};

exports.signRefresh = (userPk) => { //RefreshToken 발급
    
    return jwt.sign(
        {pk:userPk},
        process.env.JWT_SECRET_REFRESH_KEY,
        {
            algorithm:process.env.JWT_ALGORITHM,
            expiresIn:process.env.JWT_REFRESH_EXPIRE,
        }
    );
}
exports.verifyAccess = (accessToken) => { //AccessToken 검증
    try {
        const verified = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_KEY)
        return {
            success:true,
            message:'Token Verified',
            userData: {
                pk:verified.pk,
                email:verified.email
            }
        }
    } catch(error) {
        return {
            success:false,
            message:error.message,
            userData:null
        }
    }
}

exports.verifyRefresh = async (refreshToken) => { //RefreshToken 검증
    try {
        const verified = jwt.verify(refreshToken, process.env.JWT_SECRET_REFRESH_KEY);
        const user = await User.findOne({where:{pk:verified.pk}});
        const userToken = user.dataValues.Refresh;

        if (userToken === refreshToken) {
            return {
                success:true,
                message:'Token exists and verified',
                userPk:verified.pk
                };
        };
        if (userToken !== refreshToken) {
            return {
                success:false,
                message:'Token valid, but not found in DB',
                userPk:verified.pk
            };
        };  
    } catch (error) {
        return {
            success:false,
            message:'Token not verified',
            userPk:null
        };
    };
};

exports.getAccess = ({authorization}) => {
    try {
        const accessToken = authorization.split('Bearer ')[1];
        
        return accessToken
    } catch(error) {
        return error.message;
    }
}

exports.getUserWithAccess = async (accessToken) => {
    try {
        const verified = jwt.verify(accessToken, process.env.JWT_SECRET_ACCESS_KEY)
        const user = await User.findOne({where:{pk:verified.pk}})

        return user.dataValues
    } catch(error) {
        return error.message;
    }
}

exports.getUserWithRefresh = async (refreshToken) => {
    try {
        const verified = jwt.verify(refreshToken, process.env.JWT_SECRET_REFRESH_KEY);
        const user = await User.findOne({where:{pk:verified.pk}})

        return user.dataValues;
    } catch(error) {
        return error.message;
    }
}

signAccess

  • 사용자 정보를 입력 받아, 해당 내용을 담은 Access Token을 발급하는 함수

signRefresh

  • 사용자의 id값을 입력 받아, 해당 내용을 담은 Refresh Token을 발급하는 함수

RefreshToken에는 원래 사용자 식별 정보를 담지 않았는데, Token 갱신 시에 RefreshToken에서도 사용자 정보를 추출할 수 있어야 할 것 같아 pk값만 넣는 것으로 수정해 둔 상태!

verifyAccess

  • accessToken을 입력받아 유효성을 검사하고, 검사 결과를 성공 여부, 메시지, 사용자 정보로 반환하는 함수

verifyRefresh

  • refreshToken을 입력받아 유효성 검사 및 해당 token이 정상적으로 DB에 저장되어 있는지를 확인하고, 그 결과에 따라 검증 성공 여부, 상태 메시지, 사용자의 pk값을 반환하는 함수

getAccess

  • AccessToken은 headers에서 authorization이라는 키에 담겨 Bearer {accessToken}이라는 형태로 전달되는데, 해당 부분에서 accessToken 부분만을 추출하는 함수

getUserWithAccess

  • accessToken을 인자로 받아, 해당 토큰에 담긴 pk 정보로 해당 사용자를 찾아 사용자 정보를 반환하는 함수

getUserWithRefresh

  • refreshToken을 인자로 받아, 해당 토큰에 담긴 pk 정보로 해당 사용자를 찾아 사용자 정보를 반환하는 함수

2. 회원가입/로그인 관련 controllers

controllers/authController.js

const authServices = require('../services/authServices');

exports.signUp = async(req, res, next) => {
    try {
        const context = await authServices.signUp(req.body);
        if (context['user']) {
            next(); //router에서 다음 -> 로그인 로직으로.
        } else {
            res.status(409).json({
                msg:context['msg']
            });
        }
    } catch (error) {
        console.log(error);
        res.status(400).json({
            msg:context['msg']
        });
    }
};

exports.logIn = async(req, res, next) => {
    try {
        const authUser = await authServices.logIn(req.body);
        res.status(200).json({
            msg:'로그인 성공',
            data:authUser
        });
    } catch(error) {
        console.log(error);
        next(error);
    }
};

견고한 node.js 프로젝트 설계하기 참고자료를 다 이해하진 못했지만..


  • 비즈니스 로직을 controller에 담지 말 것

정도는 반영해 보고자 했고, 그에 따라 controller는
  • 요청을 가공해서 services에 보내는 역할
  • services의 반환값을 응답으로 보내는 역할

을 수행하는 느낌으로 코드를 작성해 보았다.

signUp 함수는 회원가입 이후 로그인까지 이어질 수 있도록 작성한 것 외에는..
특별한 부분은 없는 듯!

3. 회원가입/로그인 관련 services

services/authServices.js

const bcrypt = require('bcrypt');
const {User} = require('../models');
require('dotenv').config();
const {signAccess, signRefresh, verifyAccess, verifyRefresh, getUserWithRefresh} = require('../utils/auth');

exports.signUp = async ({nickname, email, password}) => {
    let context = {'user':null, 'msg':''};
    try {
        const userExists = await User.findOne({where:{email}}) //없으면 null 반환

        if (userExists) {
            context['msg'] = '이미 사용 중인 이메일입니다!'
            console.log('이미 사용 중인 이메일입니다!');
            return context;
        } else {
            const salt = await bcrypt.genSalt(10);
            const hashed_pw = await bcrypt.hash(password, salt);
            const user = await User.create({
                nickname,
                email,
                password:hashed_pw
            });
            context['user'] = user;
            return context;
        }

    } catch(error) {
        console.log(error);
        context['msg'] = 'catch' + error.message;
        return context;  
    }
};

exports.logIn = async({email, password}) => {
    const user = await User.findOne({where:{email}})
    const userData = user.dataValues;

    const accessToken = signAccess(userData);
    const refreshToken = signRefresh(userData.pk);

    const tokens = {
        access:accessToken,
        refresh:refreshToken,
    };
    user.Refresh = refreshToken;
    user.save();
    return {userData, tokens};
}

실제 비즈니스 로직에 해당하는 services는 위와 같이 작성해 보았다.

signUp에서는
회원가입 시에 req.body에 담아서 보내는 nickname, email, password 정보를 받아

해당 이메일을 사용 중인 사용자가 있는지를 확인하고, 있다면
회원가입이 정상적으로 되지 않은 상태이기에

{
 'user':null,
 'msg':'이미 사용 중인 이메일입니다!'
}

라는 context를 반환한다.

반환값을 context라는 object에 담는 방식은 예전에 Django로 백엔드 로직을 작성할 때 본 적이 있는 방식이라 그대로 사용했는데.. 통상적인 방식인지는 모르겠음...

백엔드 내에서만 사용되는 값이라 그냥 나 편한 대로 작성해따..


이메일이 이미 사용 중이지 않다면 bcrypt 라이브러리를 사용해서
salt를 생성하고, 비밀번호를 해싱하는 과정을 거쳐 사용자 정보를 DB에 생성하고, 해당 사용자 정보를 반환해 준다.

bcrypt 라이브러리에 대해서는 참고자료에 잘 정리해 주신 분의 글을 링크해 두어, 참고하면 좋을 듯!

간단히 이야기하면,

Hashing이란

  • Plain Text를 특정 알고리즘을 통해 인간이 해독하지 못하는 문자열로 바꾸는 것

그리고 그 과정에서 사용되는 salt란, 음식에 살짝 소금을 더하듯이

  • 실제 비밀번호(Plain Text)에 salt라는 임의의 값을 추가해서 Hashing함을 통해,
    원문인 실제 비밀번호를 역추적하기 어렵게 하는 것

정도인 것 같다.

logIn에서는 req.body에 담아서 보내는 사용자의 이메일, 비밀번호를 받아 해당하는 사용자를 찾고, 사용자 정보를 담아 accessTokenrefreshToken을 발급해 준다.

passport 단에서 검증을 마친 유효한 사용자 정보만 들어오기 때문에, login에서 따로 이메일, 비밀번호의 유효성을 검증하지는 않았다.

사용자의 refreshToken은 사용자 정보에 함께 담아 DB에 저장하고,

사용자 정보와, tokens를 다시 controller에 반환해 준다.

#2. 에서 작성했듯, services에서 반환해 준 값을 controller가 받아,
반환값에 따라 적절한 응답을 보내주면 마무리 :)


코드를 작성할 때 분명 여러 번 찾아보았던 내용들이지만, 글로 정리하면서 한 번씩 더 찾아보고, 복습할 수 있게 되는 것 같아 즐겁게 작성 중이다 ㅎㅎ

profile
주니어 백엔드 개발자입니다! 조용한 시간에 읽고 쓰는 것을 좋아합니다 :)

0개의 댓글