본문 바로가기
개발/Spring

[SpringBoot] React + TypeScript + JPA 프로젝트 (3) - SpringSecurity 적용

by 코딩하는 흰둥이 2024. 10. 31.

이전글

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

 

[SpringBoot] React + TypeScript + JPA 프로젝트 (2) - Bootstrap 적용

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

greed-yb.tistory.com

 

 

 

 

이전글 security 를 참고하여 만들었다

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

 

[SpringBoot] Security + JWT(Access , Refresh) - SecurityConfig

권한에 따른 인증/인가 방식은 이전글을 참고하길 바란다https://greed-yb.tistory.com/223 [SpringBoot] Security 로그인 인증, 인가(1) - 환경설정Java 17MavenSpring Boot 3.0.3Spring Security 6.0.2Oracle 11gMybatisIntelliJ Ultima

greed-yb.tistory.com

참고만 하였을 뿐 많은 부분이 바뀌었으며

교육이나 책을 보고 하는 게 아니라

대부분 구글링 또는 간간이 ChatGPT 이용으로 공부 위주의 프로젝트를 만들고 있다

개발에 정답은 없지만 현재 방법은 개념이 맞지 않거나 코드가 부실할 수도 있으니 주의 바란다

 

JWT 추가하기
// gradle

// 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'

 

 


 

 

security 폴더 구조

 

WebSecurityConfig(Security 설정 클래스)
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;


@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf().disable()
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/resources/**", "/static/**", "/css/**", "/js/**").permitAll() // 정적 리소스 허용
                        .requestMatchers("/api/**").permitAll() //axios 허용
                        .anyRequest().authenticated() // 나머지 요청은 인증 필요
                );
        return http.build();
    }
}

인증/인가 코드를 제외하였다

 

 

 

WebConfig(Cors 적용을 위한 클래스)
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    public void addCorsMappings(CorsRegistry corsRegistry){
        corsRegistry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000") // React 앱의 URL
                .allowedMethods("*")
                .allowCredentials(true);
    }
}

 

 

 

JwTokenUtil
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;
        }
    }
}

 

 

 

TokenVo
import lombok.Data;

@Data
public class TokenVo {
    private String accessToken;
    private String refreshToken;
}

굳이 vo 만들 필요 없이 받아도 된다

 

 

UserVo
import lombok.Data;

@Data
public class UserVo {
    private String username;
    private String password;
}

로그인을 위한 TEST 용

굳이 vo 만들 필요 없이 받아도 된다

 

 

 

LoginController
import com.example.reactpractice.security.JwtTokenUtil;
import com.example.reactpractice.vo.tokenVo.TokenVo;
import com.example.reactpractice.vo.userVo.UserVo;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/*")
public class LoginController {

    private final JwtTokenUtil jwtTokenUtil;

    // jwtTokenUtil 에 설정한 시간과 동일한 시간으로 맞춘다
    private int accessTime = 1 * 60;  // 1분
    private int refreshTime = 5 * 60;  // 5분

    public LoginController(JwtTokenUtil jwtTokenUtil) {
        this.jwtTokenUtil = jwtTokenUtil;
    }

    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> loginCheck(@RequestBody UserVo vo, HttpServletResponse response) {
        // 반환할 데이터 구성
        Map<String, Object> data = new HashMap<>();
        try {

            /////////////////////////////////////////////////////////////////////
            // 세션 기반 인증 및 특정 로직일때 사용함 , 혹시 필요할까 싶어서 작성, 지워도 무방하다
            // 사용자 인증
            Authentication authentication = new UsernamePasswordAuthenticationToken(vo.getUsername(), vo.getPassword());
            // 사용자 정보 set
            SecurityContextHolder.getContext().setAuthentication(authentication);
            /////////////////////////////////////////////////////////////////////


            // 사용자가 입력한 비밀번호와 DB에 암호화 되어 있는 사용자의 비밀번호를 비교하기 위함
            PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            // test 용으로 사용자의 비밀번호가 1111 이라고 가정
            String test = passwordEncoder.encode("1111");

            // 사용자가 입력한 비밀번호와 DB의 비밀번호가 일치하지 않으면 login 페이지로 이동
            if (vo.getPassword() == null || !passwordEncoder.matches(vo.getPassword(), test)) {
                // 인증 실패 시 다시 로그인 페이지로
                data.put("message", "fail");
                return ResponseEntity.ok(data);
            }

            // JWT 토큰 생성
            String accessToken = jwtTokenUtil.generateAccessToken(vo.getUsername());
            String refreshToken = jwtTokenUtil.generateRefreshToken(vo.getUsername());

            // 쿠키에 JWT 저장
            Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(accessTime);
            accessTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
            response.addCookie(accessTokenCookie);

            Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
            refreshTokenCookie.setHttpOnly(true);
            refreshTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
            refreshTokenCookie.setPath("/");
            refreshTokenCookie.setMaxAge(refreshTime);
            refreshTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
            response.addCookie(refreshTokenCookie);

            data.put("accessToken", accessToken);
            data.put("refreshToken", refreshToken);
            data.put("role", "guest"); // test 용 임의 권한 지정
            data.put("message", "success");

        }catch (Exception e){
            e.printStackTrace();
        }
        return ResponseEntity.ok(data);
    }



    @PostMapping("/newAccessToken")
    public ResponseEntity<Map<String, Object>> newAccessToken(HttpServletResponse response, @RequestBody TokenVo vo) throws Exception{
        // vo 사용하지 않을 때
//    public ResponseEntity<Map<String, Object>> newAccessToken(HttpServletResponse response, @RequestParam String refreshToken) throws Exception{
        // 반환할 데이터 구성
        Map<String, Object> data = new HashMap<>();
        // 유효한 refreshToken 인지 확인
        if(jwtTokenUtil.validateRefreshToken(vo.getRefreshToken())){
            // 유저정보 가져와서 새로운 accessToken 발행
            String username = jwtTokenUtil.getUsernameFromRefreshToken(vo.getRefreshToken());
            String newAccess = jwtTokenUtil.generateAccessToken(username);

            // 쿠키에 JWT 저장
            Cookie accessTokenCookie = new Cookie("accessToken", newAccess);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setDomain("localhost"); // localhost에 쿠키 설정
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(accessTime);
            accessTokenCookie.setHttpOnly(false); // javascript 로 cookie 접근 허용(false)
            response.addCookie(accessTokenCookie);
            data.put("message", "success");
            return ResponseEntity.ok(data);
        }else{
            data.put("message", "fail");
            return ResponseEntity.ok(data);
        }

    }

}

 

/login 메서드에서 

PasswordEncoder 을 이용한 encode 와 matches 를 사용한 부분은

사용자가 입력한 ID 와 Password 를 가지고 DB 의 데이터 조회하여 일치하는지 확인하는 부분이다

회원가입 페이지 생성 및 JPA 를 다룰 때 변경할 예정이다

 

 

 

 

 


페이지 이동시 token 을 확인

 

 

 

auth(token 인증)
import axios from "axios";


// 쿠키에서 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;
                        }
                    }))

                // Param 형식으로 데이터 전송 시
                // axios.post("http://localhost:7172/api/newAccessToken",null ,
                //     {
                //       params: {
                //           refreshToken: refreshToken
                //       }
                //     , withCredentials: true })
                //     .then((res => {
                //         if (res.data.message == "fail"){
                //             return false;
                //         }
                //     }))

                return true;
            }
        }
    }catch (error){
        localStorage.removeItem("jwtToken");
        return false;
    }
};

데이터 전송 방식은 각자 편한 방식으로 보내면 된다

 

 

 

 

privateRoute(token 여부에 따른 페이지 이동 router)
import React from 'react';
import { Navigate, RouteProps } from 'react-router-dom';
import {isTokenCheck} from './auth';

interface PrivateRouteProps extends Omit<RouteProps, 'element'> {
    children: React.ReactNode; // children으로 설정
}

const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
    return isTokenCheck() ? (
        <>{children}</> // 인증되었으면 App.tsx 에서 설정한 경로로 Route 를 한다
    ) : (
        <Navigate to="/signin" replace /> // 인증되지 않았으면 로그인 페이지로
    );
};

export default PrivateRoute;

페이지 이동시 마다 token 을 체크한다

 

 

 

 

login
import React, {useState} from "react";
import axios from "axios";

function Login() {

    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');

    const handleLogin = async (e: { preventDefault: () => void; }) => {
        e.preventDefault();
        try {
            const response = await axios.post('http://localhost:7172/api/login', {
                username,
                password,
            }, {withCredentials: true }); // 쿠키를 자동 입력해 준다
            if(response.data.message == "success"){
                // 로그인 성공 후 홈 페이지로 이동
                window.location.href = '/';
            }else {
                console.error('Login failed');
            }
        } catch (error) {
            console.error('Login failed:', error);
        }
    };

    
    
    return(
        <main>
            <div className="container">
                <section
                    className="section register min-vh-100 d-flex flex-column align-items-center justify-content-center py-4">
                    <div className="container">
                        <div className="row justify-content-center">
                            <div
                                className="col-lg-4 col-md-6 d-flex flex-column align-items-center justify-content-center">

                                <div className="d-flex justify-content-center py-4">
                                    <a href="index.html" className="logo d-flex align-items-center w-auto">
                                        <img src="assets/img/logo.png" alt=""/>
                                            <span className="d-none d-lg-block">NiceAdmin</span>
                                    </a>
                                </div>

                                <div className="card mb-3">

                                    <div className="card-body">

                                        <div className="pt-4 pb-2">
                                            <h5 className="card-title text-center pb-0 fs-4">Login to Your Account</h5>
                                            <p className="text-center small">Enter your username & password to login</p>
                                        </div>

                                        <form className="row g-3 needs-validation" noValidate onSubmit={handleLogin}>

                                            <div className="col-12">
                                                <label htmlFor="username" className="form-label">Username</label>
                                                <div className="input-group has-validation">
                                                    <span className="input-group-text" id="inputGroupPrepend">@</span>
                                                    <input type="text" name="username" className="form-control"
                                                           id="username" required onChange={(e) => setUsername(e.target.value) }/>
                                                        <div className="invalid-feedback">Please enter your username.
                                                        </div>
                                                </div>
                                            </div>

                                            <div className="col-12">
                                                <label htmlFor="password" className="form-label">Password</label>
                                                <input type="password" name="password" className="form-control"
                                                       id="password" required onChange={(e) => setPassword(e.target.value)}/>
                                                    <div className="invalid-feedback">Please enter your password!</div>
                                            </div>

                                            <div className="col-12">
                                                <div className="form-check">
                                                    <input className="form-check-input" type="checkbox" name="remember"
                                                           value="true" id="rememberMe"/>
                                                        <label className="form-check-label" htmlFor="rememberMe">Remember
                                                            me</label>
                                                </div>
                                            </div>
                                            <div className="col-12">
                                                <button className="btn btn-primary w-100" type="submit">Login</button>
                                            </div>
                                            <div className="col-12">
                                                <p className="small mb-0">Don't have account? 
                                                <a href="pages-register.html">Create an account</a></p>
                                            </div>
                                        </form>

                                    </div>
                                </div>

                                <div className="credits">
                                    Designed by <a href="https://bootstrapmade.com/">BootstrapMade</a>
                                </div>

                            </div>
                        </div>
                    </div>

                </section>

            </div>
        </main>
);
}

export default Login;

login 페이지는 Bootstrap 의 login 페이지를 가져와
<form> 태그에 onSubmit

username, password <input> 태그에 onChange 를 추가하였다

 

 

 

App
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";

// 페이지 layout 구분하기
function AppLayout(){
    const location = useLocation();
    const pageCheck = location.pathname === "/signin";

    return (
            <div className="App">
                {!pageCheck && <Header/>}
                {!pageCheck && <Sidebar/>}
                <Routes>
                    <Route path="/signin" element={<Login />}/>
                    <Route path="/" element={
                    <PrivateRoute>
                        <Dashboard />
                    </PrivateRoute>
                    }/>
                    <Route path="/profile" element={
                    <PrivateRoute>
                        <Profile />
                    </PrivateRoute>
                    }/>
                </Routes>
                {!pageCheck && <Footer/>}
            </div>
    );
}


function App() {
    return (
        <BrowserRouter>
          <AppLayout/>
        </BrowserRouter>
    );
}

export default App;

 

login 페이지는 header , sidebar , footer 가 나오면 안 되기 때문에

layout 을 분리시켜 준다

login.tsx 를 Router path="/signin" 으로 설정한 이유는

/login 으로 지정하면 SpringSecurity 에서 경로를 못 찾을 수도 있다고 하여 구분을 하였다

 

 


 

 

 

 

TEST

React 와 Spring 을 실행시킨다

localhost:3000 으로 접속되면서 token 이 없기 때문에 로그인 페이지 /signin 으로 이동되었다

 

username 에 아무 값을 넣고

Password 에 Test 용으로 하드코딩한 1111 을 입력하고 Login 버튼을 클릭한다

 

 

 

 

 

token 이 발행되어 localhost:3000 으로 접속되었고

Cookies 에 accessTokenrefreshToken 이 들어왔다

 

현재 Test 로 accessToken - 1분 , refreshToken - 5분으로 설정했기 때문에

1분이 지나면 accessToken 은 사라지며 다른 페이지 이동 및 기능을 동작하게 되면

refreshToken 이 유효한지에 따라서 accessToken 을 재발행하거나 login 페이지로 이동하게 된다

 

 

 

 

 

1분이 지나서 accessToken 이 사라졌다

Cookies 페이지를 열고 있는 상태라면

다른 탭을 클릭했다가 오면 사라져 있는 것을 볼 수 있다

 

 

 

 

 

다른 페이지 이동 및 기능을 동작하게 되면 refreshToken 이 유효한지 체크하여

Spring console

 

새로운 accessToken 을 발행하여 Cookies 에 저장한다

 

 

 

 

 

 

 

 

 

 

5분이 지나서 refreshToken 마저도 삭제 되었다

 

 

다른 페이지 이동 및 기능을 동작하게 되면 token 이 없기 때문에 login 페이지로 이동된다

 

 

 

 

 


참고 index.js 또는 index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

//     // StrictMode : 개발 환경에서 컴포넌트의 렌더링이 2번씩 실행된다 - 예) 메소드가 두번씩 실행됨
//     // 실제 운영 환경에서는 한번만 실행된다고 함
// root.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>
// );

// StrictMode 사용하지 않을 경우
root.render(<App />);


reportWebVitals();

 

axios 를 사용해서 데이터를 확인하던 중

동일한 메서드 두 번씩 동작하는 것을 확인하였고

render 할 때 StrictMode 설정 때문이라는 것을 알게 되었다

참고하길 바란다

 


 

 


React 에 대한 기본지식도 없는 상태로 

맨땅에 헤딩을 하면서 Security 까지 적용하려고 하니 머리가 터질 것 같다...

 

지금도 정상적으로 구현한 느낌은 아니다

아마 실제로도 그렇겠지...?

 

프로젝트를 계속 진행하며 점차 수정을 할 것이고,

React + Spring 을 한 번에 build 하게 되면 다시 변경해야 할 것으로 보인다

 

현재 cookie.setHttpOnly(false) 를 설정해서

javascript 로 document.cookie 명령어가 동작하고,

이걸로 Token 을 가져오고 있는데 보안적으로 좋아 보이지는 않는다

근데 막상 큰 사이트들 들어가서 확인해 보면 다들 명령어가 동작하는 것으로 봐서는.....음.... 

 

 

 

댓글