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 Security

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications. Spring Security is a framework that focuses on providing both authentication and authoriz

spring.io

 

💡 [참고] 상세한 Spring Boot Seucrity에 대해 궁금하시면, 아래의 글을 참고하시면 도움이 됩니다.
 

[Java] Spring Boot Security 이해하기 -1 : 2.7.x 버전 구조 및 파일 이해

해당 글에서는 Spring Boot 기반의 Spring Security Framework를 적용하여 로그인의 API를 구성하는 방법에 대해서 공유합니다. 1) 개발환경 💡 Spring Security 개발 환경을 구성하기 위해 사용한 개발환경입니

adjh54.tistory.com

 

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. 실패 시, 클라이언트에게 오류 코드와 오류 메시지를 전달합니다.

 

 💡 [참고] 해당 프로세스에 대해 상세히 궁금하시면 아래의 글에 대해 참고하시면 도움이 됩니다.
 

[Java] Spring Boot Security 3.x + JWT 이해하기 -1 : 구조 및 Client / Server 처리과정

해당 환경에서는 Spring Boot Security 3.x 기준으로 JWT와 함께 이해하고 처리되는 과정에 대해 알아봅니다. 💡[참고] Spring Security 관련 글 및 Github Repository 경로입니다. 참고하시면 도움이 됩니다.분류

adjh54.tistory.com

 

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 환경을 구성한 형태에서 로그아웃을 테스트합니다.
- 아래의 환경 및 소스코드를 확인하여 구성한 뒤 이에 적용합니다.
 

[Java] Spring Boot Security 3.x + JWT 이해하기 -3 : Refresh Token 활용한 자동 갱신 방법

해당 글에서는 Spring Security 3.x 내에서 JWT를 이용하여 만료된 접근 토큰(Access Token)에 대해 Refresh Token을 이용하여 자동 갱신을 하는 과정에 대해 확인해 봅니다. 💡[참고] Spring Security 관련 글 및 Gi

adjh54.tistory.com

 

blog-codes/spring-boot2-security at main · adjh54ir/blog-codes

Contributor9 티스토리 블로그 내에서 활용한 내용들을 담은 레포지토리입니다. Contribute to adjh54ir/blog-codes development by creating an account on GitHub.

github.com

 

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가 구성되지 않았을 경우 아래의 글을 참고하여 구성합니다.
 

[Java] Spring Boot Redis 환경 구성 및 활용하기 -1 : 환경 구성 및 데이터 조작 방법

해당 글에서는 Spring Boot 환경에서 Redis를 다루는 방법에 대해 알아봅니다. 💡 [참고] Redis 관련해서 구성 내용에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.분류링크Redis(Remote Diction

adjh54.tistory.com

 

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 내에서 확인이 가능합니다.
 

blog-codes/spring-boot3-security at main · adjh54ir/blog-codes

Contributor9 티스토리 블로그 내에서 활용한 내용들을 담은 레포지토리입니다. Contribute to adjh54ir/blog-codes development by creating an account on GitHub.

github.com

 

 

blog-codes/react-login at main · adjh54ir/blog-codes

Contributor9 티스토리 블로그 내에서 활용한 내용들을 담은 레포지토리입니다. Contribute to adjh54ir/blog-codes development by creating an account on GitHub.

github.com

 

 

 

 

 

 

오늘도 감사합니다😀

 

 

 

반응형