Physicist, Programmer. What you eat, how you think, and most importantly what you have done become who you are. Who are you? and who will you be?
[IT/Programming]
11월 04일 2기 위클리 페이퍼 - 경험을 바탕으로 React 애플리케이션에서 JSON Web Token(JWT)을 사용하여 사용자 인증 시스템을 구현하는 방법에 대해 자세히 설명해주세요. 특히 로그아웃 구현 로직에 대해 설명해주세요. RESTful API의 개념과 주요 제약 조건을 설명하세요.
kipid2024. 11. 11. 15:48
728x90
반응형
# 11월 04일 2기 위클리 페이퍼 - 경험을 바탕으로 React 애플리케이션에서 JSON Web Token(JWT)을 사용하여 사용자 인증 시스템을 구현하는 방법에 대해 자세히 설명해주세요. 특히 로그아웃 구현 로직에 대해 설명해주세요. RESTful API의 개념과 주요 제약 조건을 설명하세요.
## PH
2024-11-05
## TOC
## React 애플리케이션에서 JSON Web Token(JWT)을 사용하여 사용자 인증 시스템을 구현하는 방법에 대해 자세히 설명
### Front-End 단에서의 처리.
```[.linenums]
import axios from 'axios';
const instance = axios.create({
// baseURL: `https://panda-market-api.vercel.app`,
baseURL: `http://localhost:3100`,
});
// request 를 보낼때 가로채서 Authorization 에 Bearer ${accessToken} 을 붙여준다.
instance.interceptors.request.use(function (config) {
const user = localStorage.getItem("user");
if (user) {
const accessToken = JSON.parse(user).accessToken;
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Token 이 유효하지 않아 401 혹은 403 error 가 났을 때, refreshToken 을 이용해 accessToken 을 update 해준다. 그래도 401 혹은 403 error 가 난다면 로그인 페이지로~
instance.interceptors.response.use(res => res, async (error) => {
const originalRequest = error.config;
const response = error.response; // 가로챈 리스폰스
const user = localStorage.getItem("user");
if (user && (response?.status === 401 || response?.status === 403)) {
const userJSON = JSON.parse(user);
if (!originalRequest._retry) {
const res = await instance.post('/account/renew-token', {}, { _retry: true }); // refreshToken 은 HttpOnly 쿠키로 전달.
userJSON.accessToken = res.data.accessToken;
localStorage.setItem("user", JSON.stringify(userJSON));
originalRequest._retry = true;
return instance(originalRequest);
} else {
localStorage.removeItem("user");
window.location.href = '/login';
}
}
return Promise.reject(error);
});
export default instance;
```/
### Back-End 단에서의 처리.
```[.linenums]
// userService.js 에서
// 비밀번호를 해싱한다.
async function hashingPassword(password) { // 함수 추가
return bcrypt.hash(password, 10); // 2^10 번 hash
}
// Type 에 따라 토큰을 만들어준다.
function createToken(user, type) {
const payload = { userId: user.id };
const options = {
expiresIn: type === 'refresh' ? '1w' : '1h',
};
return jwt.sign(payload, process.env.JWT_SECRET, options);
}
// 입력받은 비밀번호와 저장된 (해싱된) 비밀번호의 일치여부를 판단한다.
async function verifyPassword(inputPassword, savedPassword) {
const isValid = await bcrypt.compare(inputPassword, savedPassword); // 변경
if (!isValid) {
const error = new Error('Unauthorized');
error.code = 401;
throw error;
}
}
// 이메일과 비밀번호로 유저 인증을하고 민감한 정보를 필터한 user 를 리턴해준다.
async function getUser(email, password) {
const user = await userRepository.findByEmail(email);
if (!user) {
const error = new Error('User not found.');
error.code = HttpStatus.NOT_FOUND;
throw error;
}
verifyPassword(password, user.encryptedPassword);
return filterSensitiveUserData(user);
}
// user 정보 중 민감한 (보안) 정보를 필터해준다.
function filterSensitiveUserData(user) {
const { password, encryptedPassword, refreshToken, ...rest } = user;
return rest;
}
// 가입시에 이미 존재하는 유저인지 확인하고 없으면 비밀번호를 해싱해서 저장해준다. 그리고 민감한 (보안) 정보를 필터해서 반환해준다.
async function createUser(user) {
const existedUser = await userRepository.findByEmail(user.email);
if (existedUser) {
const error = new Error('User already exists');
error.code = 422;
error.data = { email: user.email };
throw error;
}
const hashedPassword = await hashingPassword(user.password); // 해싱 과정 추가
const createdUser = await userRepository.save({ ...user, encryptedPassword: hashedPassword }); // password 추가
return filterSensitiveUserData(createdUser);
}
// Refresh-Token 은 기본 인증에 추가로 user table 에 저장된 refresh token 과도 일치하는지 한번 더 확인한다. 이건 보안에는 좀 더 좋을지 모르지만, 여러기기에서 로그인을 유지해주기 어렵다는 단점도 있다.
async function refreshToken(userId, refreshToken) {
const user = await userRepository.findById(userId);
if (!user || user.refreshToken !== refreshToken) {
const error = new Error('Unauthorized');
error.code = 401;
throw error;
}
const accessToken = createToken(user); // 변경
const newRefreshToken = createToken(user, 'refresh'); // 추가
return { accessToken, newRefreshToken };
}
```/
```[.linenums]
// userController.js 에서
// Refresh-Token 은 보안을 위해 HttpOnly and Secure 쿠키로 전달한다.
const setRefreshTokenCookie = (res, refreshToken) => {
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
sameSite: 'none',
secure: true,
path: `/account${RENEW_TOKEN_PATH}`,
maxAge: 7 * 24 * 60 * 60,
});
};
// 가입을 받을때 이미 있는 계정인지 확인하고 없다면, accessToken 과 refreshToken 및 필터된 user 데이터를 넘겨준다. 비밀번호는 해싱해서 저장한다.
userController.post('/users', async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
const accessToken = userService.createToken(user);
const refreshToken = userService.createToken(user, 'refresh');
await userService.updateUser(user.id, { refreshToken });
setRefreshTokenCookie(res, refreshToken);
return res.status(HttpStatus.CREATED).json({ accessToken, user });
} catch (error) {
next(error);
}
});
// 로그인시 계정과 비밀번호를 받아서 해싱된 비밀번호와 비교를 통해 로그인을 인증한다.
userController.post('/login', async (req, res, next) => {
const { email, password } = req.body;
try {
const user = await userService.getUser(email, password);
const accessToken = userService.createToken(user);
const refreshToken = userService.createToken(user, 'refresh');
await userService.updateUser(user.id, { refreshToken });
setRefreshTokenCookie(res, refreshToken);
return res.json({ accessToken, user }); // filter 된 user 정보
} catch (error) {
next(error);
}
});
// Refresh-Token 재발급 요청이 왔을때, passport 에 등록한 'refresh-token' 방식으로 인증을 하고 따로 또 DB 랑도 비교를 통해 이중 인증을 한다. 인증이 성공했다면, 새로 발급받은 accessToken 과 refreshToken 을 적절히 반환해준다.
userController.post(RENEW_TOKEN_PATH,
passport.authenticate('refresh-token', { session: false }),
async (req, res, next) => {
try {
const { refreshToken } = req.cookies;
const { id: userId } = req.user;
const { accessToken, newRefreshToken } = await userService.refreshToken(userId, refreshToken);
await userService.updateUser(userId, { refreshToken: newRefreshToken }); // 추가
setRefreshTokenCookie(res, newRefreshToken);
return res.json({ accessToken });
} catch (error) {
return next(error);
}
});
```/
#### passport.js 이용하기.
```[.linenums]
// passport.js 에서
import passport from 'passport';
import { accessTokenStrategy, refreshTokenStrategy } from '../middlewares/passport/jwtStrategy.js';
import googleStrategy from '../middlewares/passport/googleStrategy.js';
passport.use(googleStrategy);
passport.use('access-token', accessTokenStrategy);
passport.use('refresh-token', refreshTokenStrategy);
export default passport;
```/
#### GoogleStrategy
```[.linenums]
// googleStrategy.js 에서
import GoogleStrategy from 'passport-google-oauth20';
import userService from '../../services/userService.js';
// Google console 에서 API 등록한 값들을 여기 적는다.
const googleStrategyOptions = {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/account/auth/google/callback'
};
// 구글에 접속하기 위한 accessToken 과 refreshToken 을 변수로 넘겨주고, profile 을 받는다. 다음 인가를 위해 user 를 넘겨준다.
async function verify(accessToken, refreshToken, profile, done) {
const user = await userService.oauthCreateOrUpdate(
profile.provider,
profile.id,
profile.emails[0].value,
profile.displayName
);
done(null, user);
}
const googleStrategy = new GoogleStrategy(googleStrategyOptions, verify);
export default googleStrategy;
```/
```[.linenums]
// userController.js 에서
// '/account/auth/google' 로 요청이 들어오면, 구글 인증창으로 redirect 시켜준다.
userController.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
// 구글 인증을 user 가 허가하면 callback 으로 받아서 다음과 같이 처리한다.
userController.get('/auth/google/callback',
passport.authenticate('google'),
(req, res, next) => {
const { id } = req.user;
const accessToken = userService.createToken(id);
const refreshToken = userService.createToken(id, 'refresh');
setRefreshTokenCookie(res, refreshToken);
return res.json({ accessToken, user: req.user });
});
```/
```[.linenums]
// userService.js 에서
async function oauthCreateOrUpdate(provider, providerId, email, nickname) {
const user = await userRepository.createOrUpdate(provider, providerId, email, nickname);
return filterSensitiveUserData(user);
}
```/
#### JwtStrategy
```[.linenums]
// jwtStrategy.js 에서
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import userService from '../../services/userService.js'
// Authorization: Bearer 에서 accessToken 을 가져온다.
const accessTokenOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
// issuer: 'enter issuer here',
// audience: 'enter audience here',
};
// app.use(cookieParser()); lib 를 통해 쿠키에서 refreshToken 을 가져온다.
const cookieExtractor = function(req) {
var token = null;
if (req && req.cookies) {
token = req.cookies['refreshToken'];
}
return token;
};
const refreshTokenOptions = {
jwtFromRequest: cookieExtractor,
secretOrKey: process.env.JWT_SECRET,
}
// 뒤의 hash 값 검증은 당연히 하는거고 payload 에서 얻어낸 userId 에 해당하는 user 가 있는지 확인한다. 그리고 user 를 돌려준다.
async function jwtVerify(payload, done) {
try {
const user = await userService.getUserById(payload.userId);
if (!user) {
return done(null, false);
}
return done(null, user);
} catch (error) {
return done(error, false);
}
}
const accessTokenStrategy = new JwtStrategy(accessTokenOptions, jwtVerify);
const refreshTokenStrategy = new JwtStrategy(refreshTokenOptions, jwtVerify);
export {
accessTokenStrategy,
refreshTokenStrategy,
};
```/
## 특히 로그아웃 구현 로직에 대해 설명
jwt 는 서버단에서 무언가를 저장해서 비교하는게 아니기 때문에 클라이언트단에서 jwt 만 지워주면 로그아웃이 된다. 관련된 유저 정보가 있다면 그것도 지워준다.
```[.linenums]
localStorage.clear();
setUser(null);
```/
## RESTful API의 개념과 주요 제약 조건을 설명
RESTful API (Representational State Transfer API)는 웹 서비스 아키텍처의 한 종류로, HTTP 프로토콜을 기반으로 하는 API 설계 원칙입니다. RESTful API는 클라이언트와 서버 간의 상호 작용을 간단하고 확장 가능하게 만들기 위해 설계되었습니다. 주요 개념과 제약 조건은 다음과 같습니다:
### 주요 개념
자원 (Resource): 자원은 API의 주요 개체로, 고유한 URL로 식별됩니다. 자원은 데이터베이스의 레코드나 파일과 같은 실제 데이터를 나타낼 수 있습니다.
표현 (Representation): 자원의 상태는 다양한 형식 (JSON, XML, HTML 등)으로 클라이언트에게 제공됩니다. 클라이언트는 자원의 현재 상태를 읽거나 수정할 수 있습니다.
HTTP 메서드:
GET: 자원 조회
POST: 자원 생성
PUT: 자원 전체 수정
PATCH: 자원 부분 수정
DELETE: 자원 삭제
상태 코드 (Status Code): 클라이언트에게 요청의 결과를 나타내는 HTTP 응답 코드입니다. 예: 200(성공), 404(자원 없음), 500(서버 오류) 등.
### 주요 제약 조건
클라이언트-서버 구조: 클라이언트와 서버는 서로 독립적으로 설계되어야 합니다. 클라이언트는 서버의 구현 세부 사항을 알 필요가 없으며, 서버는 클라이언트의 사용자 인터페이스나 상태를 관리할 필요가 없습니다.
무상태 (Statelessness): 각 요청은 독립적이고 완전해야 합니다. 즉, 서버는 요청 간의 상태를 저장하지 않습니다. 모든 필요한 정보는 요청에 포함되어야 합니다.
캐시 가능성 (Cacheability): 응답은 캐시할 수 있어야 하며, 캐시 가능한 응답에는 명시적인 캐시 제어 정보가 포함되어야 합니다.
계층화 시스템 (Layered System): 클라이언트는 중간 서버를 통해 서버와 상호 작용할 수 있으며, 각 중간 서버는 추가적인 기능 (로드 밸런싱, 캐시 등)을 제공할 수 있습니다.
통합 인터페이스 (Uniform Interface): 자원에 접근하기 위한 표준화된 방법이 제공되어야 합니다. 이는 URI, HTTP 메서드, 상태 코드 등으로 이루어집니다.
이러한 개념과 제약 조건을 준수함으로써, RESTful API는 확장 가능하고 유지보수하기 쉬운 시스템을 만들 수 있습니다.
### 예시: 도서관
가상의 도서관 시스템을 생각해 봅시다. 도서관 시스템에서는 책을 조회, 추가, 수정, 삭제할 수 있어야 합니다. 이를 위해 RESTful API를 설계해 보겠습니다.
자원 (Resource)
도서관 시스템에서 주요 자원은 "책"입니다. 각 책은 고유한 URL로 식별됩니다.
예: /books
HTTP 메서드 사용 예
#### GET (자원 조회):
모든 책 조회: GET /books
특정 책 조회: GET /books/{book_id}
예: GET /books/123 (ID가 123인 책 조회)
#### POST (자원 생성):
새로운 책 추가: POST /books
요청 본문에 새 책의 정보를 포함하여 전송
```[.linenums]
{
"title": "RESTful API의 이해",
"author": "김철수",
"published_date": "2023-01-01"
}
```/
#### PUT (자원 전체 수정):
기존 책 정보 수정: PUT /books/{book_id}
요청 본문에 수정할 책의 전체 정보를 포함하여 전송
```[.linenums]
{
"title": "RESTful API 완벽 가이드",
"author": "김철수",
"published_date": "2023-01-02"
}
```/
#### PATCH (자원 부분 수정):
책의 특정 정보 수정: PATCH /books/{book_id}
요청 본문에 수정할 부분 정보만 포함하여 전송
```[.linenums]
{
"title": "RESTful API 완벽 가이드"
}
```/
#### DELETE (자원 삭제):
특정 책 삭제: DELETE /books/{book_id}
예: DELETE /books/123 (ID가 123인 책 삭제)
#### 응답 상태 코드 예
200 OK: 요청이 성공적으로 처리되었을 때
201 Created: 새로운 자원이 성공적으로 생성되었을 때
400 Bad Request: 잘못된 요청일 때 (예: 필수 데이터 누락)
401 Unauthorized: 인증되지 않은 사용자가 접근할 때
403 Forbidden: 권한이 없는 사용자가 접근할 때
404 Not Found: 요청한 자원이 없을 때
500 Internal Server Error: 서버 내부 오류가 발생했을 때
#### 예시 시나리오
클라이언트가 GET /books 요청을 보내면, 서버는 도서 목록을 JSON 형식으로 응답합니다.
클라이언트가 새로운 책을 추가하기 위해 POST /books 요청을 보내면, 서버는 새 책을 데이터베이스에 저장하고 201 Created 상태 코드와 함께 새 책의 정보를 응답합니다.
클라이언트가 특정 책의 제목을 수정하기 위해 PATCH /books/123 요청을 보내면, 서버는 해당 책의 제목을 업데이트하고 200 OK 상태 코드와 함께 수정된 책의 정보를 응답합니다.
이와 같이 RESTful API를 통해 클라이언트와 서버가 자원을 주고받으며 상호작용할 수 있습니다.