본문 바로가기

[IT/Programming]

11월 04일 2기 위클리 페이퍼 - 경험을 바탕으로 React 애플리케이션에서 JSON Web Token(JWT)을 사용하여 사용자 인증 시스템을 구현하는 방법에 대해 자세히 설명해주세요. 특히 로그아웃 구현 로직에 대해 설명해주세요. RESTful API의 개념과 주요 제약 조건을 설명하세요.

반응형
# 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를 통해 클라이언트와 서버가 자원을 주고받으며 상호작용할 수 있습니다.
반응형