Express 백엔드에서 Passport와 jsonwebtoken을 사용하여 인증 처리 시스템을 구현하는 방법을 설명합니다. 주요 개념, 설치 방법, 그리고 실용적인 코드를 포함한 안내서를 제공합니다.
토큰은 mysql 에 저장하는 방식을 취했습니다.
1.백엔드
1. 프로젝트에 필요한 라이브러리 설치
npm install express passport passport-local passport-kakao jsonwebtoken bcrypt dotenv sequelize mysql2 express-session cors cookie-parser morgan
위 명령어를 실행하면, Express와 관련된 주요 인증 및 데이터 처리 라이브러리가 설치됩니다.
2. 프로젝트 구조 설계 및 환경 설정
- 프로젝트 구조
/project ├── /models # Sequelize 모델 정의 ├── /routes # 라우터 파일 ├── /passport # Passport 전략 파일 ├── /utils # 유틸리티 함수 ├── app.js # Express 서버 메인 파일 ├── .env # 환경 변수
.env 예시
JWT_SECRET_KEY=your_secret_key ACCESS_TOKEN_SECRET=your_access_secret REFRESH_TOKEN_SECRET=your_refresh_secret COOKIE_SECRET=your_cookie_secret PORT=8001
models/user.js
const Sequelize = require('sequelize'); const { DataTypes } = require('sequelize'); class User extends Sequelize.Model { static initiate(sequelize) { User.init({ id: { // 기본 키로 설정 type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, }, userId: { type: Sequelize.STRING(40), allowNull: false, unique: true, }, nick: { type: Sequelize.STRING(15), allowNull: false, }, password: { type: Sequelize.STRING(100), allowNull: true, }, address: { type: Sequelize.TEXT, allowNull: false, }, userType: { type: DataTypes.ENUM('guest', 'owner'), allowNull: false, defaultValue: 'guest', }, time: { type: Sequelize.STRING(100), allowNull : true, }, }, { sequelize, timestamps: true, underscored: false, modelName: 'User', tableName: 'users', paranoid: true, charset: 'utf8mb4', collate: 'utf8mb4_general_ci', }); } static associate(db) { db.User.hasOne(db.RefreshToken, { // User와 RefreshToken 간 1:1 관계 foreignKey: 'userId', sourceKey: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE', }); } }; module.exports = User;
models/refreshTokens.js
const Sequelize = require('sequelize'); class RefreshToken extends Sequelize.Model { static initiate(sequelize) { RefreshToken.init({ id: { type: Sequelize.INTEGER, autoIncrement: true, primaryKey: true, }, userId: { // User의 기본 키 id와 연결 type: Sequelize.INTEGER, // User.id의 데이터 타입과 일치시킴 allowNull: false, unique: true, // 한 User는 하나의 RefreshToken만 가질 수 있음 }, refreshToken: { type: Sequelize.STRING(255), allowNull: false, }, expiresAt: { type: Sequelize.DATE, allowNull: false, }, }, { sequelize, timestamps: true, underscored: false, modelName: 'RefreshToken', tableName: 'refresh_tokens', paranoid: false, charset: 'utf8mb4', collate: 'utf8mb4_general_ci', }); } static associate(db) { db.RefreshToken.belongsTo(db.User, { foreignKey: 'userId', // RefreshToken.userId -> User.id targetKey: 'id', // User의 기본 키(id)에 연결 onDelete: 'CASCADE', onUpdate: 'CASCADE', }); } } module.exports = RefreshToken;
3. app.js 구현
app.js는 Express 애플리케이션의 메인 엔트리로, 불필요한 코드는 제거하고 중요한 기능에 집중합니다.
const express = require('express'); const cookieParser = require('cookie-parser'); const session = require('express-session'); const dotenv = require('dotenv'); const passport = require('passport'); const cors = require('cors'); const { sequelize } = require('./models'); const passportConfig = require('./passport'); const authRouter = require('./routes/auth'); // 인증 관련 라우터 dotenv.config(); const app = express(); passportConfig(); // Passport 설정 초기화 // 기본 설정 app.set('port', process.env.PORT || 8001); // DB 연결 sequelize.sync({ force: false }) .then(() => console.log('데이터베이스 연결 성공')) .catch(err => console.error(err)); // 미들웨어 설정 app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser(process.env.COOKIE_SECRET)); app.use(session({ resave: false, saveUninitialized: false, secret: process.env.COOKIE_SECRET, cookie: { httpOnly: true, secure: false }, })); app.use(passport.initialize()); app.use(passport.session()); app.use(cors({ origin: 'http://localhost:3000', // React 프론트엔드 도메인 credentials: true, })); // 라우터 연결 app.use('/api/auth', authRouter); // 에러 처리 app.use((req, res, next) => { const error = new Error('Not Found'); error.status = 404; next(error); }); app.use((err, req, res, next) => { res.status(err.status || 500).json({ message: err.message }); }); // 서버 시작 app.listen(app.get('port'), () => { console.log(`Server running on port ${app.get('port')}`); });
4. Passport 설정 (passport/index.js)
Passport는 인증 요청 처리의 핵심 역할을 합니다. local과 kakao 전략을 통합 관리합니다
const passport = require('passport'); const local = require('./localStrategy'); const kakao = require('./kakaoStrategy'); const User = require('../models/user'); module.exports = () => { passport.serializeUser((user, done) => { console.log('serialize'); done(null, user.id); }); passport.deserializeUser((id, done) => { console.log('deserialize'); User.findOne({ where: { id }, include: [{ model: User, attributes: ['id', 'nick'], as: 'Followers', }, { model: User, attributes: ['id', 'nick'], as: 'Followings', }], }) .then(user => { console.log('user', user); done(null, user); }) .catch(err => done(err)); }); local(); kakao(); };
localStrategy.js
로컬 로그인 전략 (passport/localStrategy.js)
const passport = require('passport'); const LocalStrategy = require('passport-local').Strategy; const bcrypt = require('bcrypt'); const User = require('../models/user'); module.exports = () => { // 로그인 passport.use(new LocalStrategy({ usernameField: 'userId', // req.body.userId passwordField: 'password', // req.body.password passReqToCallback: false, }, async (userId, password, done) => { try { const exUser = await User.findOne({ where: { userId } }); if (exUser) { const result = await bcrypt.compare(password, exUser.password); if (result) { done(null, exUser); } else { done(null, false, { message: '비밀번호가 일치하지 않습니다.' }); } } else { done(null, false, { message: '가입되지 않은 회원입니다.' }); } } catch (error) { console.error(error); done(error); } })); };
kakaoStrategy.js
const passport = require('passport'); const KakaoStrategy = require('passport-kakao').Strategy; const User = require('../models/user'); module.exports = () => { passport.use(new KakaoStrategy({ clientID: process.env.KAKAO_ID, callbackURL: '/auth/kakao/callback', }, async (accessToken, refreshToken, profile, done) => { console.log('kakao profile', profile); try { const exUser = await User.findOne({ where: { snsId: profile.id, provider: 'kakao' }, }); if (exUser) { done(null, exUser); } else { const newUser = await User.create({ email: profile._json?.kakao_account?.email, nick: profile.displayName, snsId: profile.id, provider: 'kakao', }); done(null, newUser); } } catch (error) { console.error(error); done(error); } })); };
5. JWT 유틸리티 (utils/auth.js)
JWT 토큰 생성 및 검증을 담당하는 유틸리티 함수를 정의합니다.
const { sign, verify, decode } = require('jsonwebtoken'); const { compare } = require('bcrypt'); const RefreshToken = require('../models/refreshTokens'); // Sequelize 모델 임포트 const User = require('../models/user'); // User 모델 임포트 (User 모델이 있다고 가정) const { NotAuthError } = require('./errors'); // 암호화 키를 설정합니다. const KEY = process.env.JWT_SECRET_KEY; const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; const ACCESS_TOKEN_EXPIRATION = '1h'; // 1시간 const REFRESH_TOKEN_EXPIRATION = 14 * 24 * 60 * 60; // 14일 (초 단위) // 1. 입력받은 비밀번호와 저장된 비밀번호를 비교하는 함수 function isValidPassword(password, storedPassword) { return compare(password, storedPassword); } // 2. 접근 토큰 생성 function createAccessToken(userId, userType) { console.log("2. 접근 토큰 생성 :", userId,userType, ACCESS_TOKEN_SECRET, ACCESS_TOKEN_EXPIRATION); const accessToken = sign({ userId,userType }, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRATION }); const decodedToken = decode(accessToken); return { accessToken, accessTokenExpires: decodedToken.exp, }; } // 3. 갱신 토큰 생성 function createRefreshToken(userId,userType) { const refreshToken = sign({ userId ,userType}, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRATION }); const decodedToken = decode(refreshToken); return { refreshToken, refreshTokenExpires: decodedToken.exp, }; } // 4. 접근 토큰 유효성 검사 function validateAccessToken(token) { return verify(token, ACCESS_TOKEN_SECRET); } // 5. 갱신 토큰 유효성 검사 function validateRefreshToken(token) { return verify(token, REFRESH_TOKEN_SECRET); } // 6. 갱신 토큰을 MySQL에 저장하는 함수 async function storeRefreshToken(refreshToken, userId) { try { const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRATION * 1000); // 만료 시간 계산 const existingToken = await RefreshToken.findOne({ where: { userId } }); if (existingToken) { // 기존 토큰 업데이트 existingToken.refreshToken = refreshToken; existingToken.expiresAt = expiresAt; await existingToken.save(); } else { // 새 토큰 생성 const newToken = await RefreshToken.create({ userId: userId, refreshToken, expiresAt, }); } console.log("6. 갱신 토큰을 MySQL에 저장하는 함수 :", userId, refreshToken, expiresAt); } catch (error) { console.error('Failed to store refresh token:', error); throw new Error('Failed to store refresh token'); } } // 7. 저장된 MySQL에서 갱신 토큰 가져오는 함수 async function getStoredRefreshToken(userId) { try { const tokenData = await RefreshToken.findOne({ where: { userId } }); return tokenData ? tokenData.refreshToken : null; } catch (error) { console.error('Failed to retrieve refresh token:', error); throw new Error('Failed to retrieve refresh token'); } } // 8. 인증 미들웨어 함수 async function checkAuthMiddleware(req, res, next) { console.log("미들웨어 인증 처리 :"); if (req.method === 'OPTIONS') { return next(); } if (!req.headers.authorization) { console.log("접근 권한이 없습니다.-1 :"); return next(new NotAuthError('접근 권한이 없습니다.')); } const authFragments = req.headers.authorization.split(' '); if (authFragments.length !== 2) { console.log("not-authenticated-2 :"); return next(new NotAuthError('접근 권한이 없습니다.')); } const authToken = authFragments[1]; try { const validatedToken = validateAccessToken(authToken); req.token = validatedToken; req.userId = validatedToken.userId; req.user = {}; // req.user 객체 초기화 req.user.userType = validatedToken.userType; req.userType = validatedToken.userType; } catch (error) { console.log("not-authenticated-3:", error.message); if (error.message === 'jwt expired') { console.log("접근 토큰 인증 만료"); return res.status(403).json({ message: 'ACCESS_TOKEN_EXPIRED' }); } return next(new NotAuthError('접근 권한이 없습니다.')); } next(); } // 9. 로그아웃 시 갱신 토큰 삭제 async function deleteRefreshToken(userId) { try { await RefreshToken.destroy({ where: { userId } }); console.log(`갱신 토큰 삭제 완료: ${userId}`); } catch (error) { console.error('Failed to delete refresh token:', error); throw new Error('Failed to delete refresh token'); } } // 10. 사용자 등록 시 (회원가입) async function registerUser(userData) { try { const { userId, password, nick } = userData; const userExists = await User.findOne({ where: { userId } }); if (userExists) { throw new Error('이미 존재하는 아이디입니다.'); } // 비밀번호 암호화 후 사용자 생성 const hashedPassword = await bcrypt.hash(password, 10); const newUser = await User.create({ userId, password: hashedPassword, nick, // 추가된 닉네임 }); return newUser; } catch (error) { console.error('회원가입 실패:', error); throw error; } } module.exports = { createAccessToken, createRefreshToken, validateAccessToken, validateRefreshToken, isValidPassword, storeRefreshToken, getStoredRefreshToken, checkAuthMiddleware, deleteRefreshToken, registerUser, // 회원가입 처리 함수 추가 };
errors.js
class NotAuthError { constructor(message) { this.message = message; this.status = 401; // 인증 실패를 나타내는 HTTP 상태 코드 } } exports.NotAuthError = NotAuthError;
6. 인증 컨트롤(routes/auth.js)
/api/auth에서 로그인 및 JWT 토큰 발급을 처리합니다
passport/localStrategy.js 와 연결처리 되어 로그인 인증처리 됩니다.
const bcrypt = require('bcrypt'); const passport = require('passport'); const User = require('../models/user'); const { createJSONToken, isValidPassword, createAccessToken, createRefreshToken, storeRefreshToken, deleteRefreshToken, validateRefreshToken } = require("../util/auth"); // 회원가입! exports.join = async (req, res, next) => { //console.log("회원가입 요청 데이터: ", req.body); const { userId, nickname, password, userType, address } = req.body; if (!userId || !nickname || !password || !address || !userType) { return res.status(400).send('모든 필드를 입력해주세요.'); } try { console.log(req.body); const exUser = await User.findOne({ where: { userId } }); // 로그인 - 일단 이 아이디로 가입한 유저가 있는지 찾기 if (exUser) { return res.status(400).json({ responseMessage: '이미 존재하는 사용자입니다.' }); } let time=req.body.time; if(!time) { time=null; } const hash = await bcrypt.hash(password, 12); // bcrypt 비밀번호 암호화 await User.create({ userId, nick: nickname, password: hash, userType, address: JSON.stringify(address), time: time }); //return res.redirect('/'); return res.status(201).json({ responseMessage: '회원가입 성공' }); } catch (error) { console.error(error); return next(error); } } // 로그인! exports.login =async (req, res, next) => { passport.authenticate('local',async (authError, user, info) => { if (authError) return res.status(500).json({ responseMessage: '서버 에러가 발생했습니다.' }); if (!user) return res.status(400).json({ responseMessage: info.message }); try { const userId = user.userId; const { accessToken: newAccessToken } = createAccessToken(user.id, user.userType); const { refreshToken: newRefreshToken } = createRefreshToken(user.id, user.userType); await storeRefreshToken(newRefreshToken, user.id); let parsedAddress = {}; if (user.address) parsedAddress = JSON.parse(user.address); return res.status(200).json({ id: user.id, userId, nickname: user.nick, address: parsedAddress, userType: user.userType, access_token: newAccessToken, refresh_token: newRefreshToken }); } catch (error) { console.error("로그인 처리 에러:", error); return res.status(500).json({ responseMessage: '서버 에러가 발생했습니다.' }); } })(req, res, next); }; //갱신 토큰 발급처리 exports.refresh = async (req, res, next) => { const { refreshToken } = req.body; //console.log("refresh token: " + refreshToken); if (!refreshToken) { return res.status(400).json({ responseMessage: 'refresh token 값이 존재하지 않습니다.', }); } // Refresh Token 유효성 검사 const refreshTokenValid = validateRefreshToken(refreshToken); if (!refreshTokenValid) { return res.status(400).json({ responseMessage: '갱신 토큰값이 유효하지 않습니다.', }); } try { console.log("갱신 토큰 유효합니다.", refreshTokenValid); // `validateRefreshToken`이 반환한 데이터 구조 확인 const { userId, userType } = refreshTokenValid; // Access Token 생성 const { accessToken: newAccessToken } = createAccessToken(userId, userType); // Refresh Token 생성 const { refreshToken: newRefreshToken } = createRefreshToken(userId, userType); // 기존 Refresh Token 삭제 await deleteRefreshToken(userId); // 새로운 Refresh Token 저장 await storeRefreshToken(newRefreshToken, userId); // 성공 응답 return res.status(200).json({ id: userId, access_token: newAccessToken, refresh_token: newRefreshToken, }); } catch (error) { console.error("갱신 토큰 처리 중 에러:", error); // 서버 에러 응답 return res.status(500).json({ responseMessage: '서버에서 에러가 발생했습니다. 잠시 후 다시 시도해주세요.', }); } }; exports.logout = async(req, res) => { req.logout(async () => { const userId = req.query.userId; const exUser = await User.findOne({ where: { userId } }); //console.log("로그아웃 시 갱신 토큰 삭제",exUser); await deleteRefreshToken(exUser.id); return res.status(200).json({responseMessage: 'success'}); }); };
2.프론트엔드
1. React 프로젝트 준비 및 필수 라이브러리 설치
1.1. 프로젝트 초기화
리액트 프로젝트를 생성하려면 create-react-app을 사용합니다.
npx create-react-app macaronics-clone cd macaronics-clone
1.2. 의존성 설치
package.json에 나열된 라이브러리를 프로젝트에 설치합니다.
npm install axios react-daum-postcode react-icons react-query react-router-dom react-select recoil seamless-scroll-polyfill styled-components
1.3. package.json 파일 예시
다음은 package.json의 기본 구조입니다.
{ "name": "macaronics-clone", "version": "0.1.0", "private": true, "dependencies": { "axios": "^1.4.0", "react": "^18.2.0", "react-daum-postcode": "^3.1.3", "react-dom": "^18.2.0", "react-icons": "^4.8.0", "react-query": "^3.39.3", "react-router-dom": "^6.11.1", "react-select": "^5.7.3", "recoil": "^0.7.7", "seamless-scroll-polyfill": "^2.3.4", "styled-components": "^6.0.0-rc.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test" } }
2. Axios 인스턴스 구성
2.1. Axios 인스턴스 설정
백엔드와의 HTTP 통신을 쉽게 하기 위해 Axios 인스턴스를 생성합니다.
import axios from "axios"; // Axios 인스턴스 생성 export const instance = axios.create({ baseURL: process.env.REACT_APP_SERVER_URL || "http://localhost:8001", // 백엔드 주소 withCredentials: true, headers: { "Content-Type": "application/json", }, }); // Request 인터셉터 instance.interceptors.request.use( (config) => { const accessToken = localStorage.getItem("access_token"); const refreshToken = localStorage.getItem("refresh_token"); if (accessToken && refreshToken) { config.headers["Access_token"] = `${accessToken}`; config.headers["Refresh_token"] = `${refreshToken}`; } if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }, (error) => Promise.reject(error) ); // Response 인터셉터 instance.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; if (error.response?.status === 403 && !originalRequest._retry) { originalRequest._retry = true; const refreshToken = localStorage.getItem("refresh_token"); if (!refreshToken) { localStorage.clear(); window.location.href = "/Intro"; return Promise.reject(error); } try { const response = await fetch( `${process.env.REACT_APP_SERVER_URL}/api/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), } ); if (response.ok) { const data = await response.json(); localStorage.setItem("access_token", data.access_token); localStorage.setItem("refresh_token", data.refresh_token); return instance(originalRequest); } else { localStorage.clear(); window.location.href = "/Intro"; return Promise.reject(error); } } catch (err) { return Promise.reject(err); } } return Promise.reject(error); } );
3. 로그인 및 로그아웃 처리
3.1. 로그인 처리
export const login = async (email, password) => { try { const response = await instance.post("/auth/login", { email, password }); localStorage.setItem("access_token", response.data.access_token); localStorage.setItem("refresh_token", response.data.refresh_token); localStorage.setItem("user", response.data.refresh_token); return response.data; } catch (error) { console.error("로그인 실패:", error); throw error; } };
3.2. 로그아웃 처리
export const userLogout = () => { const userId=localStorage.getItem("userId"); if(!userId || userId===null){ localStorage.clear(); //alert('로그아웃 성공'); return null; } return instance.get(`/api/auth/logout?userId=${userId}`) .then((response) => { localStorage.clear(); //alert('로그아웃 성공'); return response; }).catch((error) => { localStorage.clear(); if (error.response && error.response.data) { alert(error.response.data.responseMessage); } else { alert('로그아웃 중 에러가 발생했습니다.'); } return Promise.reject(error); }); };
4. CRUD 처리 예시
4.1. 데이터 조회
export const getData = async (endpoint) => { try { const response = await instance.get(endpoint); return response.data; } catch (error) { console.error("데이터 조회 실패:", error); throw error; } };
4.2. 데이터 생성
export const createData = async (endpoint, payload) => { try { const response = await instance.post(endpoint, payload); return response.data; } catch (error) { console.error("데이터 생성 실패:", error); throw error; } };
4.3. 데이터 수정
export const updateData = async (endpoint, payload) => { try { const response = await instance.put(endpoint, payload); return response.data; } catch (error) { console.error("데이터 수정 실패:", error); throw error; } };
4.4. 데이터 삭제
export const deleteData = async (endpoint) => { try { await instance.delete(endpoint); console.log("데이터 삭제 성공"); } catch (error) { console.error("데이터 삭제 실패:", error); throw error; } };
5. 실제 예제: React 컴포넌트와 Axios 통합
5.1. 사용자 리스트 조회
import React, { useEffect, useState } from "react"; import { getData } from "../api/axiosInstance"; const UserList = () => { const [users, setUsers] = useState([]); useEffect(() => { const fetchUsers = async () => { try { const data = await getData("/users"); setUsers(data); } catch (error) { console.error("사용자 목록 조회 오류:", error); } }; fetchUsers(); }, []); return ( <div> <h1>사용자 목록</h1> <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> </div> ); }; export default UserList;
위와 같은 방식으로 React에서 Axios를 사용하여 백엔드 API와 통신하는 방법을 구현할 수 있습니다.
interceptors를 활용하면 반복적인 요청/응답 처리를 효율적으로 관리할 수 있습니다.
댓글 ( 0)
댓글 남기기