이전글
https://greed-yb.tistory.com/330
Spring Security Back-end
dependencies
// security-jwt
implementation group: 'org.springframework.security', name: 'spring-security-jwt', version: '1.1.1.RELEASE'
// JWT
implementation group: 'com.auth0', name: 'java-jwt', version: '4.2.1'
WebSecurityConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
// token 인증
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public WebSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://localhost:3000"); // React 주소
configuration.addAllowedOrigin("http://localhost:7172"); // SpringBoot 주소
configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
configuration.addAllowedHeader("Authorization"); // Authorization 헤더만 허용
configuration.addExposedHeader("Authorization"); // Authorization 헤더 노출
configuration.setAllowCredentials(true); // 인증 정보 포함 (쿠키 기반)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().configurationSource(corsConfigurationSource()).and()
.csrf().disable()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/","/index.html","/{path:[^\\.]*}","/resources/**", "/static/**", "/assets/**" , "/css/**", "/js/**").permitAll() // 정적 리소스 허용
.requestMatchers("/api/**").permitAll() //axios 허용
.anyRequest().authenticated() // 나머지 요청은 인증 필요
)
.httpBasic().disable() // 기본 HTTP 인증 비활성화
.formLogin().disable(); // 폼 기반 인증 비활성화
// exception 처리
http
.exceptionHandling()
.authenticationEntryPoint(new WebAuthenticationEntryPoint()); // 인증되지 않은 사용자 접근 시
return http.build();
}
}
JWT token 유무 및 생성을 위한 메서드를 추가한다
서버에서 Exception 처리를 위한 메서드를 추가한다
WebAuthenticationEntryPoint
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import java.io.IOException;
public class WebAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
System.out.println("Exception Type: " + authException.getClass().getName());
// 인증되지 않은 사용자는 login 페이지로 이동
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드
response.setContentType("application/json");
response.getWriter().write("{\"message\": \"Unauthorized\", \"redirect\": \"/signin\"}");
}
}
인증되지 않은 사용자 또는 JWT token 이 만료된 경우
해당 메서드가 실행된다
JwtTokenUtil
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtTokenUtil {
private static final String SECRET_KEY = "jwttoken_secret_key";
private static final String REFRESH_SECRET_KEY = "jwttoken_refresh_secret_key";
private static final long ACCESS_TOKEN_EXPIRATION = 60 * 60 * 1000; // 60분
private static final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7일
// TEST 용
// private static final long ACCESS_TOKEN_EXPIRATION = 1 * 60 * 1000; // 1분
// private static final long REFRESH_TOKEN_EXPIRATION = 5 * 60 * 1000; // 5분
// Access Token 생성
public String generateAccessToken(String username) {
return JWT.create()
.withSubject(username)
.withExpiresAt(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
.sign(Algorithm.HMAC512(SECRET_KEY.getBytes()));
}
// Refresh Token 생성
public String generateRefreshToken(String username) {
return JWT.create()
.withSubject(username)
.withExpiresAt(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION))
.sign(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()));
}
// 검증 된 Access Token 에서 username 추출
public String getUsernameFromAccessToken(String token) {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(SECRET_KEY.getBytes()))
.build()
.verify(token);
return jwt.getSubject();
}
// 검증 된 Refresh Token 에서 username 추출
public String getUsernameFromRefreshToken(String token) {
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()))
.build()
.verify(token);
return jwt.getSubject();
}
// 유효한 Access Token 인지 검증
public boolean validateAccessToken(String token) {
try {
JWT.require(Algorithm.HMAC512(SECRET_KEY.getBytes()))
.build()
.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
// 유효한 Refresh Token 인지 검증
public boolean validateRefreshToken(String token) {
try {
JWT.require(Algorithm.HMAC512(REFRESH_SECRET_KEY.getBytes()))
.build()
.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
}
JwtAuthenticationFilter
import com.example.reactpractice.entity.user.User;
import com.example.reactpractice.repository.user.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Optional;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private UserRepository userRepository;
private final JwtTokenUtil jwtTokenUtil;
public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil, UserRepository userRepository) {
this.jwtTokenUtil = jwtTokenUtil;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("JwtAuthenticationFilter 호출: " + request.getRequestURI());
String aToken = getJwtFromRequest(request); // Access Token 가져오기
String rToken = getRefreshTokenFromRequest(request); // Refresh Token 가져오기
// 유효한 access token 인 경우
if (aToken != null && jwtTokenUtil.validateAccessToken(aToken)) {
String username = jwtTokenUtil.getUsernameFromAccessToken(aToken);
// 사용자 정보 가져오기
Optional<User> vo = userRepository.findById(username);
// 인증 정보 생성 , id, password , role
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, vo.get().getPassword(), new ArrayList<>());
// 사용자 정보 set
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// access token 이 만료되었고 refresh token 은 유효한 경우
else if (aToken == null && jwtTokenUtil.validateRefreshToken(rToken)) {
// refresh token 정보를 가져와서 새로운 access token 발급
if (rToken != null) {
String username = jwtTokenUtil.getUsernameFromRefreshToken(rToken);
String newAccess = jwtTokenUtil.generateAccessToken(username);
// 사용자 정보 가져오기
Optional<User> vo = userRepository.findById(username);
// 새로운 access token 로 변경
response.setHeader("Authorization", "Bearer " + newAccess);
Cookie accessTokenCookie = new Cookie("accessToken", newAccess);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(60 * 60);
response.addCookie(accessTokenCookie);
// 인증 정보 생성
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, vo.get().getPassword(), new ArrayList<>());
// 사용자 정보 set
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} else {
// token 이 존재하지 않으면
chain.doFilter(request, response);
return;
}
chain.doFilter(request, response);
}
// 필터를 적용하지 않을 경로 지정
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return path.startsWith("/assets/") ||
path.startsWith("/static/") ||
path.startsWith("/favicon.ico");
}
// 쿠키에서 Access Token 가져오기
private String getJwtFromRequest(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// 쿠키에서 Refresh Token 가져오기
public String getRefreshTokenFromRequest(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refreshToken")) {
return cookie.getValue();
}
}
}
return null;
}
}
페이지 이동 및 api 요청마다 token 을 인증한다
controller
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class WebController {
// 모든 비정적 경로 처리하며 React의 index.html 로 이동시킨다
@GetMapping("/{path:[^\\.]*}")
public String forward() {
return "forward:/index.html";
}
// security 인증 확인
@GetMapping("/api/authenticationEntryPoint")
@ResponseBody
public ResponseEntity<String> authenticationEntryPoint() {
return ResponseEntity.ok("ok");
}
}
React Front-end
auth.tsx
import axios from "axios";
import Cookies from "js-cookie";
// 유저정보 가져오기
export const userNameCheck = async (): Promise<string | null> => {
const checkToken = Cookies.get('refreshToken'); // refreshToken 가져오기
try {
const response = await axios.post("http://localhost:7172/api/username", {}, {
withCredentials: true, // 쿠키 포함
params: { checkToken },
headers: {
Authorization: `Bearer ${checkToken}` // JWT 토큰을 헤더에 포함시켜야 합니다.
},
});
return response.data ? response.data : null;
} catch (error) {
console.error('error');
return null; // 오류가 발생하면 null 반환
}
};
// 쿠키에서 token 가져오기
function getCookie(cookieName: string) {
const cookieString = document.cookie;
const cookies = cookieString.split('; '); // '; '로 accessToken 과 refreshToken 을 구분한다
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const [name, value] = cookie.split('='); // '='로 이름과 값을 분리
if (name === cookieName) {
return value;
}
}
return null;
}
// 유효한 token 인지 검증
export const isTokenCheck = () => {
const token = document.cookie;
// token 유무
if (!token) {
return false;
}
try {
const accessToken = getCookie("accessToken");
if (accessToken != null) {
// access token 체크
const accessPayload = JSON.parse(atob(accessToken.split('.')[1]));
const accessExpTime = accessPayload.exp * 1000; // 만료 시간 변수
if (Date.now() < accessExpTime) {// 현재 시간과 만료 시간 비교
return true;
}
}
const refreshToken = getCookie("refreshToken");
if (refreshToken != null) {
// accessToken 은 만료되었으면 재발급 받아야 함
// refresh token 체크
const refreshPayload = JSON.parse(atob(refreshToken.split('.')[1]));
const refreshExpTime = refreshPayload.exp * 1000; // 만료 시간 변수
if (Date.now() < refreshExpTime) {// 현재 시간과 만료 시간 비교
// body 데이터 형식
axios.post("http://localhost:7172/api/newAccessToken", {refreshToken}, {withCredentials: true})
.then((res => {
if (res.data.message === "fail") {
return false;
}
}))
return true;
}
}
} catch (error) {
localStorage.removeItem("jwtToken");
return false;
}
};
privateRoute.tsx
import React, {useState} from 'react';
import {Navigate, RouteProps, useLocation} from 'react-router-dom';
import { useUserContext } from '../utils/UserInfo';
import axios from "axios";
import {isTokenCheck, userNameCheck} from "./auth";
interface PrivateRouteProps extends Omit<RouteProps, 'element'> {
children: React.ReactNode; // children으로 설정
}
// 서버에서 인증 여부 체크하기
const responseCheck = async (): Promise<{ valid: boolean; redirect: string }> => {
try {
const response = await axios.get('http://localhost:7172/api/authenticationEntryPoint', {
withCredentials: true, // 쿠키 포함
});
console.log("response status: " + response.status);
// 인증 성공
return {
valid : response.status !== 401,
redirect : "/"
};
} catch (error: any) {
// 서버쪽에서 인증 오류가 발생하였을때
if (error.response && error.response.status === 401) {
console.log("response status: " + error.response.status);
console.log("response data: " + JSON.stringify(error.response.data));
} else {
console.error("error :", error);
}
// 인증 실패
// response.data.redirect 값이 있다면 넣고 없으면 singin 페이지로
const redirect = error.response.data.redirect || "/signin";
return {valid: false, redirect};
}
};
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
const { setUserInfo } = useUserContext();
const [ serverResponse, setServerResponse ] = useState<{valid: boolean; redirect: string}>({valid: false , redirect: "/signin"});
const [loading, setLoading] = useState(true); // 로딩 상태 추가
const location = useLocation(); // 현재 경로 가져오기
React.useEffect(() => {
const checkAuth = async () => {
try{
// 서버에서 인증되었는지 확인
const valid = await responseCheck();
// 서버 인증이 true 일때
if(valid.valid) {
// 클라이언트 쪽 token 체크
const isTokenValid = isTokenCheck();
// 클라이언트 인증이 true
if(isTokenValid){
setServerResponse({ valid: true, redirect: valid.redirect });
const username = await userNameCheck(); // 비동기적으로 username 가져오기
if (username) {
setUserInfo(username); // 유효한 username이 있으면 전역 상태에 저장
}else{
alert("만료되었습니다");
setServerResponse({ valid: false, redirect: "/signin" });
}
}else {
// 클라이언트 쪽 token 이 유효하지않으면 false
setServerResponse({ valid: false, redirect: "/signin" });
}
}else {
// 서버에서 인증 실패
setServerResponse({ valid: false, redirect: valid.redirect});
}
}catch(error){
setServerResponse({ valid: false, redirect: "/signin" });
}finally {
setLoading(false); // 로딩 상태 종료
}
};
checkAuth(); // 함수 호출
}, [setUserInfo, location]);
if (loading) {
return <div>Loading...</div>; // 로딩 중 표시
}
return serverResponse.valid ? (
<>{children}</> // 인증되었으면 App.tsx 에서 설정한 경로로 Route 를 한다
) : (
<Navigate to={serverResponse.redirect} replace /> // 인증되지 않았으면 로그인 페이지로
);
};
export default PrivateRoute;
privateRoute.tsx 에서 responseCheck 을 실행하여 서버에서 token 을 인증 하고
auth.tsx 의 isTokenCheck 로 통해 클라이언트 쪽에서 token 을 한번 더 인증 한다
로딩을 구현하지 않으면 렌더링 시점으로 인하여
페이지 이동이 제대로 되지 않는다
꼭 구현하자
App.tsx
import {BrowserRouter, Route, Routes, useLocation} from "react-router-dom";
import Header from "./components/layout/header";
import Footer from "./components/layout/footer";
import Sidebar from "./components/layout/sidebar";
import Dashboard from "./components/pages/dashboard";
import Profile from "./components/pages/profile";
import Login from "./components/pages/login";
import PrivateRoute from "./components/utils/privateRoute";
import Register from "./components/pages/register";
import Board from "./components/pages/board";
import BoardWrite from "./components/pages/boardWrite";
import BoardRead from "./components/pages/boardRead";
import {UserProvider} from "./components/utils/UserInfo";
import Chart from "./components/pages/chart";
import Denied from "./components/pages/denied";
// 페이지 layout 구분하기
function AppLayout(){
const location = useLocation();
const layoutPass = ["/signin" , "/register"]; // 로그인 , 회원가입 페이지 제외
const pageCheck = layoutPass.includes(location.pathname);
return (
<div className="App">
{!pageCheck && <Header/>}
{!pageCheck && <Sidebar/>}
<Routes>
<Route path="/signin" element={<Login />}/>
<Route path="/register" element={<Register />}/>
<Route path="/" element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}/>
<Route path="/board" element={
<PrivateRoute>
<Board />
</PrivateRoute>
}/>
<Route path="/boardWrite" element={
<PrivateRoute>
<BoardWrite />
</PrivateRoute>
}/>
<Route path="/boardRead/:no" element={
<PrivateRoute>
<BoardRead />
</PrivateRoute>
}/>
<Route path="/chart" element={
<PrivateRoute>
<Chart />
</PrivateRoute>
}/>
<Route path="/profile" element={
<PrivateRoute>
<Profile />
</PrivateRoute>
}/>
<Route path="/denied" element={
<PrivateRoute>
<Denied />
</PrivateRoute>
}/>
</Routes>
{!pageCheck && <Footer/>}
</div>
);
}
function App() {
return (
// 저장한 유저정보를 사용하기 위해서 감싼다
<UserProvider>
<BrowserRouter>
<AppLayout />
</BrowserRouter>
</UserProvider>
);
}
export default App;
<Route> 로 이동할 경로에
인증을 위해 만들었던 <PrivateRoute> 을 감싸서
페이지 이동시마다 token 을 체크한다
회원가입 및 로그인 시 token 발행은 이전글을 참고하길 바란다
https://greed-yb.tistory.com/307
token 을 이용하여 인증,
권한을 이용한 인가
를 같이 구현하다가
페이지 이동시에는 React 에서 제어를 하다 보니
Security 의 requestMatchers 설정으로 권한체크가 되지 않아
무한 삽집을 반복하다 우선 인증 부분만 작성해 본다
주말 내내 작업하다 원복 시키고 시간도 지나서
빠진 부분이 있을 수도 있지만, 핵심은 다 올렸으니 흐름만 파악하자
Html + Security 에서 할 때는 페이지 이동 및 api 요청을 requestMatchers 설정으로
인증되지 않거나 인가받지 않는 사용자에 대해서는 exceptionHandling 처리를 하였는데
React + Security 에서는 화면과 서버가 따로 동작하니 exceptionHandling 처리를 따로 구현해줘야 했다
그래서 페이지 이동 및 api 요청할 때마다 서버로 인증을 요청을 시키고
클라이언트 쪽에서도 인증을 한번 더 하게 구현하였다
현재 서버에서 인증이 안되었다면 로그인 페이지로 이동이 되며,
서버에서 인증되었으나 클라이언트 쪽에서 인증이 안되면 로그인 페이지로 이동이 되게 하였다
예) 관리자 도구에서 Cookies 를 지운경우 해당
'개발 > Security' 카테고리의 다른 글
[SpringBoot] React + Security + JWT (1) - 한번에 Build 하기 (0) | 2024.11.28 |
---|---|
[SpringBoot] Security + JWT(Access , Refresh) - Controller (0) | 2024.09.25 |
[SpringBoot] Security + JWT(Access , Refresh) - Handler (0) | 2024.09.25 |
[SpringBoot] Security + JWT(Access , Refresh) - JwtAuthenticationFilter (0) | 2024.09.25 |
[SpringBoot] Security + JWT(Access , Refresh) - JwtTokenUtil (0) | 2024.09.25 |
댓글