본문 바로가기
개발/Security

[SpringBoot] React + Security + JWT (2) - 사용자 인증 하기

by 코딩하는 흰둥이 2024. 12. 2.

이전글

https://greed-yb.tistory.com/330

 

[SpringBoot] React + Security + JWT (1) - 한번에 Build 하기

https://greed-yb.tistory.com/303 [SpringBoot] React + TypeScript + JPA 프로젝트 (1) - 생성Java 17GradleSpring Boot 3.3.4Spring Security 6.3.3Oracle 11gIntelliJ UltimateDBeaverReactJPASpringBoot 프로젝트 생성   프로젝트 생성이 끝나

greed-yb.tistory.com

 


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.tsxisTokenCheck 로 통해 클라이언트 쪽에서 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

 

[SpringBoot] React + TypeScript + JPA 프로젝트 (4) - 회원가입, 로그인 하기

이전글https://greed-yb.tistory.com/305 [SpringBoot] React + TypeScript + JPA 프로젝트 (3) - SpringSecurity 적용이전글https://greed-yb.tistory.com/304 [SpringBoot] React + TypeScript + JPA 프로젝트 (2) - Bootstrap 적용이전글https://gr

greed-yb.tistory.com

 

 


 

 

 

 


 

token 을 이용하여 인증,

권한을 이용한 인가

를 같이 구현하다가 

페이지 이동시에는 React 에서 제어를 하다 보니

Security 의 requestMatchers 설정으로 권한체크가 되지 않아

무한 삽집을 반복하다 우선 인증 부분만 작성해 본다

 

주말 내내 작업하다 원복 시키고 시간도 지나서

빠진 부분이 있을 수도 있지만, 핵심은 다 올렸으니 흐름만 파악하자

 

Html + Security 에서 할 때는 페이지 이동 및 api 요청을 requestMatchers 설정으로 

인증되지 않거나 인가받지 않는 사용자에 대해서는 exceptionHandling 처리를 하였는데

React + Security 에서는 화면과 서버가 따로 동작하니  exceptionHandling 처리를 따로 구현해줘야 했다

 

그래서 페이지 이동 및 api 요청할 때마다 서버로 인증을 요청을 시키고

클라이언트 쪽에서도 인증을 한번 더 하게 구현하였다

 

현재 서버에서 인증이 안되었다면 로그인 페이지로 이동이 되며,

서버에서 인증되었으나 클라이언트 쪽에서 인증이 안되면 로그인 페이지로 이동이 되게 하였다

 

예) 관리자 도구에서 Cookies 를 지운경우 해당

 

 

 

댓글