Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장

✒️ 2025-05-28 14:25 내용 수정



JWT 저장

참고 자료 : 프론트에서 안전하게 로그인 처리하기(ft.React), JWT를 안전하게 보관하기1 (accessToken을 로컬변수에 저장, refreshToken을 쿠키에 저장)

Token 전달 과정

  1. 클라이언트가 로그인 요청을 보낸다.
  2. 서버에서 요청을 받아 로그인이 정상적으로 처리되면 Access Token은 ResponseBody에, Refresh Token은 httpOnlysecure=true가 설정된 cookie에 담아 보낸다.
  3. 클라이언트는 응답을 받아 로컬 변수에 Access Token을 저장하고, Refresh Token은 cookie에 저장된다.
    • httpOnly로 설정 시 Javascript 내에서 접근할 수 없다.
  4. 클라이언트가 서버로 자원을 요청할 때 Authorization Header에 Access Token을 넣어 함께 전송한다.
  5. 서버는 JwtAuthenticationFilter에서 요청의 Authorization Header에 있는 Access Token의 유효성을 검사하고, 유효성 검사가 통과 되면 Dispatcher Servlet에서 요청에 맞는 Controller를 매칭시켜 응답을 반한다.
  6. 만약 Access Token이 유효하지 않다면 요청을 거부하고, 클라이언트는 cookie에 저장된 Refresh Token을 전달하여 Access Token 재발급 요청을 전송한다.
  7. 서버는 요청과 함께 온 cookie의 Refresh Token을 DB에 저장된 Refresh Token과 비교하여 유효성 검사를 진행하고, 유효하다면 새로운 Access Token을 ResponseBody에, Refresh Token은 cookie에 담아 전송한다.
  8. 클라이언트 요청의 Refresh Token이 만료되었거나 찾을 수 없는 Token이라면 로그인 절차를 다시 수행한다.

로그인 유지


클라이언트(React) 설정

App.js과 index.js 설정

  1. App.js : React-Router 설정을 위한 RoutesRoute Component만 추가하였다.
    • 로그인과 회원가입 둘 다 Access Token과 Refresh Token을 응답으로 받기에 둘 다 추가하였다.
    • 로그인 전 후의 서버 자원 접근 확인을 위한 Demo 페이지를 생성하여 데이터 요청을 비교할 예정이다.
import './App.css';
import { Route, Routes } from 'react-router-dom';
import Login from './Login';
import Register from './Register';
import Demo from './Demo';

function App() {
  return (
    <div className="App">
      <Routes>
        <Route path='/login' Component={Login}></Route>
        <Route path='/register' Component={Register}></Route>
        <Route path='/demo' Component={Demo}/>
      </Routes>
    </div>
  ); 
}

export default App;
  1. index.js : React-Router 사용을 위한 BrowserRouter Component를 추가하고, axios의 기본 URL과 쿠키 사용을 위한 withCredentials=true 설정을 적용한다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
import axios from 'axios';

const root = ReactDOM.createRoot(document.getElementById('root'));
axios.defaults.baseURL="http://localhost:9000/api/v1";
axios.defaults.withCredentials="true";
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

reportWebVitals();

CORS를 위한 Proxy 설정

npm install http-proxy-middleware --save
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
    app.use(
        '/api', // proxy가 필요한 요청 경로, /api/test와 같이 /api로 시작하는 요청들 포함
        createProxyMiddleware({
            target: `http://localhost:9000/`, // 타겟이 되는 api url
            changeOrigin: true, // 대상 서버 구성에 따라 host header 변경 설정
        })
    );
};

회원가입 페이지 설정

import axios from 'axios';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

function Register() {
    
    // 회원가입 form
    const [formData, setFormData] = useState({ 
        firstname: '',
        lastname: '',
        email: '', 
        password: ''
    });
    const navigate = useNavigate();

    // bootstrap 유효성 검사 및 제출
    const handleSubmit = async (event) => {
        event.preventDefault();
        
            try {
                // 전송 
                const res = await axios.post('/auth/register', formData);

                if (res.status === 200) {
                    // 응답에서 Access Token 가져와 로컬 변수에 저장
                    const {access_token} = res.data;
                    
                    // Access Token을 axios의 header의 Authorization Bearer Schema에 적용
                    axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;

                    alert("가입 성공!");

                    if (!window.confirm("로그인으로 이동할까요?")) {
                        return;
                    }
                    navigate("/login");
                } else {
                    alert('회원가입 실패!');
                }
            } catch (error) {
            }
    };

    // form 데이터 등록
    function handleChange(event) {
        const { name, value } = event.currentTarget;
        setFormData((prev) => ({
            ...prev,
            [name] : value,
        }));
    }

    return(
        <div>
            <h2>회원가입</h2>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor='firstname'>Firstname : </label>
                    <input
                        type="text"
                        name="firstname"
                        id="firstname"
                        onChange={handleChange}
                    />  
                </div>
                <div>
                    <label htmlFor='lastname'>Lastname : </label>
                    <input
                        type="text"
                        name="lastname"
                        id="lastname"
                        onChange={handleChange}
                    />
                </div>
                <div>
                    <label htmlFor='email'>Email : </label>
                    <input
                        type="email"
                        name="email"
                        id="email"
                        onChange={handleChange}
                    />
                </div>
                <div>
                    <label htmlFor='password'>Password : </label>
                    <input
                        type="password"
                        name="password"
                        id="password"
                        onChange={handleChange}
                        />
                </div>
                <button type='submit' onClick={handleSubmit}>가입</button>
            </form>
        </div>
    )
}

export default Register;

로그인 페이지 설정

import axios from 'axios';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

function Login() {
    
    // 로그인 form
    const [formData, setFormData] = useState({ email: '', password: ''});
    const navigate = useNavigate();

    // bootstrap 유효성 검사 및 제출
    const handleSubmit = async (event) => {
        event.preventDefault();
        
            try {
                // 전송 
                const res = await axios.post('/auth/authenticate', formData);

                if (res.status === 200) {
                    // 응답에서 Access Token 가져와 로컬 변수에 저장
                    const {access_token} = res.data;

                    // Access Token을 axios의 header의 Authorization Bearer Schema에 적용
                    axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;

                    navigate("/demo");
                } else {
                    alert("로그인 실패")
                }
            } catch (error) {
            }
    };

    // form 데이터 등록
    function handleChange(event) {
        const { name, value } = event.currentTarget;
        setFormData((prev) => ({
            ...prev,
            [name] : value,
        }));
    }

    return(
        <div>
            <h2>로그인</h2>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor='email'>Email : </label>
                    <input
                        type="email"
                        name="email"
                        id="email"
                        onChange={handleChange}
                    />
                </div>
                <div>
                    <label htmlFor='password'>Password : </label>
                    <input
                        type="password"
                        name="password"
                        id="password"
                        onChange={handleChange}
                        />
                </div>
                <button type='submit' onClick={handleSubmit}>로그인</button>
            </form>
        </div>
    )
}

export default Login;

Demo 페이지 설정

import axios from "axios";
import { useEffect, useState } from "react";

function Demo() {

    const [hello, setHello] = useState('');

    const getResource = async () => {
        try {
            // 자원 요청
            const res = await axios.get('/demo-controller');

			// 데이터가 있다면 데이터를 hello에 설정
            if (res.data !== null) {
                setHello(res.data);
            } else {
	            // 없다면 인가 안됨 표시
                setHello("Not Authorized");
            }
        } catch (error) {
        }
    }

	// 최초 렌더링 때만 자원 요청 함수 실행
    useEffect(()=>{
        getResource();
    }, []);

    return(
        <div>
            <h2>Demo Page</h2>
            <p>{hello}</p>
        </div>
    )
}

export default Demo;

서버(Spring boot) 설정

Controller 수정

package com.example.security.auth;  
  
import jakarta.servlet.http.Cookie;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.servlet.http.HttpServletResponse;  
import lombok.RequiredArgsConstructor;  
import org.springframework.http.ResponseEntity;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RestController;  
  
@RestController  
@RequestMapping("/api/v1/auth")  
@RequiredArgsConstructor  
public class AuthenticationController {  
  
    private final AuthenticationService service;  
  
    // 회원가입  
    @PostMapping("/register")  
    public ResponseEntity<AuthenticationResponse> register (  
      @RequestBody RegisterRequest request  
    ) {  
        return service.register(request);  
    }  
  
    // 인증  
    @PostMapping("/authenticate")  
    public ResponseEntity<AuthenticationResponse> authenticate (  
            @RequestBody AuthenticationRequest request  
    ) {  
        return service.authenticate(request);  
    }  
  
    // 재발급  
    @PostMapping("/refresh-token")  
    public ResponseEntity<AuthenticationResponse> authenticate (  
        HttpServletRequest request  
    ) {  
        return service.refreshToken(request);  
    }  
  
}

AuthenticationService 수정

  1. Service 클래스에 cookie 설정을 위한 메소드를 추가하여 Refresh Token을 생성하면 이를 cookie에 담아 Header에 설정한다.
    • cookie는 org.springframework.http.ResponseCookie 클래스를 사용하였는데, 이유는 sameSite 설정을 추가할 수 있기 때문이다.
    • Servlet과 Spring의 Cookie 생성를 참고하여 현재 진행하는 프로젝트에 맞는 클래스를 사용하면 된다.
import org.springframework.http.ResponseCookie;
import org.springframework.http.HttpHeaders;

// cookie 설정  
public HttpHeaders setCookieHeader(String refreshToken) {  
	// Cookie 생성  
	ResponseCookie cookie = ResponseCookie.from("refresh-token", refreshToken)  
			.path("/") // cookie가 전송될 경로 설정  
			.httpOnly(true) // 클라이언트에서 javascript로 접근 불가  
			.secure(true) // https 적용  
			.sameSite("Strict") // sameSite 적용  
			.build();  

	// Set-Cookie로 Header에 Cookie 추가  
	HttpHeaders headers = new HttpHeaders();
	// header에 생성한 cookie 추가
	headers.add(HttpHeaders.SET_COOKIE, cookie.toString());  

	// header를 반환하여 ResponseEntity 생성자에 header를 설정
	return headers;  
}  
  1. register(), authenticate(), refreshToken() 메소드에 cookie 생성 메소드를 추가하고, 반환형을 ResponseEntity<AuthenticationResponse>로 수정한다.
  2. AuthenticationResponse에는 Access Token만 담고, Refresh Token은 담지 않도록 수정한다.
  3. 마지막으로 반환 값은 new ResponseEntity<>(response, header, HttpStatus.OK) 형태로 수정하여 Body에는 Access Token을, cookie 정보를 담은 Header에 Refresh Token을 담도록 설정한다.
package com.example.security.auth;  
  
import com.example.security.config.JwtService;  
import com.example.security.user.Role;  
import com.example.security.user.User;  
import com.example.security.user.UserRepository;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.transaction.Transactional;  
import lombok.RequiredArgsConstructor;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.http.HttpHeaders;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.ResponseCookie;  
import org.springframework.http.ResponseEntity;  
import org.springframework.security.authentication.AuthenticationManager;  
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;  
import org.springframework.security.core.userdetails.UsernameNotFoundException;  
import org.springframework.security.crypto.password.PasswordEncoder;  
import org.springframework.stereotype.Service; 
  
@Service  
@RequiredArgsConstructor  
@Slf4j  
public class AuthenticationService {  
  
    // DB와 상호작용하는 사용자 repo    
    private final UserRepository repository;  
    // 비밀번호 인코더  
    private final PasswordEncoder passwordEncoder;  
    // jwt 서비스  
    private final JwtService jwtService;  
    // 사용자 신원 확인  
    private final AuthenticationManager authenticationManager;  
  
    // 회원가입  
    @Transactional  
    public ResponseEntity<AuthenticationResponse> register(RegisterRequest request) {  
        // 요청으로부터 온 데이터로 사용자 객체 생성  
        var user = User.builder()  
                .firstname(request.getFirstname())  
                .lastname(request.getLastname())  
                .email(request.getEmail())  
                .password(passwordEncoder.encode(request.getPassword()))  
                .role(Role.USER)  
                .build();  
  
        // 사용자 저장  
        repository.save(user);  
  
        // 토큰 생성 - 사용자 정보로 생성  
        var accessToken = jwtService.generateAccessToken(user);  
        var refreshToken = jwtService.generateRefreshToken(user);  
  
        // 토큰을 db에 저장  
        jwtService.saveUserToken(refreshToken, user);  
  
        // cookie 생성  
        HttpHeaders header = setCookieHeader(refreshToken);  
  
        // 인증 응답 객체 생성  
        AuthenticationResponse response = AuthenticationResponse.builder()  
                .accessToken(accessToken)  
                .build();  
  
        return new ResponseEntity<>(response, header, HttpStatus.OK);  
    }  
  
    // 인증 확인  
    @Transactional  
    public ResponseEntity<AuthenticationResponse> authenticate(  
            AuthenticationRequest request  
    ) {  
        // 요청으로 들어온 사용자의 신원 확인  
        authenticationManager.authenticate(  
                new UsernamePasswordAuthenticationToken(  
                        request.getEmail(),  
                        request.getPassword()  
                )  
        );  
        // 위의 인증을 거친 사용자를 DB에 검색  
        var user = repository.findByEmail(request.getEmail())  
                .orElseThrow();  
  
        // 토큰 생성 - 사용자 정보로 생성  
        var accessToken = jwtService.generateAccessToken(user);  
        var refreshToken = jwtService.generateRefreshToken(user);  
  
        // 기존에 db에 저장된 사용자의 모든 Refresh Token 제거  
        jwtService.removeAllUserToken(user);  
  
        // 토큰을 db에 저장  
        jwtService.saveUserToken(refreshToken, user);  
  
        // cookie 생성  
        HttpHeaders header = setCookieHeader(refreshToken);  
  
        // 인증 응답 객체 생성  
        AuthenticationResponse response = AuthenticationResponse.builder()  
                .accessToken(accessToken)  
                .build();  
  
        return new ResponseEntity<>(response, header, HttpStatus.OK);  
    }  
  
    // Access Token 재발급  
    @Transactional  
    public ResponseEntity<AuthenticationResponse> refreshToken(  
            HttpServletRequest request  
            ) {  
        // request의 authorization header에서 token 추출  
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);  
  
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {  
            return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);  
        }  
  
        // Refresh Token 추출  
        String token = authHeader.substring(7);  
        // jwt로부터 사용자 이메일을 추출  
        String userEmail = jwtService.extractUsername(token);  
  
        // 검증 절차  
        // 사용자 존재 여부  
        User user = repository.findByEmail(userEmail)  
                .orElseThrow(()->new UsernameNotFoundException("No user found"));  
  
        // Refresh Token 유효성 검사  
        if (jwtService.isRefreshTokenValid(token, user)) {  
            // 유효할 경우 재발급 진행  
            String accessToken = jwtService.generateAccessToken(user);  
            String refreshToken = jwtService.generateRefreshToken(user);  
  
            // 기존에 db에 저장된 Refresh Token 제거  
            jwtService.removeUserToken(token, user);  
  
            // 토큰을 db에 저장  
            jwtService.saveUserToken(refreshToken, user);  
  
            // cookie 생성  
            HttpHeaders header = setCookieHeader(refreshToken);  
  
            // 인증 응답 객체 생성  
            AuthenticationResponse response = AuthenticationResponse.builder()  
                    .accessToken(accessToken)  
                    .build();  
  
            return new ResponseEntity<>(response, header, HttpStatus.OK);  
        }  
  
        return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);  
    }  
  

}

Test 1

  1. 애플리케이션과 클라이언트를 실행한 후 브라우저에서 http://localhost:3000/demo로 접근한다.
  2. 개발자 도구에서 Network를 보면 /api/v1/demo-controller 요청에 대한 응답이 403 Forbidden으로 뜬다.
    • Spring Security 설정으로 Filter에서 요청의 Authorization Bearer 항목이 없는 것을 확인하고 403 응답을 전송하였다.

spring_security_react_jwt_client 1.png

  1. Application의 Cookies 항목에도 아무 cookie가 존재하지 않는 것을 확인할 수 있다.

spring_security_react_jwt_client 2.png

  1. 이번엔 http://localhost:3000/register에 접속하여 가입 절차를 진행하고, 가입 요청을 보낸다.
  2. 개발자 도구의 Network에서 응답을 분석해보면 응답이 200으로 왔으며, Response Headers에서 Set-Cookie 항목에 refresh-token이 있는 것을 확인할 수 있다.

spring_security_react_jwt_client 3.png

spring_security_react_jwt_client 4.png

spring_security_react_jwt_client 5.png

  1. 로그인을 수행한 뒤 로그인 응답을 보면 Reponse Headers에 Set-Cookie로 Refresh Token이 있는 것을 확인할 수 있다.

spring_security_react_jwt_client 6.png

spring_security_react_jwt_client 7.png

spring_security_react_jwt_client 8.png

  1. 이미 위의 사진들에서 "Hello from secured endpoint" 문구가 나온 것을 볼 수 있는데, Network에서 자원 요청에 대한 응답을 보면 200 상태로 되어 있다.

spring_security_react_jwt_client 9.png


재발급 요청

클라이언트 수정 - Demo 페이지

  1. 먼저 새 Token 발급 요청을 보내는 refreshToken() 메소드에서 /auth/refresh-token으로 요청을 전송하고, 응답의 Body에 있는 Access Token을 추출하여 axios.defaults.headers.common['Authorization']에 넣어 이후 요청에 사용할 수 있도록 한다.
    • 만약 Refresh Token이 없거나 Refresh Token도 만료되어 재발급을 실패했다면 로그인 페이지로 이동하도록 설정한다.
// token 재발급
const refreshToken = async () => {
	try {
		const res = await axios.post('/auth/refresh-token');
		// 응답에서 Access Token 가져와 로컬 변수에 저장
		const {access_token} = res.data;
		
		// Access Token을 axios의 header의 Authorization Bearer Schema에 적용
		axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
	} catch (error) {
		alert("로그인을 다시 해주세요");
		navigate('/login');
	}
}
  1. 이번엔 Authorization Header의 유무에 따라 자원을 재 요청하는 메소드이다.
    • fetchWithRetry()는 코드 중복 및 관리를 위해 중복 요청을 따로 설정한 메소드로, 최초 요청을 보낸 후에 응답이 제대로 왔다면 응답의 body를 반환한다.
    • 응답에 에러가 발생했다면 401이나 403인 경우에 refreshToken()을 호출하여 Token을 재발급 받고, 다시 자원 요청을 보내 결과에 따라 응답의 body를 반환하거나 에러를 던진다.
    • 자원 요청 메소드 getResource()에선 /demo-controllerfetchWithRetry() 메소드를 사용하여 요청을 보내고, 결과에 따라 화면에 표시할 데이터를 설정한다.
// 결과에 따라 재요청을 처리
const fetchWithRetry = async (url, options = {}) => {
	try {
		const res = await axios.get(url, options);
		return res.data;
	} catch (error) {
		// 응답이 401이나 403이면 token이 없는 상태
		if (error.response && 
			(error.response.status === 401 
			|| error.response.status === 403)) {
			// token 재발급
			await refreshToken();
			// 다시 자원 요청
			const res = await axios.get(url, options);
			return res.data;
		} else {
			throw error;
		}
	}
}

const getResource = async () => {
	// 자원 요청
	try {
		const data = await fetchWithRetry('/demo-controller');
		setHello(data);
	} catch (error) {
		setHello("Not Authorized");
	}
}
import axios from "axios";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

function Demo() {

    const [hello, setHello] = useState('');
    const navigate = useNavigate();

	// token 재발급
    const refreshToken = async () => {
        try {
            const res = await axios.post('/auth/refresh-token');
            // 응답에서 Access Token 가져와 로컬 변수에 저장
            const {access_token} = res.data;
            
            // Access Token을 axios의 header의 Authorization Bearer Schema에 적용
            axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
        } catch (error) {
            alert("로그인을 다시 해주세요");
            navigate('/login');
        }
    }

    // 결과에 따라 재요청을 처리
    const fetchWithRetry = async (url, options = {}) => {
        try {
            const res = await axios.get(url, options);
            return res.data;
        } catch (error) {
            // 응답이 401이나 403이면 token이 없는 상태
            if (error.response && 
	            (error.response.status === 401 
	            || error.response.status === 403)) {
                // token 재발급
                await refreshToken();
                // 다시 자원 요청
                const res = await axios.get(url, options);
                return res.data;
            } else {
                throw error;
            }
        }
    }

    const getResource = async () => {
        // 자원 요청
        try {
            const data = await fetchWithRetry('/demo-controller');
            setHello(data);
        } catch (error) {
            setHello("Not Authorized");
        }
    }

	// 최초 렌더링 때만 자원 요청 함수 실행
    useEffect(()=>{
        getResource();
    }, []);

    return(
        <div>
            <h2>Demo Page</h2>
            <p>{hello}</p>
        </div>
    )
}

export default Demo;

서버 수정 - AuthenticationService

package com.example.security.auth;  
  
import com.example.security.config.JwtService;  
import com.example.security.user.Role;  
import com.example.security.user.User;  
import com.example.security.user.UserRepository;  
import jakarta.servlet.http.Cookie;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.transaction.Transactional;  
import lombok.RequiredArgsConstructor;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.http.HttpHeaders;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.ResponseCookie;  
import org.springframework.http.ResponseEntity;  
import org.springframework.security.authentication.AuthenticationManager;  
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;  
import org.springframework.security.core.userdetails.UsernameNotFoundException;  
import org.springframework.security.crypto.password.PasswordEncoder;  
import org.springframework.stereotype.Service;  
  
@Service  
@RequiredArgsConstructor  
@Slf4j  
public class AuthenticationService {  
  
    // DB와 상호작용하는 사용자 repo    
    private final UserRepository repository;  
    // 비밀번호 인코더  
    private final PasswordEncoder passwordEncoder;  
    // jwt 서비스  
    private final JwtService jwtService;  
    // 사용자 신원 확인  
    private final AuthenticationManager authenticationManager;  
  
	// ...

    // Access Token 재발급  
    @Transactional  
    public ResponseEntity<AuthenticationResponse> refreshToken(  
            HttpServletRequest request  
            ) {  
        // cookie 가져오기  
        Cookie[] cookies = request.getCookies();  
  
        // cookie가 없다면 인증 실패 응답 반환  
        if (cookies == null) {  
            return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);  
        }  
  
        // Refresh Token 저장 인스턴스  
        String token = null;  

		// cookie 들 중에서 이름이 
		// refresh-token인 cookie의 값 가져오기
        for (Cookie cookie : cookies) {  
            if ("refresh-token".equals(cookie.getName())) {  
                token = cookie.getValue();  
                break;  
            }  
        }  
  
        // Refresh token이 cookie에 없다면 인증 실패 응답 반환  
        if (token == null) {  
            return new ResponseEntity<>(null, HttpStatus.UNAUTHORIZED);  
        }  
  
        // jwt로부터 사용자 이메일을 추출  
        String userEmail = jwtService.extractUsername(token);  
  
        // 검증 절차  
		
		// ... 생략
    }  
  
    // cookie 설정  
    public HttpHeaders setCookieHeader(String refreshToken) {  
        // Cookie 생성  
        ResponseCookie cookie = ResponseCookie.from("refresh-token", refreshToken)  
                .path("/") // cookie가 전송될 경로 설정  
                .httpOnly(true) // 클라이언트에서 javascript로 접근 불가  
                .secure(true) // https 적용  
                .sameSite("Strict") // sameSite 적용  
                .build();  
  
        // Set-Cookie로 Header에 Cookie 추가  
        HttpHeaders headers = new HttpHeaders();  
        headers.add(HttpHeaders.SET_COOKIE, cookie.toString());  
  
        return headers;  
    }  
}

Test 2

  1. 애플리케이션을 실행하고 http://localhost:3000/demo로 접속한다.
  2. 최초 실행시엔 요청에는 Authorization Header가 없고, Refresh Token도 저장되어 있지 않아 로그인 페이지로 이동하라는 알림이 뜬다.

spring_security_react_jwt_client 10.png

  1. 회원가입 및 로그인 절차를 끝내면 /demo-controller 요청의 응답이 제대로 나와 "Hello from secured endpoint" 문구가 뜬다.

spring_security_react_jwt_client 11.png

  1. 이 상태로 새로고침을 하면 Network에서 초기 /demo-controller 요청은 Authorization Header가 없어 403이 뜨지만, /refresh-token 요청을 보내 Access Token을 재발급 받고, 이를 Authorization Header에 넣어 /demo-controller 요청을 보내 응답이 제대로 온 것을 확인할 수 있다.

spring_security_react_jwt_client 12.png
spring_security_react_jwt_client 13.png


  1. 먼저 클라이언트의 index.js에서 axios.defaults.withCredentials="true";를 제거한다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
import axios from 'axios';

const root = ReactDOM.createRoot(document.getElementById('root'));
axios.defaults.baseURL="http://localhost:9000/api/v1";
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
reportWebVitals();
  1. Login.jsDemo.js에서 로그인 및 재발급 요청을 보낼 때 withCredentials:true를 적용한다.
    • Login.js에서 withCredentials:true가 적용이 안되어 있다면 로그인 후에 받는 cookie가 저장되지 않는다.
import axios from 'axios';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

function Login() {
    
    // 로그인 form
    const [formData, setFormData] = useState({ email: '', password: ''});
    const navigate = useNavigate();

    // bootstrap 유효성 검사 및 제출
    const handleSubmit = async (event) => {
        event.preventDefault();
        
            try {
                // 전송 
                const res = await axios.post(
	                '/auth/authenticate', 
	                formData, 
	                {withCredentials: true}
                );

			// ... 생략
            } catch (error) {
            }
    };

	// ... 생략
    return()
}

export default Login;
import axios from "axios";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

function Demo() {

    const [hello, setHello] = useState('');
    const navigate = useNavigate();

    const refreshToken = async () => {
        try {
            const res = await axios.post(
	            '/auth/refresh-token', 
	            {}, 
	            {withCredentials: true}
            );
            // 응답에서 Access Token 가져와 로컬 변수에 저장
            const {access_token} = res.data;
            
            // Access Token을 axios의 header의 Authorization Bearer Schema에 적용
            axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
        } catch (error) {
            alert("로그인을 다시 해주세요");
            navigate('/login');
        }
    }

	// ...
	
    return()
}

export default Demo;

Test 3

  1. 애플리케이션을 실행하고 회원가입과 로그인을 진행한다.
  2. 회원가입 후 받은 Access Token을 Authorization Header에 담아두기에 로그인 요청을 보낼 때는 Authorization Header가 있다.
    • 하지만 /authenticate 요청 Header를 보면 이전과 달리 cookie는 존재하지 않는다.

spring_security_react_jwt_client 14.png

  1. 이번에 /demo-controller 요청 Header를 보면 로그인 요청으로 받은 Access Token을 Authorization Header에 담아 보냈으며, 마찬가지로 cookie는 보이지 않는다.

spring_security_react_jwt_client 15.png

  1. 새로고침을 수행하고 /refresh-token의 요청 header를 보면 cookie가 있는 것을 확인할 수 있으며, 클라이언트에서 cookie를 전달했기에 새 Access Token을 발급 받았음을 확인할 수 있다.

spring_security_react_jwt_client 16.png