- 해당 과정에서는 클라이언트에서 로그인 정보를 전달하면, 데이터베이스에서 이를 조회하여 ‘인증’을 수행합니다. - 이 인증이 완료된 경우, 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. 실패 시, 클라이언트에게 오류 코드와 오류 메시지를 전달합니다.
💡 [참고] 해당 프로세스에 대해 상세히 궁금하시면 아래의 글에 대해 참고하시면 도움이 됩니다.
💡 로그인 수행 이후 리소스 요청까지의 처리 관계 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 키 값에 접근/갱신 토큰을 함께 전송합니다.
4. 리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
💡 리소스 요청 이후 접근/리프레시 토큰을 확인하고 리소스를 접근 처리 관계
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. 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.
2) Spring Boot Security 로그아웃 문제점 확인
💡Spring Boot Security 로그아웃
- JWT를 기반으로 접속하는 사용자에게 ‘인증’과 ‘인가’를 수행합니다. 로그아웃을 수행하게 되면, 이 사용자에 대해서 ‘인증’과 ‘인가’에 대해 허용을 하지 않도록 해야 합니다. - 그러나, JWT 내에서는 토큰 자체의 ‘토큰 만료’가 불가능하기에 ‘로그 아웃’을 했음에도 JWT를 기반으로 재 접근을 하게 되면 리소스에 접근을 할 수 있다는 문제점이 있습니다.
1. 기존 로직 문제점 -1: 클라이언트의 로그인
💡기존 로직 문제점 -1: 클라이언트의 로그인
- 클라이언트에서 로그인을 하는 경우 아래와 같이 로컬 스토리지 내에 accessToken, refreshToken 형태로 저장이 되었습니다.
2. 기존 로직 문제점 -2 : 클라이언트의 로그아웃
💡 기존 로직 문제점 -2 : 클라이언트의 로그아웃
- 클라이언트에서 로그아웃을 수행하면 로컬 스토리지 내에 accessToken을 삭제합니다. 그러나 해당 로컬 스토리지 accessToken은 만료되지 않은 상태로 남아있습니다.
3. 기존 로직 문제점 -3 : postman 기반 토큰으로 접근
💡 기존 로직 문제점 -3 : postman 기반 토큰으로 접근
- 로그아웃을 했음에도 AccessToken으로 접근을 하게 되면 리소스에 접근을 할 수 있다는 점이 확인되었습니다.
3) Spring Boot Security 로그아웃
💡Spring Boot Security 로그아웃
- 사용자의 인증 상태를 종료하고 리소스에 대한 접근을 차단하는 기능을 수행합니다. 이를 위해서 클라이언트 측 토큰을 삭제하고 서버 측 토큰을 무효화를 동시에 수행해야 합니다. - 그렇지 않으면 로컬스토리지 내에 저장되었던 토큰을 통해 리소스 접근 및 탈취가 가능해집니다.
1. Spring Boot Security 로그아웃 구현 방식
💡 Spring Boot Security 로그아웃 구현 방식
- 로그아웃을 구현하는 방식으로는 토큰 블랙리스트 관리 방법, 토큰 만료 시간 단축, 새로운 시크릿 키 발급 등이 있습니다.
로그아웃 구현 방식
설명
토큰 블랙리스트 관리
- 로그아웃된 토큰을 블랙리스트에 추가하여 해당 토큰의 추가 사용을 방지합니다.
토큰 만료 시간 단축
- JWT 토큰의 만료 시간을 짧게 설정하여 로그아웃 효과를 얻습니다.
새로운 시크릿 키 발급
- 로그아웃 시 서버의 JWT 서명 키를 변경하여 기존 토큰을 무효화합니다.
1.1. 토큰 블랙리스트 관리 방법
💡 토큰 블랙 리스트 관리 방법
- 로그아웃된 토큰을 블랙리스트에 추가하여 해당 토큰의 추가 사용을 방지하는 방법을 의미합니다. - Redis나 데이터베이스를 사용하여 블랙리스트를 저장합니다. - 로그아웃 시 해당 토큰을 블랙리스트에 추가합니다. 모든 요청에 대해 토큰 검증 시 블랙리스트를 확인합니다. - 즉시 토큰을 무효화할 수 있어 보안성이 높지만 블랙리스트 관리에 추가적인 리소스가 필요합니다.
[ 더 알아보기 ] 💡 블랙리스트(Blacklist)
- 더 이상 유효하지 않거나 사용이 금지된 항목들의 목록을 의미합니다.
💡 화이트리스트(Whitelist)
- 블랙리스트와 반대되는 개념으로, 명시적으로 허용된 항목들의 목록을 의미합니다.
1.2. 토큰 만료 시간 단축 방법
💡 토큰 만료 시간 단축 방법
- JWT 토큰의 만료 시간을 짧게 설정하여 로그아웃 효과를 얻는 방법을 의미합니다. - 토큰 생성 시 만료 시간을 짧게 설정합니다(예: 15분). - 클라이언트는 주기적으로 새 토큰을 요청해야 합니다. - 서버 측에서 추가적인 관리가 필요 없지만 완전한 로그아웃은 어렵다는 단점이 있습니다.
1.3. 새로운 시크릿 키 발급 방법
💡새로운 시크릿 키 발급 방법 - 로그아웃 요청 시 서버의 JWT 서명 키를 새로 생성하는 방식을 의미합니다. - 새 키로 서명된 토큰만 유효하게 처리합니다. - 한 번에 모든 기존의 토큰을 무효화할 수 있지만 한 번에 모든 사용자가 재 로그인을 해야 한다는 큰 단점이 있습니다.
2. 토큰 블랙리스트 처리 과정
💡 토큰 블랙리스트 처리 과정
- 기존의 접근 토큰에 대해 JWT 자체를 만료할 수 없기에 만료시간이 될 때까지 유효하다는 문제점이 있었습니다. - 이에 대한 최적의 방법으로 토큰 블랙리스트를 이용한 처리 방법에 대해 상세히 알아봅니다.
2.1. 로그아웃에 대한 처리
💡 로그아웃에 대한 처리
- 사용자가 로그아웃을 수행할 경우 내부적인 처리 과정을 확인합니다.
1. 사용자가 로그아웃을 수행할 때, 클라이언트에서는 local storage 내에 accessToken을 삭제하고 지정한 "http:/localhost:8080/api/v1/user/logout" 엔드포인트로 Header 내에 accessToken을 담아서 API를 호출합니다.
2. Spring Boot API Server에서는 Redis DB를 호출하고 Key는 ‘tokenBlackList’로 Value로는 전달받은 ‘accessToken’ 값을 리스트 형태로 저장합니다. ex) {"tokenBlackList" : ["accessToken1", "accessToken2"]}
2.2. 과거 접근 토큰으로 리소스 접근 처리
💡 과거 접근 토큰으로 리소스 접근 처리
- 해당 경우는 과거에 로그아웃 하기 이전 로그인 당시에 발급받은 접근 토큰(Aceess Token)을 기반으로 리소스를 접근했을 때, 처리과정에 대해 알아봅니다.
1. 과거의 접근 토큰, 갱신 토큰을 기반으로 리소스를 접근합니다. - http://localhost:8080/api/v1/user/user 호출 (* 로그아웃 이전에 사용하였던 만료되지 않은 토큰으로 접근을 합니다.)
2. TokenFilter 내에서 Redis의 Black List를 조회합니다.
3. Redis 내에 "Key : tokenBlackList"로 전달받은 accessToken을 조회합니다.
4. Black List 내의 포함여부를 반환해 줍니다.(boolean)
5. 포함되는 경우, 에러메시지를 반환해 주며, 포함되지 않는 경우 리소스 접근을 허용합니다.
4) Spring Boot Security 로그아웃 : 토큰 블랙리스트 방법 환경 설정
1. 기존의 구성된 환경에서 수행을 합니다.
💡 기존의 구성된 환경에서 수행을 합니다.
- 기존에 Spring Boot Security 3.x + JWT 환경을 구성한 형태에서 로그아웃을 테스트합니다. - 아래의 환경 및 소스코드를 확인하여 구성한 뒤 이에 적용합니다.
# 실행중인 서비스 리스트 확인$ brew services list
# Redis CLI 접속$ redis-cli
3.2. 로컬 Redis 설정 정보 : application.properties
💡application.properties 파일 내에 redis에 대한 기본 설정을 하였습니다.
spring:data:redis:host:localhostport:6379
3.3. 로컬 Redis Template 구성 : RedisConfig
💡 로컬 Redis Template 구성 : RedisConfig
- Redis와의 연결을 위해 설정 정보를 기반으로 연결하며, 전달받을 데이터에 대해 직렬화를 수행합니다.
package com.adjh.springboot3security.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 환경 설정
*
* @author : jonghoon
* @fileName : RedisConfig
* @since : 11/2/24
*/@ConfigurationpublicclassRedisConfig{
@Value("${spring.data.redis.host}")private String host;
@Value("${spring.data.redis.port}")privateint port;
/**
* Redis 와의 연결을 위한 'Connection'을 생성합니다.
*
* @return
*/@Beanpublic RedisConnectionFactory redisConnectionFactory(){
returnnew LettuceConnectionFactory(host, port);
}
/**
* Redis 데이터 처리를 위한 템플릿을 구성합니다.
* 해당 구성된 RedisTemplate을 통해서 데이터 통신으로 처리되는 대한 직렬화를 수행합니다.
*
* @return
*/@Beanpublic RedisTemplate<String, Object> redisTemplate(){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// Redis를 연결합니다.
redisTemplate.setConnectionFactory(redisConnectionFactory());
// Key-Value 형태로 직렬화를 수행합니다.
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
// Hash Key-Value 형태로 직렬화를 수행합니다.
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
// 기본적으로 직렬화를 수행합니다.
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
4. Redis 활용 서비스 구성
💡Redis 활용 서비스 구성
- 로그인하였을때 발급받은 JWT를 로그인 이후에 '로그아웃 시점'에 만료시키기 위해 Redis 내의 BlackList에 추가하고 관리하기 위해 사용되는 서비스를 구성하였습니다.
4.1. TokenBlackListService.java(interface)
💡TokenBlackListService.java(interface)
- Redis를 활용하기 위한 service interface를 구성하였습니다. 이를 통해 Redis에 접근하여 데이터를 추가하고 조작할 수 있습니다.
package com.adjh.springboot3security.service;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Redis 내에 Token BlackList를 관리하는 서비스입니다.
*
* @author : jonghoon
* @fileName : RedisTokenService
* @since : 11/2/24
*/@ServicepublicinterfaceTokenBlackListService{
voidaddTokenToList(String value); // Redis key-value 형태로 리스트 추가booleanisContainToken(String value); // Redis key 기반으로 리스트 조회List<Object> getTokenBlackList(); // Redis Key 기반으로 BlackList를 조회합니다.voidremoveToken(String value); // Redis Key 기반으로 리스트 내 요소 제거
}
4.2. TokenBlackListServiceImpl.java
💡 TokenBlackListServiceImpl.java
- Redis 서비스에 대한 비즈니스 로직을 처리하는 구현체를 구성하였습니다. - 단일 기능만 하기에 Redis의 키 값은 "tokenBlackList" 변수로 고정하였습니다.
1. addTokenToList() - Redis에서 관리하는 리스트 내에 토큰을 추가합니다. 이를 통해서 BLACK LIST Token을 관리합니다.
2. isContainToken() - Redis의 KEY를 기반으로 값들을 조회하는 로직입니다. 이를 통해 토큰이 포함되는지 여부를 반환합니다.
3. getTokenBlackList() - Redis의 KEY를 기반으로 모든 값들을 조회하는 로직입니다.
4. removeToken() - Redis의 KEY를 기반으로 조회된 값 중에 파라미터로 전달받은 값을 제외하는 로직입니다.
package com.adjh.springboot3security.service.impl;
import com.adjh.springboot3security.service.TokenBlackListService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Redis 내에 Token BlackList를 관리하는 서비스 구현체입니다.
*
* @author : jonghoon
* @fileName : RedisTokenService
* @since : 11/2/24
*/@Service@RequiredArgsConstructorpublicclassTokenBlackListServiceImplimplementsTokenBlackListService{
privatefinal RedisTemplate<String, Object> redisTemplate;
privatefinal String REDIS_BLACK_LIST_KEY = "tokenBlackList";
/**
* BlackList 내에 토큰을 추가합니다.
*
* @param value
*/@OverridepublicvoidaddTokenToList(String value){
redisTemplate.opsForList().rightPush(REDIS_BLACK_LIST_KEY, value);
}
/**
* BlackList 내에 토큰이 존재하는지 여부를 확인합니다.
*
* @param value
* @return
*/@OverridepublicbooleanisContainToken(String value){
List<Object> allItems = redisTemplate.opsForList().range(REDIS_BLACK_LIST_KEY, 0, -1);
return allItems.stream()
.anyMatch(item -> item.equals(value));
}
/**
* BlackList 항목을 모두 조회합니다.
*
* @return
*/public List<Object> getTokenBlackList(){
return redisTemplate.opsForList().range(REDIS_BLACK_LIST_KEY, 0, -1);
}
/**
* BlackList 내에서 항목을 제거합니다.
*
* @param value
*/@OverridepublicvoidremoveToken(String value){
redisTemplate.opsForList().remove(REDIS_BLACK_LIST_KEY, 0, value);
}
}
5. CustomLogoutHandler
💡 CustomLogoutHandler - 로그아웃에 대한 처리를 관리하는 Handler입니다. LogoutHandler 인터페이스로부터 상속을 받아서 logout() 메서드를 구현합니다. - 로그아웃으로 지정한 엔드포인트(api/v1/user/login)로 지정한 곳으로 HTTP(S) 호출이 오면 아래의 Handler의 비즈니스 로직이 수행이 됩니다. - 위에서 구성한 Redis Service를 다루기 위한 서비스를 선언하고 활용합니다.
1. 요청 값에서 토큰을 추출합니다.
2. [STEP2-1] 토큰이 존재하는 경우 - 3. 실제 토큰 값을 확인합니다.(Bearer 형태가 아닌 토큰 값만 추출합니다) - 4. Redis 내에 토큰이 존재하지 않는 경우 - 5. BlackList를 추가합니다.
3. [STEP2-2] 토큰이 존재하지 않는 경우 - 에러 메시지를 반환합니다.
package com.adjh.springboot3security.config.handler;
import com.adjh.springboot3security.common.utils.TokenUtils;
import com.adjh.springboot3security.service.TokenBlackListService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 로그아웃에 대한 처리를 관리하는 Handler입니다.
*
* @author : jonghoon
* @fileName : UserLogoutService
* @since : 11/2/24
*/@Slf4j@ServicepublicclassCustomLogoutHandlerimplementsLogoutHandler{
@Autowiredprivate TokenBlackListService tokenBlackListService;
/**
* 로그아웃 엔드포인트로 호출되면 이에 대해 처리합니다.
*
* @param request the HTTP request
* @param response the HTTP response
* @param authentication the current principal details
*/@Overridepublicvoidlogout(HttpServletRequest request, HttpServletResponse response, Authentication authentication){
log.debug("[+] 로그아웃이 수행이 됩니다.");
// [STEP1] 요청 값에서 토큰을 추출합니다.
String headerToken = request.getHeader("Authorization");
// [STEP2-1] 토큰이 존재하는 경우if (headerToken != null) {
// [STEP3] 실제 토큰 값을 확인합니다.
String token = TokenUtils.getHeaderToToken(headerToken);
// [STEP4] Redis 내에 토큰이 존재하지 않는 경우if (!tokenBlackListService.isContainToken(token)) {
// [STEP5] BlackList를 추가합니다.
tokenBlackListService.addTokenToList(token);
List<Object> blackList = tokenBlackListService.getTokenBlackList(); // BlackList를 조회합니다.
log.debug("[+] blackList : " + blackList);
}
}
// [STEP2-2] 토큰이 존재하지 않는 경우else {
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("userInfo", null);
resultMap.put("resultCode", 9999);
resultMap.put("failMsg", "로그아웃 과정에서 문제가 발생하였습니다.");
ObjectMapper objectMapper = new ObjectMapper();
String jsonResponse = null;
PrintWriter printWriter = null;
try {
jsonResponse = objectMapper.writeValueAsString(resultMap);
printWriter = response.getWriter();
} catch (IOException e) {
thrownew RuntimeException(e);
}
printWriter.print(jsonResponse);
printWriter.flush();
printWriter.close();
}
}
}
5. JwtAuthorizationFilter.java
💡JwtAuthorizationFilter.java
- JWT 권한 관련 필터 내에 블랙리스트 내에 포함된 토큰으로 접근 시 접근이 불가능하도록 처리를 수행합니다. - 사전에 구성한 tokenBlackListService 서비스 내의 isContainToken() 메서드를 통해서 BLACK LIST 내에 토큰이 존재하는지 확인하여 존재하면 다음 프로세스를 진행하지 않고 에러 메시지를 제공합니다.
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.adjh.springboot3security.service.TokenBlackListService;
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.beans.factory.annotation.Autowired;
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@ComponentpublicclassJwtAuthorizationFilterextendsOncePerRequestFilter{
@Autowiredprivate TokenBlackListService tokenBlackListService;
privatestaticfinal String HTTP_METHOD_OPTIONS = "OPTIONS";
privatestaticfinal String ACCESS_TOKEN_HEADER_KEY = "Authorization";
privatestaticfinal String REFRESH_TOKEN_HEADER_KEY = "x-refresh-token";
privatestaticfinal List<String> WHITELIST_URLS = Arrays.asList(
"/api/v1/user/login",
"/api/v1/token/token",
"/user/login",
"/token/token",
"/api/v1/user/logout"
);
@OverrideprotectedvoiddoFilterInternal(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] 블랙리스트에 포함된 토큰으로 접근하는 경우, 이를 막아줍니다.if (tokenBlackListService.isContainToken(paramAccessToken)) {
thrownew Exception("<< 경고 >>만료된 토큰으로 접근하려합니다!!!");
}
// [STEP4] 접근 토큰(Access Token)의 유효성을 체크합니다.
ValidTokenDto accTokenValidDto = TokenUtils.isValidToken(paramAccessToken);
// [STEP5-1] 접근 토큰이 유효하다면 다음 프로세스를 진행합니다.if (accTokenValidDto.isValid()) {
// [STEP6] 접근 토큰(Access Token)내에 전달하려는 사용자 정보를 확인합니다.// [STEP6-1] 사용자 정보가 존재한다면 다음 필터로 이동을 합니다.if (StringUtils.isNotBlank(TokenUtils.getClaimsToUserId(paramAccessToken))) {
chain.doFilter(request, response);
}
// [STEP6-2] 사용자 정보가 존재하지 않는 다면 에러 메시지를 클라이언트에게 전달합니다.else {
thrownew Exception("토큰 내에 사용자 아이디가 존재하지 않습니다");
}
}
// [STEP5-2] 접근 토큰이 존재하지 않으면 접근 토큰의 에러 정보를 확인합니다.else {
// [STEP6] 접근 토큰(Access Token)에서 발생한 오류가 만료된 (TOKEN_EXPIRED)오류 인지를 체크합니다.// [STEP6-1] 오류가 토큰이 만료된 오류 인 경우 다음 프로세스를 진행합니다.if (accTokenValidDto.getErrorName().equals("TOKEN_EXPIRED")) {
// [STEP7] 리프레시 토큰(Refresh Token)이 유효한지 체크를 합니다.// [STEP7-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); // 리소스로 접근을 허용합니다.
}
// [STEP7-2] 리프레시 토큰이 유효하지 않다면 에러 메시지를 클라이언트에게 전달합니다.else {
thrownew Exception("재 로그인이 필요합니다.");
}
}
// [STEP7-2] 오류가 토큰이 만료된 경우가 아닌 경우 에러 메시지를 클라이언트에게 전달합니다.thrownew Exception("토큰이 유효하지 않습니다."); // 토큰이 유효하지 않은 경우
}
}
// [STEP2-2] 토큰이 존재하지 않으면 “토큰이 존재하지 않습니다”라는 에러메시지를 클라이언트에게 전달합니다.else {
thrownew 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내에서 오류 발생 시elseif (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
*/privatevoidsendToClientAccessToken(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);
}
}
}
6. WebSecurityConfig
💡WebSecurityConfig
- Spring Boot Security 환경 설정을 환경 클래스입니다. - 해당 부분에서 로그아웃의 엔드포인트를 지정하고, 커스텀으로 구성한 logoutHandler를 등록합니다.
1. securityFilterChain() - FilterChain을 통해서. logout에 대해 configureLogout로 처리를 인가합니다. 2. configureLogout() - 로그아웃에 대한 설정 처리를 수행합니다. 로그아웃을 위한 엔드포인트를 지정하고, 지정된 엔드포인트 호출이 왔을 때 이에 대한 처리는 customLogoutHandler()에서 수행하고 로그아웃이 성공하였을 때 결과 200 처리를 수행합니다.
@Slf4j@Configuration@EnableWebSecuritypublicclassWebSecurityConfig{
/**
* 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드입니다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception Exception
*/@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 커스텀 설정 적용
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // 우선 모든 요청에 대한 허용
.addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class) // JWT 인증 (커스텀 필터)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용 (JWT 사용)
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 사용자 인증(커스텀 필터)
.formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화
.logout(this::configureLogout) // 로그아웃 처리를 합니다.
.build();
}
// ... 중간 내용 생략/**
* 11. 로그아웃에 대한 설정을 관리합니다.
*
* @param logout
*/privatevoidconfigureLogout(LogoutConfigurer<HttpSecurity> logout){
logout
// 1. 로그아웃 엔드포인트를 지정합니다.
.logoutUrl("/api/v1/user/logout")
// 2. 엔드포인트 호출에 대한 처리 Handler를 구성합니다.
.addLogoutHandler(customLogoutHandler())
// 3. 로그아웃 처리가 완료되었을때 처리를 수행합니다.
.logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK));
}
/**
* 12. 로그아웃 처리를 위한 Handler를 커스텀으로 구성합니다.
*
* @return
*/@Beanpublic LogoutHandler customLogoutHandler(){
returnnew CustomLogoutHandler();
}
}
package com.adjh.springboot3security.config;
import com.adjh.springboot3security.config.filter.CustomAuthenticationFilter;
import com.adjh.springboot3security.config.filter.JwtAuthorizationFilter;
import com.adjh.springboot3security.config.handler.CustomAuthFailureHandler;
import com.adjh.springboot3security.config.handler.CustomAuthSuccessHandler;
import com.adjh.springboot3security.config.handler.CustomAuthenticationProvider;
import com.adjh.springboot3security.config.handler.CustomLogoutHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security 환경 설정을 구성하기 위한 설정 클래스입니다.
* 웹 서비스가 로드 될때 Spring Container 의해 관리가 되는 클래스이며 사용자에 대한 ‘인증’과 ‘인가’에 대한 구성을 Bean 메서드로 주입을 합니다.
*
* @author : jonghoon
* @fileName : WebSecurityConfig
* @since : 10/1/24
*/@Slf4j@Configuration@EnableWebSecuritypublicclassWebSecurityConfig{
/**
* 1. 정적 자원(Resource)에 대해서 인증된 사용자가 정적 자원의 접근에 대해 ‘인가’에 대한 설정을 담당하는 메서드입니다.
*
* @return WebSecurityCustomizer
*/@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {
// 정적 자원에 대해서 Security를 적용하지 않음으로 설정return web -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
/**
* 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드입니다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception Exception
*/@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 커스텀 설정 적용
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // 우선 모든 요청에 대한 허용
.addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class) // JWT 인증 (커스텀 필터)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용 (JWT 사용)
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 사용자 인증(커스텀 필터)
.formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화
.logout(this::configureLogout) // 로그아웃 처리를 합니다.
.build();
}
/**
* 3. authenticate 의 인증 메서드를 제공하는 매니져로'Provider'의 인터페이스를 의미합니다.
* - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
*
* @return AuthenticationManager
*/@Beanpublic AuthenticationManager authenticationManager() {
return new ProviderManager(customAuthenticationProvider());
}
/**
* 4. '인증' 제공자로 사용자의 이름과 비밀번호를 데이터베이스에 제공하여 반환받습니다.
* - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
*
* @return CustomAuthenticationProvider
*/@Beanpublic CustomAuthenticationProvider customAuthenticationProvider() {
return new CustomAuthenticationProvider(bCryptPasswordEncoder());
}
/**
* 5. 비밀번호를 암호화하기 위한 BCrypt 인코딩을 통하여 비밀번호에 대한 암호화를 수행합니다.
*
* @return BCryptPasswordEncoder
*/public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 6. 커스텀을 수행한 '인증' 필터로 접근 URL, 데이터 전달방식(form) 등 인증 과정 및 인증 후 처리에 대한 설정을 구성하는 메서드입니다.
*
* @return CustomAuthenticationFilter
*/@Beanpublic CustomAuthenticationFilter customAuthenticationFilter() {
CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
customAuthenticationFilter.setFilterProcessesUrl("/api/v1/user/login"); // 접근 URL
customAuthenticationFilter.setAuthenticationSuccessHandler(customLoginSuccessHandler()); // '인증' 성공 시 해당 핸들러로 처리를 전가한다.
customAuthenticationFilter.setAuthenticationFailureHandler(customLoginFailureHandler()); // '인증' 실패 시 해당 핸들러로 처리를 전가한다.
customAuthenticationFilter.afterPropertiesSet();
return customAuthenticationFilter;
}
/**
* 7. Spring Security 기반의 사용자의 정보가 '맞을 경우' 수행이 되며 결과값을 리턴해주는 Handler
*
* @return CustomLoginSuccessHandler
*/@Beanpublic CustomAuthSuccessHandler customLoginSuccessHandler() {
return new CustomAuthSuccessHandler();
}
/**
* 8. Spring Security 기반의 사용자의 정보가 '맞지 않을 경우' 수행이 되며 결과값을 리턴해주는 Handler
*
* @return CustomAuthFailureHandler
*/@Beanpublic CustomAuthFailureHandler customLoginFailureHandler() {
return new CustomAuthFailureHandler();
}
/**
* 9. JWT 토큰을 통하여서 사용자를 인증합니다.
*
* @return JwtAuthorizationFilter
*/@Beanpublic JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter();
}
/**
* 10. CORS에 대한 설정을 커스텀으로 구성합니다.
*
* @return CorsConfigurationSource
*/@Beanpublic CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("<http://localhost:3000>")); // 허용할 오리진
configuration.setAllowedMethods(List.of("*")); // 허용할 HTTP 메서드
configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
configuration.setAllowCredentials(true); // 인증 정보 허용
configuration.setMaxAge(3600L); // 프리플라이트 요청 결과를 3600초 동안 캐시
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 이 설정 적용return source;
}
/**
* 11. 로그아웃에 대한 설정을 관리합니다.
*
* @param logout
*/private void configureLogout(LogoutConfigurer<HttpSecurity> logout) {
logout
// 1. 로그아웃 엔드포인트를 지정합니다.
.logoutUrl("/api/v1/user/logout")
// 2. 엔드포인트 호출에 대한 처리 Handler를 구성합니다.
.addLogoutHandler(customLogoutHandler())
// 3. 로그아웃 처리가 완료되었을때 처리를 수행합니다.
.logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK));
}
/**
* 12. 로그아웃 처리를 위한 Handler를 커스텀으로 구성합니다.
*
* @return
*/@Beanpublic LogoutHandler customLogoutHandler() {
return new CustomLogoutHandler();
}
}
5) 실행 결과 확인
1. 정상적인 로그인 수행
💡 정상적인 로그인 수행
- 사용자 인증이 가능한 아이디와 비밀번호를 기반으로 로그인을 수행합니다.
2. 로그인에 대한 접근 토큰(accessToken), 갱신 토큰(refreshToken) 확인
💡 로그인에 대한 접근 토큰(accessToken), 갱신 토큰(refreshToken) 확인
- 사용자의 인증을 마친 뒤 인가로 접근 토큰과 갱신 토큰을 발급받았습니다.
💡 아래와 같은 토큰 정보를 얻었습니다.
- 해당 토큰은 로그아웃 수행 이후 '리소스 접근'을 하였을 때도 유효한지 확인하기 위해 정보를 확인합니다.
- 기존 문제점에서 로그아웃 이후 로컬 스토리지 내에서 삭제되었던 접근 토큰으로 리소스를 접근하였을 때, 접근이 가능하다는 문제가 있었는데, 아래와 같은 경우는 "에러 메시지"를 반환하도록 처리가 되었습니다.
5. Redis 데이터 확인
💡Redis 데이터 확인
- 비즈니스 로직에서 구성한 “tokenBlackList”라는 키값으로 조회를 하였습니다. 실제 Redis 내에 토큰이 존재하는지 확인하기 위한 과정입니다. - 아래와 같이 로그아웃 이전에 발급받은 AccessToken을 조회하였을 때 아래와 같은 리스트 내에 값이 추가됨을 확인하였습니다.
# Redis에서 key "tokenBlackList"를 기반으로 값을 모두 조회합니다.$ LRANGE tokenBlackList 0 -1
💡 [참고] 위에서 구성한 Spring Boot Server는 아래의 Repository 내에서 확인이 가능합니다.