[JWT]JWT를 위한 Spring Security 설정
- -
이번 시간에는 JWT를 Spring Security에서 사용하기 위해 필요한 내용들을 작성해보자.
필터 작성
Spring의 Filter
Spring Security는 전반적으로 Filter 기반으로 동작하기 때문에 적절한 Filter를 구성해서 JWT를 사용하는 것이 가장 권장된다.
Spring에서 Filter를 만들 때는 통상 OncePerRequestFilter를 상속받아서 구현한다. OncePerRequestFilter는 forward나 include를 포함하는 요청 처리 과정에서 딱 1번만 처리되는 것이 보장되는 필터이다. 이 클래스에는 doFilterInternal과 shouldNotFilter메서드가 제공되므로 필요한 기능을 여기에 제공하면 된다.
@RequiredArgsConstructor
@Slf4j
public class MyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 문제 없으면 다음 filter 호출
filterChain.doFilter(request, response);
}
@Override
// filter의 동작에서 제외시킬 경로 설정
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return false;
}
}
이제 JWTVerificationFilter와 JWTLoginFilter 둘을 추가해주자.
JWTVerificationFilter
먼저 JWT를 검증하기 위한 JWTVerificationFilter이다. 이 필터는 사용자의 요청에서 token을 추출하고 이 토큰이 검증 과정을 통과하는지 확인하는 역할을 수행한다. 검증 과정에서 문제가 없다면 인증 정보를 SecurityContxtHolder에 저장한다.
먼저 util성 메서드로 HttpServletRequest에서 토큰정보를 추출하기 위한 extractToken와 예외 처리를 위한 handleTokenException 메서드를 살펴보자. 특히 extractToken은 나중에 클라이언트에서 토큰을 보내주는 부분과 합이 맞아야 한다. 토큰을 추출하기 위해 요청 헤더의 Authorization 값을 조회하고 있으며 그 값이 'Bearer '로 시작하고 있음을 기억해두자.
@RequiredArgsConstructor
@Slf4j
public class JWTVerificationFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
private String extractToken(HttpServletRequest request) {
// 헤더에서 토큰 추출: 클라이언트에서 토큰 전송 시 참조
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private void handleTokenException(HttpServletResponse response, String message) {
try {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
Map<String, String> error = Map.of("error", "unauthorized", "message", message);
response.getWriter().write(new ObjectMapper().writeValueAsString(error));
} catch (IOException e) {
log.error("error", e);
}
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
...
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
...
}
}
Bearer는 OAuth 2.0의 표준에 명시되어있으며 Basic, Digest등 다른 인증 방식과 구분하기 위한 값으로 '해당 토큰을 소지한 사람이 권한을 가잠'을 의미한다.
다음은 요청에서 Filter의 핵심인 doFilterInternal 메서드를 살펴보자. 여기서는 토큰의 검증을 담당하고 검증에 통과하면 다음 filter chain을 호출한다.
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 토큰 추출
String token = extractToken(request);
Optional.of(token).ifPresentOrElse(t -> {
try {
// 토큰 검증 및 사용자 정보 추출
Map<String, Object> claims = jwtUtil.checkAndGetClaims(token);
String userId = claims.get("user").toString();
// 사용자 정보로 UserDetails 생성
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
// 인증 객체 생성 및 SecurityContext에 저장
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
handleTokenException(response, "유효기간이 지난 토큰입니다.");
} catch (SignatureException e) {
handleTokenException(response, "잘못된 토큰입니다.");
} catch (Exception e) {
handleTokenException(response, "처리 과정에서 오류가 발생했습니다." + e.getMessage());
}
}, () -> {
log.error("token 검증 실패");
handleTokenException(response, "로그인이 필요한 서비스입니다.");
});
}
마지막으로 필터가 동작하기 위한 조건을 설정하는 shouldNotFilter이다. /api/auth/login은 반드시 필터를 거치지 않고 /api/는 필터를 거치게 하자. 이것을 Filter가 동작하기 위한 것이지 Spring Security의 HttpSecurity에 설정하는 요청 경로와는 다르다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getServletPath();
return path.equals("/api/auth/login") || !path.startsWith("/api/");
}
JWTLoginFilter
다음으로 JWT 를 이용한 로그인 요청을 처리하는 JWTLoginFilter를 만들어보자. 이 토큰은 UsernamePasswordAuthenticationFilter를 상속해서 만들면 되고 attemptAuthentication, successfulAuthentication, unsuccessfulAuthentication 메서드를 각각 재정의 해주면 된다.
@RequiredArgsConstructor
@Slf4j
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
// 로그인 요청시 실행되는 메소드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
return null;
}
// 로그인 성공시 실행하는 메소드 (JWT 발급)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authentication) {
}
// 로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) {
}
}
먼저 attemptAuthentication 메서드를 살펴보자. 여기서 신경쓸 부분은 요청 정보에서 rest 방식으로 전달된 사용자 정보를 끄집어 내는 부분인데 ObjectMapper의 readValue를 사용하면 쉽게 처리할 수 있다.
추출된 username과 password를 authenticationManager에게 전달하면 등록된 CustomUserDetailsService를 이용해 인증을 처리한다.
// 로그인 요청시 실행되는 메소드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 로그인 요청에서 username, password 추출 (REST 방식)
try {
Map<String, String> requestBody = new ObjectMapper().readValue(request.getInputStream(),
new TypeReference<Map<String, String>>() { });
String username = requestBody.get("username");
String password = requestBody.get("password");
log.debug("로그인 시도: {},{}", username, password);
// username 과 password 를 검증하기 위해 token 에 담아서 사용 (스프링 시큐리티에서)
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password);
return authenticationManager.authenticate(authToken);
} catch (IOException e) {
log.error("Error reading username and password from request body", e);
throw new AuthenticationException("Invalid login request") {};
}
}
다음으로 로그인에 성공했을 때 처리할 동작을 재정의 한다. 여기서는 로그인 성공 시 JWT를 생성하고 json 문자열 형태로 응답으로 내려보낸다. 이때 token의 키값은 data라고 한다.
// 로그인 성공시 실행하는 메소드 (JWT 발급)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authentication) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
String userId = userDetails.getUsername();
// 로그인 성공 시 토큰 생성
String token = jwtUtil.createAuthToken(userId);
Map<String, String> data = Map.of("data", token);
handleResult(response, data, HttpStatus.OK);
}
private void handleResult(HttpServletResponse response, Map<String, String> data, HttpStatus status) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
try {
String jsonResponse = new ObjectMapper().writeValueAsString(data);
response.setStatus(status.value());
response.getWriter().write(jsonResponse);
} catch (IOException e) {
log.error("Error writing JSON response", e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}
}
마지막으로 로그인 실패시 동작을 재정의하자.
// 로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) {
Map<String, String> data = Map.of("error", "login fail: check id/pass");
HttpStatus status = HttpStatus.BAD_REQUEST;
handleResult(response, data, status);
}
SecurityFilterChain 재구성
SecurityFilterChain
기존의 SecurityFilterChain은 전형적인 웹 요청을 처리하는 형태였다면 이제 API 요청을 위한 Filter Chain으로 변경해주면 된다.
Security filter chain: [ DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CsrfFilter LogoutFilter UsernamePasswordAuthenticationFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter RememberMeAuthenticationFilter AnonymousAuthenticationFilter ExceptionTranslationFilter AuthorizationFilter ] |
Security filter chain: [ DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter LogoutFilter JWTVerificationFilter JWTLoginFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter SessionManagementFilter ExceptionTranslationFilter AuthorizationFilter ] |
일반 웹 요청을 위한 FilterChain | API 처리를 위한 FilterChain |
일단 CSRF 처리를 제외 시키고 UsernamePasswordAuthenticationFilter 이전에 JWTVerificationFilter를 끼워 넣은 후 JWTLoginFilter로 대체시키는 것이 최종 목표이다.
APISecurityConfig
이제 API 처리를 위한 SecurityFilterChain을 구성해보자. 여기서 만들어지는 SecurityFilterChain의 우선순위를 기존의 SecurityFilterChain보다 높여주기 위해서 @Order를 사용한 점도 눈여겨 보자.
@Configuration
public class APISecurityConfig {
@Bean
public AuthenticationManager authenticationManager(DaoAuthenticationProvider provider) {
return new ProviderManager(provider);
}
@Bean
@Order(1) // 낮을 수록 우선순위가 높음. 생략 시 가장 낮음
public SecurityFilterChain apiSecurityFilterChain(
HttpSecurity http, CustomUserDetailsService userDetailsService,
AuthenticationManager authenticationManager,JWTUtil jwtUtil)
throws Exception {
// 사용할 Filter 생성
JWTLoginFilter loginFilter = new JWTLoginFilter(authenticationManager, jwtUtil);
JWTVerificationFilter jwtFilter = new JWTVerificationFilter(jwtUtil, userDetailsService);
// loginFilter 의 URL 설정
loginFilter.setFilterProcessesUrl("/api/auth/login");
http.securityMatcher("/api/**") // SecurityFilterChain의 동작 범위 설정
.userDetailsService(userDetailsService)
.csrf(csrf -> csrf.disable()) // Csrf 설정 무력화
.sessionManagement( // 세션 설정 - 세션 사용하지 않음
session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests( // 인가 정보 확인이 필요한 경로 설정
authorize -> authorize
.requestMatchers("/api/auth/**", "/api/public/**", "/api/error").permitAll()
.anyRequest().authenticated())
// Filter의 위치 지정
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Order는 동일한 타입의 빈들에 대해 우선순위를 정할 때 많이 사용되며 지정된 숫자가 낮을 수록 높은 우선순위를 갖는다. 또한 @Order 생략 시는 가장 낮은 우선순위가 지정된다. 현재 SecurityFilterChain도 2개의 빈으로 구성되는데 기존의 일반 웹 요청을 처리하는 빈에 @Order(2)라고 추가해주면 가독성이 더 좋아진다.
Controller 추가
APIController
이제 API 요청을 처리하기 위한 APIController를 작성해보자.
@RestController
@RequestMapping("/api")
@Slf4j
public class APIController {
@GetMapping("/multi")
public Integer multi(@RequestParam("a") Integer a, @RequestParam("b") Integer b) {
return a * b;
}
@GetMapping("/error")
private ResponseEntity<Map<String, String>> handleTokenException(Model model,
HttpServletRequest request) {
log.debug("api/error called: {}", request.getAttribute("message"));
String message = request.getAttribute("message").toString();
return ResponseEntity.status(401).body(Map.of("error", "unauthorized", "error", message));
}
}
화면에서의 토큰 사용
인증 요청
이제 마지막으로 화면단에서 AJAX 요청을 하면서 토큰을 사용해보자. id로 사용될 username과 password를 입력받는 input과 나중에 토큰의 정보를 뿌려줄 tokenDisplay를 준비한다.
<div>
<h3>로그인</h3>
<div>
<input type="text" id="username" value="doding" placeholder="사용자명" />
<input type="password" id="password" value="1234" placeholder="비밀번호" />
<button onclick="login()">로그인</button>
</div>
<div id="tokenDisplay"></div>
</div>
다음은 위와 연결된 자바 스크립트 내용이다. 자바스크립트는 특별한 내용은 없고 로그인 요청을 날릴 때 username, password를 키로 JSON객체를 생성 후 전송해야 한다는 점만 주의하자.
function decodeJwtExpTime(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1])); // atob: base64 -> 일반 문자열, 반대는 btoa
const expTime = new Date(payload.exp * 1000);
return expTime.toLocaleString();
} catch (e) {
return '알 수 없음';
}
}
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const tokenDisplay = document.getElementById('tokenDisplay');
try {
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username, password }),
});
const json = await loginResponse.json();
if (!loginResponse.ok) {
throw new Error(json.error);
}
sessionStorage.setItem('authToken', json.data); // 토큰은 sessionStorage에 저장 하자.
const expTime = decodeJwtExpTime(json.data);
tokenDisplay.innerHTML = `<div style="word-break: break-all;">
<span style="color: green;">로그인 성공!</span><br/>
<small>Token: ${json.data}</small><br/>
<small>만료 예정: ${expTime}</small>
</div>`;
} catch (error) {
tokenDisplay.innerHTML = `<span style="color: red;">로그인 실패: ${error.message}</span>`;
}
}
최종적으로 전달받은 토큰은 sessionStorage에 authToken이라는 이름으로 저장해둔다.
토큰 활용
다음으로 /api/** 로 요청을 날라보자. 다음과 같이 두 개의 숫자를 입력받아서 요청하고 결과를 표시하기 위한 화면을 만들어보자.
<!-- API 호출 섹션 -->
<div>
<h3>곱셈 계산</h3>
<div>
<input type="number" id="num1" value="1" />
<input type="number" id="num2" value="3" />
<button onclick="callMultiApi()">계산</button>
</div>
<div id="result"></div>
</div>
다음으로 버튼이 클릭될 때 동작할 callMultiApi 함수이다. callMultiApi 함수에서는 요청을 만들 때 headers 정보를 신경써줘야 한다. JWTVerificationFilter에서 토큰 정보를 조사할 때 header에서 Authorization 값을 조사했고 그 값이 'Bearer '로 시작하면 JWT를 찾았다는 점을 기억하자.
async function callMultiApi() {
const num1 = document.getElementById('num1').value;
const num2 = document.getElementById('num2').value;
const resultDiv = document.getElementById('result');
const currentToken = sessionStorage.getItem('authToken');
if (!currentToken) {
resultDiv.innerHTML = '<span style="color: red;">먼저 로그인해주세요.</span>';
return;
}
try {
const apiResponse = await fetch(`/api/multi?a=${num1}&b=${num2}`, {
headers: {
Authorization: `Bearer ${currentToken}`,
Accept: 'application/json',
},
});
const result = await apiResponse.json();
if (!apiResponse.ok) {
throw new Error('API 호출 실패' + apiResponse.status);
}
resultDiv.innerHTML = `<span style="color: green;">결과: ${result}</span>`;
} catch (error) {
resultDiv.innerHTML = `<span style="color: red;">에러: ${error.message}</span>`;
}
}
동작 확인
만약 토큰이 없는 상태에서 곱셈을 요청하면 로그인 이 필요하다는 메시지가 출력된다.
로그인을 실행하면 다음과 같이 토큰이 출력된다.
이제 곱셈을 요청하면 요청이 잘 처리되고 결과가 출력된다.
토큰이 만료된 후 다시 요청하면
'Spring security > 02.JWT활용' 카테고리의 다른 글
[JWT] Refresh 토큰 개념 (1) | 2024.12.02 |
---|---|
[JWT]프로젝트 구성 및 토큰 확인 (0) | 2024.11.26 |
[JWT]JWT 개요 (0) | 2024.11.25 |
JWT를 이용한 인증 처리 6. Refresh Token 구현 (0) | 2022.05.05 |
JWT를 이용한 인증 처리 4 (0) | 2022.04.28 |
소중한 공감 감사합니다