Java/Spring Boot
[Java] Spring Boot Security 3.x + JWT 이해하기 -4 : 로그아웃 + 토큰 블랙 리스트 활용 방법
adjh54
2024. 11. 2. 17:49
반응형
해당 글에서는 Spring Boot Security 3.x 환경에서 JWT 기반 로그인 방법 중 로그아웃 기능과 블랙리스트를 적용하는 방법에 대해 알아봅니다.
💡 [참고] 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. 실패 시, 클라이언트에게 오류 코드와 오류 메시지를 전달합니다.
💡 [참고] 해당 프로세스에 대해 상세히 궁금하시면 아래의 글에 대해 참고하시면 도움이 됩니다.
3. 로그인 수행 이후 리소스 요청까지의 처리 관계
💡 로그인 수행 이후 리소스 요청까지의 처리 관계
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 환경을 구성한 형태에서 로그아웃을 테스트합니다.
- 아래의 환경 및 소스코드를 확인하여 구성한 뒤 이에 적용합니다.
2. 라이브러리 확인
💡 라이브러리 확인
- 아래와 같은 spring boot 3.3.4 버전 내에서 수행이 됩니다.
- 기존 소스코드 대비 추가적으로 spring-boot-starter-data-redis를 추가였습니다.
ext {
set('bootVer', "3.3.4")
set('jacksonVer', "2.16.1")
}
dependencies {
// [Spring Boot Starter]
implementation "org.springframework.boot:spring-boot-starter-web:${bootVer}" // Spring Boot Web
implementation "org.springframework.boot:spring-boot-starter-log4j2:${bootVer}" // Spring Boot Log4j2
implementation "org.springframework.boot:spring-boot-starter-security:${bootVer}" // Spring Boot Security
implementation "org.springframework.boot:spring-boot-starter-data-redis:${bootVer}" // Spring Boot Data Redis <<-- 해당 라이브러리 추가
// [OpenSource]
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' // MyBatis
implementation 'io.jsonwebtoken:jjwt:0.12.6' // JSON-WEB-TOKEN
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2' // DataType Converter
implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVer}" // Jackson Data Binding
implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jacksonVer}" // Jackson DatabindFormat Yaml
// [compile & runtime & test]
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.3'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
3. 로컬 Redis 구성
💡로컬 Redis 구성
- 로컬 Redis가 구성되지 않았을 경우 아래의 글을 참고하여 구성합니다.
3.1. 로컬 Redis 서비스 실행 확인
💡 아래와 같이 구성이 완료되었고 redis가 실행이 되었음을 확인하였습니다.
# 실행중인 서비스 리스트 확인
$ brew services list
# Redis CLI 접속
$ redis-cli
3.2. 로컬 Redis 설정 정보 : application.properties
💡application.properties 파일 내에 redis에 대한 기본 설정을 하였습니다.
spring:
data:
redis:
host: localhost
port: 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
*/
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/**
* Redis 와의 연결을 위한 'Connection'을 생성합니다.
*
* @return
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
/**
* Redis 데이터 처리를 위한 템플릿을 구성합니다.
* 해당 구성된 RedisTemplate을 통해서 데이터 통신으로 처리되는 대한 직렬화를 수행합니다.
*
* @return
*/
@Bean
public 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
*/
@Service
public interface TokenBlackListService {
void addTokenToList(String value); // Redis key-value 형태로 리스트 추가
boolean isContainToken(String value); // Redis key 기반으로 리스트 조회
List<Object> getTokenBlackList(); // Redis Key 기반으로 BlackList를 조회합니다.
void removeToken(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
@RequiredArgsConstructor
public class TokenBlackListServiceImpl implements TokenBlackListService {
private final RedisTemplate<String, Object> redisTemplate;
private final String REDIS_BLACK_LIST_KEY = "tokenBlackList";
/**
* BlackList 내에 토큰을 추가합니다.
*
* @param value
*/
@Override
public void addTokenToList(String value) {
redisTemplate.opsForList().rightPush(REDIS_BLACK_LIST_KEY, value);
}
/**
* BlackList 내에 토큰이 존재하는지 여부를 확인합니다.
*
* @param value
* @return
*/
@Override
public boolean isContainToken(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
*/
@Override
public void removeToken(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
@Service
public class CustomLogoutHandler implements LogoutHandler {
@Autowired
private TokenBlackListService tokenBlackListService;
/**
* 로그아웃 엔드포인트로 호출되면 이에 대해 처리합니다.
*
* @param request the HTTP request
* @param response the HTTP response
* @param authentication the current principal details
*/
@Override
public void logout(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) {
throw new 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
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Autowired
private TokenBlackListService tokenBlackListService;
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",
"/api/v1/user/logout"
);
@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] 블랙리스트에 포함된 토큰으로 접근하는 경우, 이를 막아줍니다.
if (tokenBlackListService.isContainToken(paramAccessToken)) {
throw new 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 {
throw new 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 {
throw new Exception("재 로그인이 필요합니다.");
}
}
// [STEP7-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);
}
}
}
6. WebSecurityConfig
💡WebSecurityConfig
- Spring Boot Security 환경 설정을 환경 클래스입니다.
- 해당 부분에서 로그아웃의 엔드포인트를 지정하고, 커스텀으로 구성한 logoutHandler를 등록합니다.
1. securityFilterChain()
- FilterChain을 통해서. logout에 대해 configureLogout로 처리를 인가합니다.
2. configureLogout()
- 로그아웃에 대한 설정 처리를 수행합니다. 로그아웃을 위한 엔드포인트를 지정하고, 지정된 엔드포인트 호출이 왔을 때 이에 대한 처리는 customLogoutHandler()에서 수행하고 로그아웃이 성공하였을 때 결과 200 처리를 수행합니다.
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
/**
* 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드입니다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception Exception
*/
@Bean
public 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
*/
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
*/
@Bean
public LogoutHandler customLogoutHandler() {
return new 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
@EnableWebSecurity
public class WebSecurityConfig {
/**
* 1. 정적 자원(Resource)에 대해서 인증된 사용자가 정적 자원의 접근에 대해 ‘인가’에 대한 설정을 담당하는 메서드입니다.
*
* @return WebSecurityCustomizer
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
// 정적 자원에 대해서 Security를 적용하지 않음으로 설정
return web -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
/**
* 2. HTTP에 대해서 ‘인증’과 ‘인가’를 담당하는 메서드이며 필터를 통해 인증 방식과 인증 절차에 대해서 등록하며 설정을 담당하는 메서드입니다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception Exception
*/
@Bean
public 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
*/
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(customAuthenticationProvider());
}
/**
* 4. '인증' 제공자로 사용자의 이름과 비밀번호를 데이터베이스에 제공하여 반환받습니다.
* - 과정: CustomAuthenticationFilter → AuthenticationManager(interface) → CustomAuthenticationProvider(implements)
*
* @return CustomAuthenticationProvider
*/
@Bean
public CustomAuthenticationProvider customAuthenticationProvider() {
return new CustomAuthenticationProvider(bCryptPasswordEncoder());
}
/**
* 5. 비밀번호를 암호화하기 위한 BCrypt 인코딩을 통하여 비밀번호에 대한 암호화를 수행합니다.
*
* @return BCryptPasswordEncoder
*/
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 6. 커스텀을 수행한 '인증' 필터로 접근 URL, 데이터 전달방식(form) 등 인증 과정 및 인증 후 처리에 대한 설정을 구성하는 메서드입니다.
*
* @return CustomAuthenticationFilter
*/
@Bean
public 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
*/
@Bean
public CustomAuthSuccessHandler customLoginSuccessHandler() {
return new CustomAuthSuccessHandler();
}
/**
* 8. Spring Security 기반의 사용자의 정보가 '맞지 않을 경우' 수행이 되며 결과값을 리턴해주는 Handler
*
* @return CustomAuthFailureHandler
*/
@Bean
public CustomAuthFailureHandler customLoginFailureHandler() {
return new CustomAuthFailureHandler();
}
/**
* 9. JWT 토큰을 통하여서 사용자를 인증합니다.
*
* @return JwtAuthorizationFilter
*/
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter();
}
/**
* 10. CORS에 대한 설정을 커스텀으로 구성합니다.
*
* @return CorsConfigurationSource
*/
@Bean
public 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
*/
@Bean
public LogoutHandler customLogoutHandler() {
return new CustomLogoutHandler();
}
}
5) 실행 결과 확인
1. 정상적인 로그인 수행
💡 정상적인 로그인 수행
- 사용자 인증이 가능한 아이디와 비밀번호를 기반으로 로그인을 수행합니다.
2. 로그인에 대한 접근 토큰(accessToken), 갱신 토큰(refreshToken) 확인
💡 로그인에 대한 접근 토큰(accessToken), 갱신 토큰(refreshToken) 확인
- 사용자의 인증을 마친 뒤 인가로 접근 토큰과 갱신 토큰을 발급받았습니다.
💡 아래와 같은 토큰 정보를 얻었습니다.
- 해당 토큰은 로그아웃 수행 이후 '리소스 접근'을 하였을 때도 유효한지 확인하기 위해 정보를 확인합니다.
// accessToken
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInJlZ0RhdGUiOjE3MzA1MzE5ODg0NDZ9.eyJ1c2VyTm0iOiJsZWUiLCJ1c2VySWQiOiJhZGpoNTQiLCJzdWIiOiIxIiwiZXhwIjoxNzMwNTMyODg4fQ.6-fCOejLcEPL_Kdpp0lnBclAGJb7R1gWO5o6dRL7wKw
// refreshToken
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInJlZ0RhdGUiOjE3MzA1MzE5ODg0NDl9.eyJ1c2VySWQiOiJhZGpoNTQiLCJzdWIiOiIxIiwiZXhwIjoxNzMxNzQxNTg4fQ.ntuUqqt0XO5UyHCncksy3FqmlY1A_G5pqFxnm1KwdQo
3. 로그아웃 수행
💡로그아웃 수행
- 로그아웃을 수행했을 때, 클라이언트에서는 accessToken을 localstorage 내에서 삭제하고 /api/v1/user/logout으로 호출을 수행합니다.
const eventHandler = (() => {
return {
logout: async() => {
/**
* 로그아웃 서비스 호출
*/
await LoginService.logout()
.then((res) => {
console.log('결과 값 :: ', res.data);
})
.catch((error) => {
console.log(`error :: ${error} `);
});
localStorage.removeItem('accessToken');
navigation('/login');
},
};
})();
4. 위에서 발급받은 accessToken, refreshToken으로 리소스 접근
💡 위에서 발급받은 accessToken, refreshToken으로 리소스 접근
- 기존 문제점에서 로그아웃 이후 로컬 스토리지 내에서 삭제되었던 접근 토큰으로 리소스를 접근하였을 때, 접근이 가능하다는 문제가 있었는데, 아래와 같은 경우는 "에러 메시지"를 반환하도록 처리가 되었습니다.
5. Redis 데이터 확인
💡Redis 데이터 확인
- 비즈니스 로직에서 구성한 “tokenBlackList”라는 키값으로 조회를 하였습니다. 실제 Redis 내에 토큰이 존재하는지 확인하기 위한 과정입니다.
- 아래와 같이 로그아웃 이전에 발급받은 AccessToken을 조회하였을 때 아래와 같은 리스트 내에 값이 추가됨을 확인하였습니다.
# Redis에서 key "tokenBlackList"를 기반으로 값을 모두 조회합니다.
$ LRANGE tokenBlackList 0 -1
💡 [참고] 위에서 구성한 Spring Boot Server는 아래의 Repository 내에서 확인이 가능합니다.
오늘도 감사합니다😀
반응형