반응형
해당 글에서는 Spring Security 3.x 내에서 JWT를 이용하여 만료된 접근 토큰(Access Token)에 대해 Refresh Token을 이용하여 자동 갱신을 하는 과정에 대해 확인해 봅니다.
💡[참고] Spring Security 관련 글 및 Github Repository 경로입니다. 참고하시면 도움이 됩니다.
분류 | 상세 분류 | 주제 | 링크 |
Spring Boot 2.x | 이론 | Spring Boot Security 이해하기 -1 : 2.7.x 버전 구조 및 파일 이해 | https://adjh54.tistory.com/91 |
Spring Boot 2.x | 환경 설정 | Spring Boot Security 이해하기 -2 : 2.7.x 버전 구현하기 | https://adjh54.tistory.com/92 |
Spring Boot 2.x | 이론 | Spring Boot Security 이해하기 -3: JWT(JSON Web Token) 이해하기 | https://adjh54.tistory.com/93 |
Spring Boot 2.x | 환경 설정 | Spring Boot Security 이해하기 -4: JWT 환경 설정 및 구성 하기 | https://adjh54.tistory.com/94 |
Spring Boot 2.x | 소스코드 | Spring Boot Security 2.x + JWT 기반 구성 Github Repository | https://github.com/adjh54ir/blog-codes/tree/main/spring-boot2-security |
기타 | 이론 | Spring Boot 2.x.x 버전 프로젝트 생성: 지원 종료 및 다운그레이드 | https://adjh54.tistory.com/361 |
Spring Boot 3.x | 이론 | Spring Boot Security 3.x + JWT 이해하기 -1 : 구조 및 Client / Server 처리과정 | https://adjh54.tistory.com/576 |
Spring Boot 3.x | 환경 설정 | Spring Boot Security 3.x + JWT 이해하기 -2 : 환경 설정 및 구성 | https://adjh54.tistory.com/577 |
Spring Boot 3.x | 환경 설정 | Spring Boot Security 3.x + JWT 이해하기 -3 : Refresh Token 활용 | https://adjh54.tistory.com/583 |
Spring Boot 3.x | 환경 설정 | Spring Boot Security 3.x + JWT 이해하기 -4 : 로그아웃 + 블랙리스트 활용 | https://adjh54.tistory.com/592 |
Spring Boot 3.x | 소스코드 | Spring Boot Security 3.x + JWT 기반 구성 Github Repository | https://github.com/adjh54ir/blog-codes/tree/main/spring-boot3-security |
React | 소스코드 | Spring Boot 3.x와 통신하여 로그인을 수행하는 클라이언트 구성 Github Repository | https://github.com/adjh54ir/blog-codes/tree/main/react-login |
1) Spring Boot Security
💡Spring Boot Security
- Spring 기반 애플리케이션 내에서 보안을 담당하는 프레임워크를 의미합니다. 이는 사용자에 대한 ‘인증’과 ‘인가(권한부여)’에 대한 처리를 담당합니다.
분류 | 설명 |
인증(Authentication) | - 사용자는 자신을 입증할 수 있는 ‘정보’를 시스템에 제공하며, 시스템은 사용자에 대한 정보를 ‘검증’하여 시스템을 이용할 수 있는 사용자 인지에 대해 확인을 하는 과정을 의미합니다. |
인가(Authorization) | - 애플리케이션에서 보호된 자원(메서드 접근 혹은 요청에 대한 자원)에 대해서 접근을 허가하거나 거부하는 기능을 의미합니다. |
💡[참고] 상세한 Spring Boot Seucrity에 대해 알고 싶으시면 아래의 글을 참고하시면 도움이 됩니다.
1. Client 입장에서의 인증과 인가
💡 Client 입장에서의 인증과 인가
- 해당 과정에서는 클라이언트에서 로그인 정보를 전달하면, 데이터베이스에서 이를 조회하여 ‘인증’을 수행합니다.
- 그리고 이 인증이 완료된 경우, JWT 토큰을 발급하여 인증이 된 사용자로 ‘인가(권한부여)’를 하는 프로세스입니다.
2. Spring Boot Security의 접근 토큰(Access Token) 발급 처리 과정
💡Spring Boot Security의 접근 토큰(Access Token) 발급 처리 과정
- 클라이언트의 호출 이후 내부적으로 처리되는 API Server의 과정에 대해 확인합니다.
1. 클라이언트의 API 호출 : 엔드포인트(api/v1/user/login)
- 클라이언트는 사용자 아이디, 비밀번호를 기반으로 API 호출을 수행합니다.
2. 서버 내에서 해당 엔드포인트에 대한 감지 하여 이를 처리합니다. : CustomAuthenticationFilter
- CustomAuthenticationFilter 내에서 사전에 지정한 엔드포인트를 통해서 수행처리 합니다.
3. 감지 이후 Filter에서 우선적으로 감지하여 해당 엔드포인트로 호출되는 정보를 조회합니다. : JwtAuthorizationFilter
- 3.1. JWT가 필요가 없는 URL인 경우는 다음 필터를 수행하도록 처리가 됩니다.
- 3.2. JWT가 필요한 경우는 JWT에 대한 존재 및 유효성을 검증합니다.
4. 사용자 아이디/비밀번호를 감지하여 전달합니다.: CustomAuthenticationFilter
- CustomAuthenticationFilter 내에서 사용자 아이디/비밀번호 값을 CustomAuthenticationProvider로 전달을 합니다.
5. 전달받은 값을 통해 데이터베이스 내의 사용자 정보를 조회하여 사용자 여부를 확인합니다. : CustomAuthenticationProvider
- 데이터베이스를 호출하여 사용자를 조회하여 실제 사용자 여부인지 여부를 성공(CustomAuthSuccessHandler)과 실패(CustomAuthFailureHandler)로 전달을 합니다.
6. 이전 과정에서 사용자 여부가 비교되어서 상황에 따른 처리를 수행합니다.
- 6.1. 성공 시, 조회된 사용자 정보와 토큰을 발급하여 클라이언트에게 전달합니다.
- 6.2. 실패 시, 클라이언트에게 오류 코드와 오류 메시지를 전달합니다.
💡 [참고] 해당 프로세스에 대해 상세히 궁금하시면 아래의 글에 대해 참고하시면 도움이 됩니다.
2) Spring Boot Security Token 종류 : 접근/갱신 토큰
1. 접근 토큰(Access Token)
💡접근 토큰(Access Token)
- 사용자 인증 후 서버에서 발급이 되며, 클라이언트가 보호된 리소스에 접근할 때 사용을 합니다. 이는 암호화된 문자열 형태로 구성이 되어 있습니다.
- 일반적으로 JWT(JSON Web Token) 형식으로 발급되며, 헤더, 페이로드, 서명으로 구성이 됩니다.
특징 | 설명 |
짧은 유효 기간 | 보안을 위해 일반적으로 짧은 유효 기간(예: 15분~1시간)을 가집니다. |
클라이언트 저장 | 주로 클라이언트 측(브라우저 등)에 저장되어 요청 시 함께 전송됩니다. |
무상태성 | 서버에 상태를 저장하지 않아 확장성이 좋습니다. |
자체 포함 | 사용자 정보와 권한 등을 토큰 자체에 포함하고 있습니다. |
재사용성 | 여러 서비스나 API에서 동일한 토큰으로 인증할 수 있습니다. |
2. 갱신 토큰(Refresh Token)
💡갱신 토큰(Refresh Token)
- 접근 토큰(Access Token)이 만료되었을 때 ‘새로운 접근 토큰을 발급받기 위해 사용되는 토큰’입니다.
- 이 갱신 토큰을 통해 리소스의 접근은 불가능합니다. 접근 토큰에 비해 더 긴 유효시간을 가지며, 보안 강화를 위해서 사용됩니다.
특징 | 설명 |
긴 유효 기간 | 접근 토큰보다 더 오래 유효하여 사용자 경험을 개선합니다. |
보안 강화 | 접근 토큰의 유효 기간을 짧게 유지하면서도 로그인 상태를 오래 유지할 수 있게 합니다. |
서버 측 저장 | 일반적으로 데이터베이스에 저장되어 관리됩니다. |
재발급 과정 | 접근 토큰 만료 시 갱신 토큰을 사용해 새로운 접근 토큰을 발급받습니다. |
[ 더 알아보기 ]
💡 갱신 토큰(Refresh Token) 또한 만료가 되면 어떻게 되는 걸까?
- 갱신 토큰이 만료되면 사용자는 다시 로그인을 해야 합니다. 이는 보안을 위한 조치입니다. 일반적으로 갱신 토큰의 유효시간은 접근 토큰보다 훨씬 깁니다. 일반적인 갱신토큰이 30분 ~ 1시간을 가진다면 갱신 토큰의 경우는 2주 또는 1개월이 됩니다.
💡 갱신 토큰(Refresh Token)을 사용하지 않고 접근 토큰(Access Token)을 길게 사용하면 되는 것 아닐까?
- 접근 토큰을 사용하지 않고 접근 토큰을 길게 가져가면, 보안 위험이 있습니다. 유효기간이 길어짐에 따라 탈취 시 오랫동안 악용될 수 있습니다. 또한, 사용자의 권한 변경이나 토큰 폐기가 어려워집니다.
💡 갱신 토큰이 2주 또는 1개월이 된다면, 로그인이 2주 또는 1개월 동안 유지된다는 것 아닌가? 이거 위험하지 않아?
- 아닙니다. 갱신 토큰은 ‘접근 토큰을 새로 발급받기 위한 용도로만 사용이 됩니다.’ 이를 직접적으로 사용자의 로그인에서 사용되지는 않습니다.
- 서버 측 제어: 서버는 보안 정책에 따라 갱신 토큰의 사용을 제한하거나 무효화할 수 있습니다. 예를 들어, 일정 횟수 이상 사용되면 갱신 토큰을 무효화할 수 있습니다.
- 동적 보안 관리: 서버는 보안상의 이유로 언제든지 갱신 토큰을 무효화할 수 있습니다. 이는 의심스러운 활동이 감지되거나 사용자의 권한이 변경될 때 유용합니다.
3) Client와 API Server 간의 로그인, 리소스 처리
1. 로그인 수행 이후 리소스 요청까지의 처리 관계
💡로그인 수행 이후 리소스 요청까지의 처리 관계
1. Client → API Server : 로그인 수행
- [POST] api/v1/user/login의 엔드포인트를 통해 사용자 아이디, 비밀번호로 API Server로 요청을 합니다.
2. API Server → Client : 로그인 수행 결과 반환
- 로그인 수행에 따른 결과값을 반환받습니다.
- 사용자 정보, 접근 토큰, 갱신 토큰을 클라이언트에게 반환합니다.
3. Client : 접근 토큰, 갱신 토큰 저장
- 접근, 갱신 토큰을 localstorage 내에 저장해 두어 유지합니다.
4. Client → API Server : 리소스 접근 요청
- [POST] api/v1/user/user의 엔드포인트를 통해 API Server로 요청을 합니다.
- 요청 시 Header 내에 Authorization, x-refresh-token 키 값에 접근/갱신 토큰을 함께 전송합니다.
2. 리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
💡리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
1. 클라이언트는 API Server로 리소스를 요청합니다.
- 요청 시 Header 내에 Authorization로 접근 토큰(Access Token)을 보내며, x-refresh-token로 갱신 토큰(Refresh Token)을 함께 전달합니다.
2. Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다.
- 2.1. 토큰이 존재하면 다음 프로세스를 진행합니다.
- 2.2. 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.
3. 접근 토큰(Access Token)의 유효성을 체크합니다.
- 3.1. 접근 토큰이 유효하다면 다음 프로세스를 진행합니다.
- 3.2. 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.
4. 접근 토큰(Access Token) 내에 전달하려는 사용자 정보를 확인합니다.
4.1. 사용자 정보가 존재한다면 다음 필터로 이동을 합니다.
4.2. 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.
5. 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED) 오류인지를 체크합니다.
- 5.1. 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다.
- 5.2. 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.
6. 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다.
- 6.1. 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다.
- 6.2. 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
반응형
4) 구성 개발 환경
개발환경 | 분류 | 버전 | 설명 |
java jdk | java version | 17 | Java 개발 키트 |
spring-boot | spring boot starter | 3.3.4 | Spring Boot 프레임워크 |
spring-boot-web | spring boot starter | 3.3.4 | Spring Boot 웹 애플리케이션 개발 지원 |
spring-boot-security | spring boot starter | 3.3.4 | Spring Security 통합 |
org.mybatis.spring.boot:mybatis-spring-boot-starter | spring boot starter | 3.0.3 | MyBatis와 Spring Boot 통합 |
io.jsonwebtoken:jjwt | opensource | 0.12.6 | JSON Web Token 생성 및 검증 |
com.fasterxml.jackson.core:jackson-databind | opensource | 2.16.1 | JSON 데이터 바인딩 라이브러리 |
jakarta.xml.bind:jakarta.xml.bind-api | opensource | 4.0.2 | XML 바인딩 API |
org.postgresql:postgresql | opensource | - | PostgreSQL JDBC 드라이버 |
lombok | spring boot | - | Java 코드 생성 라이브러리 |
💡 [참고] 이전에 구성한 내용에서 추가로 이어지는 내용입니다. 해당 환경이 구축되어야 이를 추가적으로 구성이 가능합니다
5) 개발환경 : 소스코드 Refresh Token 구성
1. 이전에 작성한 코드 내에서 이어집니다.
💡이전에 작성한 코드 내에서 이어집니다.
- 해당 코드는 이전에 작성한 코드 내에서 이어지기에 아래의 환경을 확인해 주세요.
2. JwtAuthorizationFilter
💡JwtAuthorizationFilter
- 리소스를 접근하기 이전에 JWT에 대한 체크를 위한 JwtAuthorizationFilter가 수행이 됩니다.
- 해당 과정에서 유효한 JWT를 확인하고 JWT가 만료되면 유효한 JWT를 발급하여 주는 프로세스가 포함되어 있습니다.
💡해당 필터에서는 아래의 과정으로 처리가 됩니다.
1. 클라이언트는 API Server로 리소스를 요청합니다.
- 요청 시 Header 내에 Authorization로 접근 토큰(Access Token)을 보내며, x-refresh-token로 갱신 토큰(Refresh Token)을 함께 전달합니다.
2. Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다.
- 2.1. 토큰이 존재하면 다음 프로세스를 진행합니다.
- 2.2. 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.
3. 접근 토큰(Access Token)의 유효성을 체크합니다.
- 3.1. 접근 토큰이 유효하다면 다음 프로세스를 진행합니다.
- 3.2. 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.
4. 접근 토큰(Access Token) 내에 전달하려는 사용자 정보를 확인합니다.
- 4.1. 사용자 정보가 존재한다면 다음 필터로 이동을 합니다.
- 4.2. 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.
5. 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED) 오류인지를 체크합니다.
- 5.1. 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다.
- 5.2. 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.
6. 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다.
- 6.1. 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다.
- 6.2. 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
package com.adjh.springboot3security.config.filter;
import com.adjh.springboot3security.common.utils.TokenUtils;
import com.adjh.springboot3security.model.dto.UserDto;
import com.adjh.springboot3security.model.dto.ValidTokenDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인하는 필터 역할의 클래스입니다.
*
* @author : jonghoon
* @fileName : CustomAuthenticationFilter
* @since : 10/1/24
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private static final String HTTP_METHOD_OPTIONS = "OPTIONS";
private static final String ACCESS_TOKEN_HEADER_KEY = "Authorization";
private static final String REFRESH_TOKEN_HEADER_KEY = "x-refresh-token";
private static final List<String> WHITELIST_URLS = Arrays.asList(
"/api/v1/user/login",
"/api/v1/token/token",
"/user/login",
"/token/token"
);
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
throws IOException, ServletException {
// [STEP1] 토큰이 필요하지 않는 API 호출 발생 혹은 토큰이 필요없는 HTTP Method OPTIONS 호출 시 : 아래 로직 처리 없이 다음 필터로 이동
if (WHITELIST_URLS.contains(request.getRequestURI()) || HTTP_METHOD_OPTIONS.equalsIgnoreCase(request.getMethod())) {
chain.doFilter(request, response);
return; // 종료
}
try {
// [STEP2] Header 내에 Authorization, x-refresh-token를 확인하여 접근/갱신 토큰의 존재여부를 체크합니다.
String accessTokenHeader = request.getHeader(ACCESS_TOKEN_HEADER_KEY);
String refreshTokenHeader = request.getHeader(REFRESH_TOKEN_HEADER_KEY);
// [STEP2-1] 토큰이 존재하면 다음 프로세스를 진행합니다.
if (StringUtils.isNotBlank(accessTokenHeader) || StringUtils.isNotBlank(refreshTokenHeader)) {
String paramAccessToken = TokenUtils.getHeaderToToken(accessTokenHeader);
String paramRefreshToken = TokenUtils.getHeaderToToken(refreshTokenHeader);
// [STEP3] 접근 토큰(Access Token)의 유효성을 체크합니다.
ValidTokenDto accTokenValidDto = TokenUtils.isValidToken(paramAccessToken);
// [STEP3-1] 접근 토큰이 유효하다면 다음 프로세스를 진행합니다.
if (accTokenValidDto.isValid()) {
// [STEP4] 접근 토큰(Access Token)내에 전달하려는 사용자 정보를 확인합니다.
// [STEP4-1] 사용자 정보가 존재한다면 다음 필터로 이동을 합니다.
if (StringUtils.isNotBlank(TokenUtils.getClaimsToUserId(paramAccessToken))) {
chain.doFilter(request, response);
}
// [STEP4-2] 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.
else {
throw new Exception("토큰 내에 사용자 아이디가 존재하지 않습니다");
}
}
// [STEP3-2] 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.
else {
// [STEP5] 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED)오류 인지를 체크합니다.
// [STEP5-1] 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다.
if (accTokenValidDto.getErrorName().equals("TOKEN_EXPIRED")) {
// [STEP6] 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다.
// [STEP6-1] 리프레시 토큰이 유효하다면 접근 토큰을 갱신합니다. 갱신하여 재 생성된 접근 토큰을 반환합니다.
if (TokenUtils.isValidToken(paramRefreshToken).isValid()) {
// Token 내에 사용자 정보를 추출하고 이를 기반으로 토큰을 생성합니다.
UserDto claimsToUserDto = TokenUtils.getClaimsToUserDto(paramRefreshToken, false);
System.out.println("claimsToUserDto :: " + claimsToUserDto);
String token = TokenUtils.generateJwt(claimsToUserDto); // 접근 토큰(AccessToken)을 새로 발급합니다.
sendToClientAccessToken(token, response); // 발급한 접근 토큰을 클라이언트에게 전달합니다.
chain.doFilter(request, response); // 리소스로 접근을 허용합니다.
}
// [STEP6-2] 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
else {
throw new Exception("재 로그인이 필요합니다.");
}
}
// [STEP5-2] 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.
throw new Exception("토큰이 유효하지 않습니다."); // 토큰이 유효하지 않은 경우
}
}
// [STEP2-2] 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.
else {
throw new Exception("토큰이 존재하지 않습니다."); // 토큰이 존재하지 않는 경우
}
}
// Token 내에 Exception 발생 하였을 경우 : 클라이언트에 응답값을 반환하고 종료합니다.
catch (Exception e) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
String jsonResponse = jwtTokenError(e);
printWriter.print(jsonResponse);
printWriter.flush();
printWriter.close();
}
}
/**
* JWT 내에 Exception 발생 시 JSON 형태의 예외 응답값을 구성하는 메서드
*
* @param e Exception
* @return String
*/
private String jwtTokenError(Exception e) {
ObjectMapper om = new ObjectMapper();
Map<String, Object> resultMap = new HashMap<>();
String resultMsg = "";
// [CASE1] JWT 기간 만료
if (e instanceof ExpiredJwtException) {
resultMsg = "토큰 기간이 만료되었습니다.";
}
// [CASE2] JWT내에서 오류 발생 시
else if (e instanceof JwtException) {
resultMsg = "잘못된 토큰이 발급되었습니다.";
}
// [CASE3] 이외 JWT내에서 오류 발생
else {
resultMsg = "OTHER TOKEN ERROR" + e;
}
// Custom Error Code 구성
resultMap.put("status", 403);
resultMap.put("code", "9999");
resultMap.put("message", resultMsg);
resultMap.put("reason", e.getMessage());
try {
return om.writeValueAsString(resultMap);
} catch (JsonProcessingException err) {
log.error("내부적으로 JSON Parsing Error 발생 " + err);
return "{}"; // 빈 JSON 객체를 반환
}
}
/**
* 클라이언트에게 접근 토큰을 전달합니다.
*
* @param token
* @param response
*/
private void sendToClientAccessToken(String token, HttpServletResponse response) {
Map<String, Object> resultMap = new HashMap<>();
ObjectMapper om = new ObjectMapper();
resultMap.put("status", 401);
resultMap.put("failMsg", null);
resultMap.put("accessToken", token);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try {
PrintWriter printWriter = response.getWriter();
printWriter.write(om.writeValueAsString(resultMap));
printWriter.flush();
printWriter.close();
} catch (IOException e) {
log.error("[-] 결과값 생성에 실패하였습니다 : {}", e);
}
}
}
3. TokenUtils
💡TokenUtils
- 해당 유틸에서는 AccessToken, RefreshToken 등을 생성하고 유효성을 검사하는 유틸입니다.
메서드 명 | 기능 |
createExpiredDate | 토큰의 만료 기간을 설정합니다. Access Token은 5초, Refresh Token은 14일로 설정됩니다. |
createHeader | JWT의 헤더 값을 생성합니다. 토큰 타입, 알고리즘, 등록 날짜를 포함합니다. |
createClaims | 사용자 정보를 기반으로 Claims를 생성합니다. userId와 userNm(Access Token의 경우)을 포함합니다. |
isValidToken | 주어진 토큰이 유효한지 검사합니다. 만료, 변조, null 여부를 확인합니다. |
generateJwt | 사용자 정보를 기반으로 Access Token을 생성합니다. |
generateRefreshToken | 사용자 정보를 기반으로 Refresh Token을 생성합니다. |
getHeaderToToken | HTTP 헤더에서 토큰 정보를 추출합니다. |
getTokenToClaims | 주어진 토큰에서 Claims 정보를 추출합니다. |
getClaimsToUserId | Claims에서 사용자 ID를 추출합니다. |
getClaimsToUserDto | Claims에서 사용자 정보(UserDto)를 추출합니다. |
package com.adjh.springboot3security.common.utils;
import com.adjh.springboot3security.model.dto.UserDto;
import com.adjh.springboot3security.model.dto.ValidTokenDto;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT의 구성요소를 생성하고 최종적으로 JWT를 생성하여 유효성을 체크하는 유틸입니다.
*
* @author lee
* @fileName TokenUtils
* @since : 10/1/24
*/
@Log4j2
@Component
public class TokenUtils {
private static SecretKey JWT_SECRET_KEY;
/**
* JWT_SECRET_KEY 변수값에 환경 변수에서 불러온 SECRET_KEY를 주입합니다.
*/
public TokenUtils(@Value("${jwt.secret}") String jwtSecretKey) {
TokenUtils.JWT_SECRET_KEY = Keys.hmacShaKeyFor(jwtSecretKey.getBytes(StandardCharsets.UTF_8));
}
/**
* '토큰의 만료기간'을 지정하는 메서드
*
* @param isAccessToken : AccessToken 인지 여부
* @return {Date} Calendar
*/
private static Date createExpiredDate(boolean isAccessToken) {
Calendar c = Calendar.getInstance();
if (isAccessToken) {
c.add(Calendar.SECOND, 5); // 10초
// c.add(Calendar.HOUR, 1); // 1시간
// c.add(Calendar.HOUR, 8); // 8시간
// c.add(Calendar.DATE, 1); // 1일
} else {
// c.add(Calendar.SECOND, 10); // 10초
c.add(Calendar.DATE, 14);
}
return c.getTime();
}
/**
* JWT의 '헤더' 값을 생성해주는 메서드
*
* @return HashMap<String, Object>
*/
private static Map<String, Object> createHeader() {
return Jwts.header()
.add("typ", "JWT")
.add("alg", "HS256")
.add("regDate", System.currentTimeMillis()).build();
}
/**
* '사용자 정보' 기반으로 'Claims' 생성하는 메서드
*
* @param userDto 사용자 정보
* @param isAccessToken : AccessToken 인지 여부
* @return Map<String, Object>
*/
private static Map<String, Object> createClaims(UserDto userDto, boolean isAccessToken) {
// 공개 클레임에 사용자의 이름과 이메일을 설정하여 정보를 조회할 수 있다.
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userDto.getUserId());
if (isAccessToken) {
claims.put("userNm", userDto.getUserNm());
}
return claims;
}
/**
* 토큰을 기반으로 유효한 토큰인지 여부를 반환해주는 메서드
* - Claim 내에서 사용자 정보를 추출합니다.
*
* @param token String : 토큰
* @return boolean : 유효한지 여부 반환
*/
public static ValidTokenDto isValidToken(String token) {
try {
Claims claims = getTokenToClaims(token);
log.info("expireTime :{}", claims.getExpiration());
log.info("userId :" + claims.get("userId"));
log.info("userNm :" + claims.get("userNm"));
return ValidTokenDto.builder().isValid(true).errorName(null).build();
} catch (ExpiredJwtException exception) {
log.error("Token Expired", exception);
return ValidTokenDto.builder().isValid(false).errorName("TOKEN_EXPIRED").build();
} catch (JwtException exception) {
log.error("Token Tampered", exception);
return ValidTokenDto.builder().isValid(false).errorName("TOKEN_INVALID").build();
} catch (NullPointerException exception) {
log.error("Token is null", exception);
return ValidTokenDto.builder().isValid(false).errorName("TOKEN_NULL").build();
}
}
/**
* 사용자 정보를 기반으로 토큰을 생성하여 반환 해주는 메서드
*
* @param userDto UserDto : 사용자 정보
* @return String : 토큰
*/
public static String generateJwt(UserDto userDto) {
// 사용자 시퀀스를 기준으로 JWT 토큰을 발급하여 반환해줍니다.
JwtBuilder builder = Jwts.builder()
.setHeader(createHeader()) // Header 구성
.claims(createClaims(userDto, true)) // Payload - Claims 구성
.subject(String.valueOf(userDto.getUserSq())) // Payload - Subject 구성
.signWith(JWT_SECRET_KEY) // Signature 구성
.expiration(createExpiredDate(true)); // Expired Date 구성
return builder.compact();
}
/**
* ReFresh Token을 생성합니다.
*
* @param userDto
* @return
*/
public static String generateRefreshToken(UserDto userDto) {
return Jwts.builder()
.setHeader(createHeader())
.claims(createClaims(userDto, false))
.subject(String.valueOf(userDto.getUserSq()))
.signWith(JWT_SECRET_KEY)
.expiration(createExpiredDate(false))
.compact();
}
/**
* 'Header' 내에서 'Token' 정보를 반환하는 메서드
*
* @param header 헤더
* @return String
*/
public static String getHeaderToToken(String header) {
return header.split(" ")[1];
}
/**
* 'JWT' 내에서 'Claims' 정보를 반환하는 메서드
*
* @param token : 토큰
* @return Claims : Claims
*/
private static Claims getTokenToClaims(String token) {
System.out.println("확인111 : " + token);
System.out.println("확인222 : " + JWT_SECRET_KEY);
return Jwts.parser()
.verifyWith(JWT_SECRET_KEY)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 'Claims' 내에서 '사용자 아이디'를 반환하는 메서드
*
* @param token : 토큰
* @return String : 사용자 아이디
*/
public static String getClaimsToUserId(String token) {
Claims claims = getTokenToClaims(token);
return claims.get("userId").toString();
}
/**
* 'Claims' 내에서 토큰을 기반으로 사용자 정보를 반환하는 메서드
*
* @param token
* @param isAccessToken : AccessToken 인지 여부
* @return Claim 내의 사용자 정보를 반환합니다.
*/
public static UserDto getClaimsToUserDto(String token, boolean isAccessToken) {
Claims claims = getTokenToClaims(token);
String userId = claims.get("userId").toString();
if (isAccessToken) {
String userNm = claims.get("userNm").toString();
return UserDto.builder().userId(userId).userNm(userNm).build();
} else {
return UserDto.builder().userId(userId).build();
}
}
}
6) [참고] 클라이언트 처리 과정
💡[참고] 클라이언트 처리 과정
- 클라이언트 측에서 JWT(JSON Web Token)를 사용한 인증 처리과정을 구현한 Axios 인스턴스입니다.
1. AxiosJwtInstance 생성
- 기본 URL, 타임아웃, 헤더 등의 설정을 포함한 Axios 인스턴스를 생성합니다.
2. 요청 인터셉터 : AxiosJwtInstance.interceptors.request
- 서버로 요청을 보내기 전에 실행됩니다.
- BLACK_LIST에 포함되지 않은 URL에 대해 로컬 스토리지에서 접근 토큰과 갱신 토큰을 가져와 요청 헤더에 추가합니다.
3. 응답 인터셉터 : AxiosJwtInstance.interceptors.response
- 서버로부터 응답을 받은 후 실행됩니다.
- 응답 상태 코드가 401이고 새로운 접근 토큰이 제공된 경우, 로컬 스토리지의 토큰을 갱신하고 요청을 재시도합니다.
import axios from 'axios';
const BLACK_LIST = ['/api/v1/user/login']; // 토큰 처리를 제외할 목록
/**
* Axios 인스턴스를 생성합니다.
* - 이 인스턴스는 기본 URL, 타임아웃, 헤더 등의 설정을 포함합니다.
*/
const AxiosJwtInstance = axios.create({
baseURL: '<http://localhost:8080>',
timeout: 5000,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Credentials': true,
},
});
/**
* 요청 인터셉터 추가
* - 요청이 서버로 전송되기 전에 실행이 됩니다.
*/
AxiosJwtInstance.interceptors.request.use(
(config) => {
// BLACK_LIST 내에 요청 URL이 포함되는지 확인합니다.
if (!BLACK_LIST.some((url) => config.url?.includes(url))) {
const accessToken = localStorage.getItem('accessToken'); // 접근 토큰을 로컬스토리지 내에서 가져옵니다.
const refreshToken = localStorage.getItem('refreshToken'); // 리프레시 토큰을 로컬스토리지 내에서 가져옵니다.
// 발급받은 토큰을 확인합니다.
if (accessToken && refreshToken) {
// console.log('[+] 발급받은 토큰을 Header내에 추가합니다.');
config.headers['Authorization'] = `Bearer ${accessToken}`;
config.headers['x-refresh-token'] = `Bearer ${refreshToken}`;
}
}
return config;
},
(error) => {
console.log('[-] 요청 중 오류가 발생되었을때 수행이 됩니다. ', error);
return Promise.reject(error);
},
);
/**
* 응답 인터셉터 추가
* - 서버로부터 응답을 받은 후, 그 응답이 then/catch 핸들러로 전달되기 전에 실행됩니다.
*/
AxiosJwtInstance.interceptors.response.use(
(response) => {
// console.log('[+] 응답이 정상적으로 수행된 경우 수행이 됩니다. ', response);
// 상태코드가 401이고 accessToken이 존재하면, 토큰을 재발급 하고 API를 재 호출합니다
if (response.data.status === 401 && response.data.accessToken) {
const responseConfig = response.config; // 요청에 대한 환경 정보를 다시 가져옵니다.
localStorage.setItem('accessToken', response.data.accessToken); // 인증 토큰(access Token)을 재 갱신합니다.
responseConfig.headers['Authorization'] = response.data.accessToken; // API 재 호출 시 Authorization 값을 갱신하여 전달합니다.
return AxiosJwtInstance(responseConfig); // API를 재 호출합니다.
}
return response;
},
(error) => {
console.log('[-] 응답이 실패한 경우 수행이 됩니다. ', error);
return Promise.reject(error);
},
);
export default AxiosJwtInstance;
7) 결과 확인
1. 접근 토큰(Access Token) & 갱신 토큰(Refresh Token)이 유효한 경우
💡접근 토큰(Access Token) & 갱신 토큰(Refresh Token)이 유효한 경우
- 로그인을 수행하였을 때, 로컬 스토리지 내에 접근 토큰(Access Token)과 갱신 토큰(Refresh Token)이 발급되었습니다.
💡아래와 같이 정상적으로 리소스를 반환받음을 확인하였습니다.
2. 접근 토큰(Access Token)은 유효하지 않고 갱신 토큰(Refresh Token)은 유효한 경우
💡 접근 토큰(Access Token)은 유효하지 않고 갱신 토큰(Refresh Token)은 유효한 경우
- 접근 토큰은 만료가 되었고, 갱신 토큰은 유효한 경우 접근 토큰이 재발급됩니다.
💡아래의 과정에서 status가 401이고 , accessToken이 발급되었습니다.
- 해당 경우는 접근 토큰은 만료가 되었고, 갱신토큰이 유효한 경우입니다.
💡하지만, 클라이언트에서는 인터셉터를 통해서 아래의 과정에서 status가 401이고 , accessToken가 발급되는 경우에 다시 한번 API를 호출하도록 구성하여서 사용자의 입장에서는 무중단으로 API 호출이 이루어지는 상태를 확인할 수 있습니다.
3. 접근 토큰(Access Token)과 갱신 토큰(Refresh Token)이 유효하지 않은 경우
💡접근 토큰(Access Token)과 갱신 토큰(Refresh Token)이 유효하지 않은 경우
- 접근 토큰과 갱신 토큰이 유효하지 않는 경우에는 403 에러로 반환하고 있으며, 재 로그인이 필요하다는 메시지를 제공합니다.
💡 [참고] 위에 클라이언트 & API Server의 소스코드는 아래에서 확인이 가능합니다.
오늘도 감사합니다. 😀
반응형