Backend
home
🕔

2024. 9. 10 (JWT 토큰)

생성일
2025/01/24 05:52
태그
JWT 토큰 관련 핵심 코드 정리

Spring 코드

LoginCustomAuthenticationFilter 작성

로그인 인증 필터를 위해 LoginCustomAuthenticationFilter 작성 (WebSecurityConfig 쪽에서 로그인 필터 내용 제거해도 됨)
package com.kosta.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.kosta.domain.LoginRequest; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import java.io.IOException; @Slf4j public class LoginCustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter { private static final AntPathRequestMatcher LOGIN_PATH = new AntPathRequestMatcher("/api/auth/login", "POST"); protected LoginCustomAuthenticationFilter(AuthenticationManager authenticationManager) { super(LOGIN_PATH); setAuthenticationManager(authenticationManager); } @Override public Authentication attemptAuthentication( HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { // POST, /api/auth/login 에 요청이 들어오면 진행되는 곳 LoginRequest loginRequest = null; // 1. Body에 있는 로그인 정보 ("email": "~~", password: "~~") try { log.info("[attemptAuthentication] 로그인 정보 가져오기"); ObjectMapper objectMapper = new ObjectMapper(); loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class); } catch (IOException e) { throw new RuntimeException("로그인 요청 파라미터 이름 확인 필요 (로그인 불가)"); } // 2. email과 password를 기반으로 AuthenticationToken 생성! log.info("[attemptAuthentication] AuthenticationToken 생성"); UsernamePasswordAuthenticationToken uPAT = new UsernamePasswordAuthenticationToken( loginRequest.getEmail(), loginRequest.getPassword()); // 3. 인증 시작 (AuthenticationManager의 authenticate 메소드가 동작할 때 -> loadUserByUsername 동작) log.info("[attemptAuthentication] 인증 시작"); Authentication authenticate = getAuthenticationManager().authenticate(uPAT); return authenticate; } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { log.info("로그인 정상적으로 성공함"); super.successfulAuthentication(request, response, chain, authResult); } }
Java
복사

토큰 처리 위한 TokenUtil 생성

package com.kosta.util; import com.fasterxml.jackson.databind.ObjectMapper; import com.kosta.config.JwtProvider; import com.kosta.domain.LoginResponse; import com.kosta.entity.User; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.HashMap; import java.util.Map; @Component @RequiredArgsConstructor public class TokenUtils { private final JwtProvider jwtProvider; // 토큰 생성 public Map<String, String> generateToken(User user) { String accessToken = jwtProvider.generateAccessToken(user); Map<String, String> tokenMap = new HashMap<>(); tokenMap.put("accessToken", accessToken); return tokenMap; } // JSON 응답 전송 public void writeResponse(HttpServletResponse response, LoginResponse loginResponse) throws IOException { ObjectMapper objectMapper = new ObjectMapper(); String jsonResponse = objectMapper.writeValueAsString(loginResponse); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); response.getWriter().write(jsonResponse); } }
Java
복사

RefreshToken 적용

controller
@PostMapping("/signup") public ResponseEntity<UserResponse> signUp(@RequestBody SignUpRequest signUpRequest) { log.info("[signUp] 회원가입 진행. 요청정보 : {}", signUpRequest); UserResponse userResponse = authService.signUp(signUpRequest); return ResponseEntity.status(HttpStatus.CREATED).body(userResponse); }
Java
복사

React 코드

Header.jsx

const Header = () => { const { accessToken, tokenCheck, logout, } = useAuth(); let allMenu = [ { path: '/user', name: '회원 관리', auth: ["ROLE_ADMIN"] }, { path: '/favorite', name: '즐겨찾기', auth: ["ROLE_ADMIN", "ROLE_USER"] }, { path: '/post', name: '게시물', auth: ["ROLE_ADMIN", "ROLE_USER", "none"] }, { path: '/signup', name: '회원가입', auth: ["none"] }, { path: '/login', name: '로그인', auth: ["none"] }, { path: '/logout', name: '로그아웃', auth: ["ROLE_ADMIN", "ROLE_USER"] }, { path: '/search', name: '검색', auth: ["ROLE_ADMIN", "ROLE_USER", "none"] }, ] const [menu, setMenu] = useState([]); useEffect(() => { // 만약에 브라우저 토큰이 유효하면 if (tokenCheck()) { // 권한에 맞는 메뉴 설정 const claims = jwtDecode(accessToken); const role = claims.role; setMenu(allMenu.filter(m => m.auth.includes(role))); // 그렇지 않으면 } else { // 로그아웃 처리 logout(() => navigate("/login")); // none 메뉴 설정 setMenu(allMenu.filter(m => m.auth.includes("none"))); } }, [accessToken]); const [menuOpen, setMenuOpen] = useState(false); const navigate = useNavigate(); const toggleDrawer = () => { setMenuOpen(prev => !prev); } ... 중략 ... } const MySearch = () => { const navigate = useNavigate(); const [keyword, setKeyword] = useState(""); const handleSearch = (e) => { if (e.key === "Enter") { // alert(`${keyword} 검색`); // http://localhost:8080/post/search?keyword=검색어 postSearch(keyword); } } const postSearch = async (keyword) => { try { const res = await postAPI.searchPost(keyword); navigate("/search", { state : res.data }); } catch (error) { console.error(error); } } return ( <Search> <SearchIconWrapper> <SearchIcon /> </SearchIconWrapper> <StyledInputBase placeholder="Search…" value={keyword} onKeyDown={(e) => handleSearch(e)} onChange={(e) => setKeyword(e.target.value)} /> </Search> ) }
JavaScript
복사

Login.jsx

import { useNavigate } from "react-router-dom"; import { useAuth } from "../../hooks/useAuth"; const { Paper, Box, Typography, Divider, TextField, Button } = require("@mui/material"); const { useForm } = require("react-hook-form"); const Login = () => { const { register, handleSubmit, formState: { errors }, setError, clearErrors } = useForm(); const navigate = useNavigate(); const { login } = useAuth(); const onSubmit = (data) => { try { login(data, () => navigate("/"), () => alert("로그인 실패")); } catch (error) { setError("email", { type: "manual", message: "아이디를 확인해주세요" }); setError("password", { type: "manual", message: "비밀번호를 확인해주세요" }); } } return ( <Paper variant="outlined"> <Box component="form" noValidate sx={{ p: 5 }} onSubmit={handleSubmit(onSubmit)}> <Typography component="h1" variant="h6" gutterBottom>로그인</Typography> <Divider sx={{ my: 2 }} /> <TextField label="Email address" id="email" name="email" placeholder="your email address" variant="standard" fullWidth margin="normal" type="email" focused color="main" autoComplete="email" {...register( "email", { required: "이메일은 필수 입력값입니다." } )} error={errors.email ? true : false} helperText={errors.email && errors.email.message} /> <TextField label="Password" id="password" name="password" placeholder="your password" variant="standard" fullWidth margin="normal" type="password" focused color="main" autoComplete="password" sx={{ mb: 3 }} {...register("password", { required: "비밀번호를 입력해주세요", pattern: { value: /^(?=.*[a-zA-Z0-9]).{6,20}$/, message: "비밀번호는 문자와 숫자를 포함해 최소 6자 이상 20자 이내로 작성해주세요." } } )} error={errors.password ? true : false} helperText={errors.password && errors.password.message} /> <Button type="submit" variant="contained" color="sub" fullWidth>로그인</Button> </Box> </Paper> ); } export default Login;
JavaScript
복사

Post.jsx

import { Button, Divider, Grid2 } from "@mui/material" import axios from "axios"; import { useEffect } from "react"; import { useState } from "react"; import PostCard from "./PostCard"; import { useLocation, useNavigate } from "react-router-dom"; import { postAPI } from "../../api/services/post"; import { useAuth } from "../../hooks/useAuth"; const Post = () => { const { state } = useLocation(); const navigate = useNavigate(); const [postList, setPostList] = useState([]); const getPostList = async() => { if (state) { setPostList(state); } else { try { const res = await postAPI.getPostList(); const data = res.data; setPostList(data); } catch (error) { console.error(error); navigate("/error", {state: error.message}); } } } useEffect(() => { getPostList(); }, [state]); const { accessToken } = useAuth(); return ( <> <h1>포스트</h1> { accessToken && <> <Button variant="contained" color="main" onClick={() => navigate("/post/write")}>글쓰기</Button> <Divider /> </> } <Grid2 container direction={"column"} spacing={2}> { postList.map(post => ( <PostCard key={post.id} post={post}></PostCard> )) } </Grid2> </> ); } export default Post;
JavaScript
복사

SignUp.jsx (회원가입)

/* 회원가입 */ const onSubmit = async (data) => { try { console.log("회원가입 로직", data); const res = await userAPI.addUser(data); if (res.status === 201) { // 회원가입 성공 시 메시지와 함께 메인으로 이동 Swal.fire({ html: "가입에 성공했습니다.", timer: 1500, timerProgressBar: true, }).then(navigate("/")); } } catch (error) { console.error(error); navigate("/error", {state: error.message}); } } /* 이메일 중복 확인 여부를 체크하기 위한 state*/ const [isNotDuplicate, setIsNotDuplicate] = useState(false); /* 중복 확인 결과를 통한 색상 변경 state */ const [emailColor, setEmailColor] = useState("main"); /* 이메일 중복 확인 */ const emailCheck = async() => { console.log("이메일 체크"); const email = getValues("email"); const regExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; // 이메일 정규식 체크하여, 통과하면 중복체크 if(regExp.test(email)) { // 중복체크 const res = await userAPI.emailCheck(email); if (res.data) { // 중복체크 성공 시 setIsNotDuplicate(true); setEmailColor("success"); clearErrors("email"); } else { setError("email", { type: 'custom', message: '중복된 이메일입니다.' }); } } else { // 이메일 정규식 체크 실패 또는 중복된 이메일일 경우. setError("email", { type: 'custom', message: '이메일 형식에 맞지 않습니다.' }); } } // 이메일 값이 변경되면, 중복체크 다시 할 수 있도록 설정 const emailInputReset = () => { setIsNotDuplicate(false); setEmailColor("main") }
JavaScript
복사

Api.js

403 에러 처리
HttpOnly 쿠키 속성으로 저장된 refreshToken 전송
import axios from "axios"; const api = axios.create({ baseURL: `${process.env.REACT_APP_REST_SERVER}`, withCredentials: true // HttpOnly 쿠키 속성으로 저장된 refreshToken 전송한다. }); api.interceptors.request.use( (config) => { // token 관련 내용 const token = localStorage.getItem("token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } else { delete config.headers.Authorization; } return config; }, (err) => { return Promise.reject(err); } ); api.interceptors.response.use( (res) => { return res; }, async (err) => { // 원래 403으로 실패했던 요청 const originalReq = err.config; // 403 에러 처리 if (err.response.status == 403 && !originalReq._retry) { // 만약에 권한이 없다는 에러가 나오면 try { // 토큰 재발급 해주도록 할 것이다. const response = await refreshTokenHandler(); // 정상 재발급 시 if (response.status === 200) { // token값 로컬스토리지에 저장 localStorage.setItem("token", response.data.accessToken); // 토큰 재발급 // 헤더에 새로운 token 추가해서 originalReq.headers.Authorization = `Bearer ${response.data.accessToken}`; // 실패했던 요청 다시 보내기 return api.request(originalReq); } } catch (error) { console.log("토큰 재발급 실패"); } console.log("403 에러 권한 없음"); } return Promise.reject(err); } ); const refreshTokenHandler = async () => { try { const response = await api.post("/auth/refresh-token"); return response; } catch (error) { throw error; } } export default api;
JavaScript
복사