Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장
✒️ 2025-05-28 14:25 내용 수정
- code : https://github.com/ase10git/SpringSecurityTest
- SpringSecurity 프로젝트 설정 목록
- Spring Security 기본 사용자 추가 및 테스트
- Spring Security 프로젝트 설정 1 - DB연결과 JPA 설정
- Spring Security 프로젝트 설정 2 - JwtService와 Filter 설정
- Spring Security 프로젝트 설정 3 - Security Config
- Spring Security 프로젝트 설정 4 - Authentication Service와 Controller
- Spring Security 프로젝트 설정 5 - Security CORS 설정
- Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장
- Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급
- Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장
- Spring Security 프로젝트 설정 9 - JWT 로그아웃
- Spring Security 프로젝트 설정 10 - 권한 설정
JWT 저장
참고 자료 : 프론트에서 안전하게 로그인 처리하기(ft.React), JWT를 안전하게 보관하기1 (accessToken을 로컬변수에 저장, refreshToken을 쿠키에 저장)
- JWT Access Token과 Refresh Token#Token 저장과 보안 문제에서 Access Token은 클라이언트 내의 로컬 변수에 저장하고, Refresh Token은
httpOnly,secure=true,SameSite=strict혹은SameSite=Lax로 설정된 cookie에 저장하여 XSS 공격과 CSRF 공격을 방지할 수 있다고 정리했다. - 이전 프로젝트 과정을 통해 JWT Access Token과 Refresh Token을 발급 및 재발급 하였고, 이번 과정에선 클라이언트에 각 Token을 전송한 뒤 저장하는 과정을 정리하였다.
Token 전달 과정
- 클라이언트가 로그인 요청을 보낸다.
- 서버에서 요청을 받아 로그인이 정상적으로 처리되면 Access Token은
ResponseBody에, Refresh Token은httpOnly와secure=true가 설정된 cookie에 담아 보낸다. - 클라이언트는 응답을 받아 로컬 변수에 Access Token을 저장하고, Refresh Token은 cookie에 저장된다.
httpOnly로 설정 시 Javascript 내에서 접근할 수 없다.
- 클라이언트가 서버로 자원을 요청할 때 Authorization Header에 Access Token을 넣어 함께 전송한다.
- 서버는
JwtAuthenticationFilter에서 요청의 Authorization Header에 있는 Access Token의 유효성을 검사하고, 유효성 검사가 통과 되면 Dispatcher Servlet에서 요청에 맞는 Controller를 매칭시켜 응답을 반한다. - 만약 Access Token이 유효하지 않다면 요청을 거부하고, 클라이언트는 cookie에 저장된 Refresh Token을 전달하여 Access Token 재발급 요청을 전송한다.
- 서버는 요청과 함께 온 cookie의 Refresh Token을 DB에 저장된 Refresh Token과 비교하여 유효성 검사를 진행하고, 유효하다면 새로운 Access Token을
ResponseBody에, Refresh Token은 cookie에 담아 전송한다. - 클라이언트 요청의 Refresh Token이 만료되었거나 찾을 수 없는 Token이라면 로그인 절차를 다시 수행한다.
로그인 유지
- React의 경우 Context를 사용한 로그인 관리를 참고.
클라이언트(React) 설정
- React 설정의 상세한 과정은 React, React-Router, Axios 방법을 참고하고, 테스트에는 회원가입, 로그인, 데모(로그인 후에만 데이터 가져옴) 페이지를 작성하였다.
App.js과 index.js 설정
- App.js : React-Router 설정을 위한
Routes및RouteComponent만 추가하였다.- 로그인과 회원가입 둘 다 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;
- index.js : React-Router 사용을 위한
BrowserRouterComponent를 추가하고, 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 설정
- 자세한 설명은 CORS(Cross-Origin Resource Sharing), React Proxy 설정를 참고.
- 터미널에서
http-proxy-middleware를 설치하고, React 프로젝트의 root 폴더에setupProxy.js를 추가하여 아래 내용을 작성한다.
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 변경 설정
})
);
};
회원가입 페이지 설정
- 회원가입을 진행한 뒤에 서버에서 Access Token은 Body에, Refresh Token은 cookie에 담겨 오기에 이를 확인하고자 회원가입 페이지를 설정했다.
- 가장 기본 input만 사용하였고,
useState로 보낼 데이터를 담을formData를 관리한다. - 필요한 데이터를 입력한 후 가입 버튼을 누르면
axios로 요청을 전송하며, 응답의 상태를 확인한다.200코드로 응답이 오면 응답의 데이터에서access_token을 꺼내고,axios.defaults.headers.common['Authorization']에 Access Token을 Bearer Schema로 지정한다.
- 가입이 완료되면 로그인 페이지로 이동하는데, 개발자 도구에서 Network와 cookie를 확인하기 위해
window.confirm의 확인 버튼을 눌러야 이동하게 설정했다.
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;
로그인 페이지 설정
- 회원가입과 마찬가지로 요청을 전송하면 Access Token이 응답의 Body에, Refresh Token이 cookie에 담겨 온다.
- 가장 기본 input만 사용하였고,
useState로 보낼 데이터를 담을formData를 관리한다. - 필요한 데이터를 입력한 후 로그인 버튼을 누르면
axios로 요청을 전송하며, 응답의 상태를 확인한다.200코드로 응답이 오면 응답의 데이터에서access_token을 꺼내고,axios.defaults.headers.common['Authorization']에 Access Token을 Bearer Schema로 지정한다.
- 로그인이 제대로 진행되면 자원 요청 페이지로 자동 이동하도록 설정했다.
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 페이지 설정
- 자원을 요청하는 페이지로, 이 페이지에선 Spring Security에 의해 Authenticate를 받아야만 자원을 보내준다.
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) 설정
- 원래 https를 사용하려면 몇가지 절차가 필요한데, 이 곳에서는 Token을 서버에서 클라이언트로 전송하는 부분을 위주로 정리하여 추후에 설정을 정리할 예정이다.
Controller 수정
- Cookie 설정을 Service에서 추가하고, 응답용 클래스
AuthenticationResponse의 필드에 Access Token만 노출 시키기 위해 Sevice의 각 메소드가ResponseEntity<AuthenticationResponse>를 반환하도록 변경하였다. - 이에 따라 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 수정
- Service 클래스에 cookie 설정을 위한 메소드를 추가하여 Refresh Token을 생성하면 이를 cookie에 담아 Header에 설정한다.
- cookie는
org.springframework.http.ResponseCookie클래스를 사용하였는데, 이유는sameSite설정을 추가할 수 있기 때문이다. - Servlet과 Spring의 Cookie 생성를 참고하여 현재 진행하는 프로젝트에 맞는 클래스를 사용하면 된다.
- 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;
}
register(),authenticate(),refreshToken()메소드에 cookie 생성 메소드를 추가하고, 반환형을ResponseEntity<AuthenticationResponse>로 수정한다.AuthenticationResponse에는 Access Token만 담고, Refresh Token은 담지 않도록 수정한다.- 마지막으로 반환 값은
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
- 애플리케이션과 클라이언트를 실행한 후 브라우저에서
http://localhost:3000/demo로 접근한다. - 개발자 도구에서 Network를 보면
/api/v1/demo-controller요청에 대한 응답이403 Forbidden으로 뜬다.- Spring Security 설정으로
Filter에서 요청의Authorization Bearer항목이 없는 것을 확인하고403응답을 전송하였다.
- Spring Security 설정으로
- Application의 Cookies 항목에도 아무 cookie가 존재하지 않는 것을 확인할 수 있다.
- 이번엔
http://localhost:3000/register에 접속하여 가입 절차를 진행하고, 가입 요청을 보낸다. - 개발자 도구의 Network에서 응답을 분석해보면 응답이
200으로 왔으며, Response Headers에서Set-Cookie항목에refresh-token이 있는 것을 확인할 수 있다.
- Application의 Cookies 항목에도
httpOnly,secure,sameSite="Strict"가 설정된 Refresh Token을 확인할 수 있다.
- 회원가입의 응답 body를 보면 Access Token만 있고 Refresh Token은 없다.
- 이미지는 로그인 페이지 이동 확인을 눌러 로그인 페이지로 이동한 모습이다.
- 로그인을 수행한 뒤 로그인 응답을 보면 Reponse Headers에
Set-Cookie로 Refresh Token이 있는 것을 확인할 수 있다.
- 응답의 Body를 보면 회원가입과 마찬가지로 Access Token만 있고 Refresh Token은 없다.
- Application의 Cookies 항목에 회원가입 절차로 받은 Refresh Token과 다른 Token이 있는 것을 확인할 수 있다.
- 이미 위의 사진들에서 "Hello from secured endpoint" 문구가 나온 것을 볼 수 있는데, Network에서 자원 요청에 대한 응답을 보면
200상태로 되어 있다.
- 또한 Request Headers를 보면
Authorization이Bearer에 Access Token과 함께 나와 있다. - 이를 통해 Access Token을 사용한 자원 요청이 정상적으로 이루어졌음을 확인하였다.
- Request Header에 Cookie 부분을 보면 Refresh Token이 그대로 나와있어 재발급 요청 방법을 정리한 후 이 부분도 수정한다.
재발급 요청
- 서버에서 클라이언트로 Access Token은 Body에, Refresh Token은 cookie에 담아 전송하고, 클라이언트에서 Access Token을 Authorization Bearer Schema Header에 담아 보내는 걸 연습했다.
- 이번엔 클라이언트에서 서버로 Refresh Token을 cookie 그대로 전송하여 Access Token을 재발급하는 과정을 정리했다.
클라이언트 수정 - Demo 페이지
- Access Token으로 인증받은 상태로 자원을 요청하는 Demo 페이지에서 Refresh Token으로 Access Token을 재발급 받는 메소드를 추가했다.
- 먼저 새 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');
}
}
- 이번엔 Authorization Header의 유무에 따라 자원을 재 요청하는 메소드이다.
fetchWithRetry()는 코드 중복 및 관리를 위해 중복 요청을 따로 설정한 메소드로, 최초 요청을 보낸 후에 응답이 제대로 왔다면 응답의 body를 반환한다.- 응답에 에러가 발생했다면
401이나403인 경우에refreshToken()을 호출하여 Token을 재발급 받고, 다시 자원 요청을 보내 결과에 따라 응답의 body를 반환하거나 에러를 던진다. - 자원 요청 메소드
getResource()에선/demo-controller로fetchWithRetry()메소드를 사용하여 요청을 보내고, 결과에 따라 화면에 표시할 데이터를 설정한다.
// 결과에 따라 재요청을 처리
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");
}
}
- Demo 페이지 코드를 모두 정리하면 아래와 같다.
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
- 기존 서버의
AuthenticationService에서 재발급 부분은 요청의 Authorization Header 부분의 값을 읽어오도록 되어 있었다. - 하지만 서버에서 클라이언트로 Refresh Token을 "refresh-token"이라는 이름의 cookie에 담아 보냈다.
- 클라이언트에서 cookie는
httpOnly설정으로 인해 javascript의Document.cookieAPI로 직접 접근할 수가 없어 요청을 보낼 때 서버로부터 받은 cookie를 그대로 보낸다. - 따라서 서버에서 요청을 받을 때 Authorization Header 부분에서 Refresh Token을 추출하는 것이 아닌 cookie에서 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.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
- 애플리케이션을 실행하고
http://localhost:3000/demo로 접속한다. - 최초 실행시엔 요청에는 Authorization Header가 없고, Refresh Token도 저장되어 있지 않아 로그인 페이지로 이동하라는 알림이 뜬다.
- 회원가입 및 로그인 절차를 끝내면
/demo-controller요청의 응답이 제대로 나와 "Hello from secured endpoint" 문구가 뜬다.
- 이 상태로 새로고침을 하면 Network에서 초기
/demo-controller요청은 Authorization Header가 없어403이 뜨지만,/refresh-token요청을 보내 Access Token을 재발급 받고, 이를 Authorization Header에 넣어/demo-controller요청을 보내 응답이 제대로 온 것을 확인할 수 있다.
cookie를 특정 요청에서만 교환하기
- 지금까지 설정대로만 진행했다면 모든 요청에서 Header에 항상 cookie가 포함되어 있다.
- 이는 Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장#App.js과 index.js 설정에서
axios.defaults.withCredentials="true";를 적용했기 때문에 모든 요청에서 cookie를 전송하는 것이다. - 매 요청마다 cookie를 보내면 중간에 Refresh Token이 탈취될 위험이 있기에 클라이언트와 서버에서 특정 요청 및 경로에만 cookie를 담도록 수정하면 해결할 수 있다.
- 서버는 cookie 생성 시
setPath()설정으로 특정 경로에서만 cookie를 보내주고, 클라이언트에선withCredential옵션을 요청 메소드에 따로 추가하여 설정할 수 있다.
- 서버는 cookie 생성 시
- 먼저 클라이언트의
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();
Login.js과Demo.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
- 애플리케이션을 실행하고 회원가입과 로그인을 진행한다.
- 회원가입 후 받은 Access Token을 Authorization Header에 담아두기에 로그인 요청을 보낼 때는 Authorization Header가 있다.
- 하지만
/authenticate요청 Header를 보면 이전과 달리 cookie는 존재하지 않는다.
- 하지만
- 이번에
/demo-controller요청 Header를 보면 로그인 요청으로 받은 Access Token을 Authorization Header에 담아 보냈으며, 마찬가지로 cookie는 보이지 않는다.
- 새로고침을 수행하고
/refresh-token의 요청 header를 보면 cookie가 있는 것을 확인할 수 있으며, 클라이언트에서 cookie를 전달했기에 새 Access Token을 발급 받았음을 확인할 수 있다.