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
복사

