[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (6) - 로그아웃

김찬미·2024년 7월 9일
0
post-thumbnail

프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git

✅ 로그아웃(Logout)이란?

웹 개발에서 로그아웃이란 사용자가 시스템에서 계정을 안전하게 로그아웃하는 과정을 의미한다. 이는 사용자가 더 이상 웹 애플리케이션에 접근하지 못하도록 하여 애플리케이션의 보안을 유지하는 필수적인 기능이다.

🔒 JWT를 사용한 로그아웃

JWT는 무상태stateless 인증 방식으로, 사용자가 로그인할 때 서버는 사용자 정보를 포함한 JWT를 생성하여 클라이언트에 전달한다. 이런 방식에서의 로그아웃은 세션 기반 인증과 다르며, 다음과 같은 과정을 거친다.

  • 1) 클라이언트 측 토큰 삭제: 사용자가 로그아웃하면 클라이언트에서 JWT를 삭제하여 더 이상 인증된 요청을 보낼 수 없게 한다.

  • 2) 서버 측 블랙리스트 처리: 서버는 로그아웃된 JWT를 블랙리스트에 추가하여 해당 토큰을 무효화한다.

  • 3) 토큰 유효성 검사: 서버는 각 요청 시 블랙리스트를 확인하여 블랙리스트에 있는 JWT는 접근을 거부한다.

🤔 무상태stateless 인증 방식이란?
무상태(stateless)란 시스템이나 애플리케이션이 특정 요청 간에 상태 정보를 저장하지 않는 특성을 의미한다. 무상태 애플리케이션에서는 각 요청이 독립적이며, 서버는 이전 요청의 상태를 기억하지 못한다.

  • JWT와 무상태: JWT 안에 필요한 정보가 포함되어 있으므로, 서버는 별도의 상태 정보를 저장하지 않고 클라이언트가 서버에 요청할 때마다 해당 JWT를 검증한다.

여기서 블랙리스트 처리는 Redis를 이용하여 빠른 읽기/쓰기 속도와 만료 관리를 목표로 할 것이다. Redis는 메모리 기반 데이터 저장소로, 데이터 구조의 유연성과 TTL(Time To Live) 설정을 통해 만료된 토큰을 자동으로 제거하여 애플리케이션에서 효율적인 JWT 관리를 가능하게 한다.

블랙리스트에 대해서는 JWT 토큰 관리 - 블랙리스트 (feat. Redis)에서 다루었으니 자세한 방식과 주의점 등을 알고 싶다면 이 게시물을 참고 바란다.


🗂️ 패키지 구조

com.project.securelogin
├── config
│   └── RedisConfig.java
│   └── SecurityConfig.java
├── controller
│   └── AuthController.java
│   └── UserController.java
├── domain
│   └── CustomUserDetails.java
│   └── User.java
├── dto
│   └── UserRequestDTO.java
│   └── UserResponseDTO.java
├── jwt
│   └── JwtAuthenticationFilter.java
│   └── JwtTokenProvider.java
├── repository
│   └── JwtTokenRedisRepository.java
│   └── UserRepository.java
└── service
    └── AuthService.java
    └── CustomUserDetailsService.java
    └── UserService.java

➕ 추가된 클래스

  • RedisConfig: Redis와 관련된 설정을 정의하는 클래스이다. Redis 연결에 필요한 bean들을 구성할 수 있다.

  • JwtTokenRedisRepository: Redis를 사용해 JWT 토큰을 블랙리스트로 관리하는 Repository 클래스이다.


🛠️ 의존성 추가

로그인 기능에서 필요한 의존성을 pom.xmldependencies에 추가한다.

<dependencies>
	··· 생략 ···
        <!-- Redis 의존성 추가 -->
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
            <version>3.3.0</version>
        </dependency>
	··· 생략 ···
</dependencies>
  • Spring Data Redis: 스프링 프레임워크와 Redis를 통합하여 스프링에서 Redis 데이터베이스에 접근할 수 있도록 기능을 제공하는 라이브러리이다. Redis는 로그인 후 JWT를 통해 인증된 사용자의 세션 데이터를 저장한다.

application.yml


spring:
  data:
    redis:
      host: localhost
      port: 6379

Redis에 관한 기본적인 설정을 해줘야 한다. hostport는 기본값을 사용했다.


Config

RedisConfig

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Bean // Lettuce를 이용해 Redis 서버에 연결
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory();
    }

    @Bean // Redis 작업을 수행하기 위한 RedisTemplate 빈을 정의
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

RedisConfig는 스프링 애플리케이션에서 Redis를 사용할 수 있도록 기본적인 설정을 정의한다. redisConnectionFactory()는 Redis 연결을 설정하고, redisTemplate()은 Redis 작업을 위한 RedisTemplate을 정의한다. 이 설정들은 Spring Data Redis를 사용하여 Redis 데이터베이스와 통합할 때 기본적으로 필요한 구성이다.


Repository

JwtTokenRedisRepository

@Component
@RequiredArgsConstructor
public class JwtTokenRedisRepository {

    private static final String BLACKLIST_KEY_PREFIX = "jwt:blacklist:";
    private final RedisTemplate<String, String> redisTemplate;

    // 주어진 JWT 토큰을 블랙리스트에 추가한다. 이미 추가된 경우 false를 반환하고, 성공적으로 추가된 경우 true를 반환한다.
    public boolean addTokenToBlacklist(String tokenId, long expireInSeconds) {
        try {
            String key = BLACKLIST_KEY_PREFIX + tokenId;
            Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "true", expireInSeconds, TimeUnit.SECONDS);
            return Boolean.TRUE.equals(result);
        } catch (Exception e) {
            return false;
        }
    }


    // 주어진 JWT 토큰이 블랙리스트에 있는지 여부를 확인한다.
    public boolean isTokenBlacklisted(String tokenId) {
        if (redisTemplate == null) {
            return false;
        }
        String key = BLACKLIST_KEY_PREFIX + tokenId;
        return redisTemplate.hasKey(key);
    }

}

JwtTokenRedisRepository란?

이 클래스는 Redis를 사용해 JWT 토큰을 블랙리스트로 관리하는 Repository 클래스이다. JWT 토큰을 블랙리스트에 추가하거나, 해당 토큰이 블랙리스트에 있는지 확인하는 기능을 제공한다.

✅ 구성 요소

  • BLACKLIST_KEY_PREFIX 상수: jwt:blacklist:로 시작하는 모든 Redis 키의 접두사이다. 이 접두사는 블랙리스트에 있는 JWT 토큰을 구분하기 위해 사용된다.

  • redisTemplate: Redis와의 상호작용을 담당하는 Spring Data Redis의 RedisTemplate 객체이다. 이 객체를 통해 Redis에 데이터를 저장하고 조회할 수 있다.

✅ 메서드

1) addTokenToBlacklist(String tokenId, long expireInSeconds)

  • 기능: 주어진 JWT 토큰을 블랙리스트에 추가한다.
  • 매개변수:
    • email: JWT 토큰에 있는 사용자 이메일
    • expireInSeconds: 토큰이 Redis에 유지될 유효 기간
  • 반환값: 이미 추가된 경우 false, 성공적으로 추가된 경우 true를 반환
  • 동작:
    • redisTemplate.opsForValue().setIfAbsent(key, "true", expireInSeconds, TimeUnit.SECONDS): Redis의 setIfAbsent 명령을 사용하여 키가 없는 경우에만 값("true")을 설정하고, 지정된 시간 후에 만료되도록 설정한다. 이미 키가 존재하면 추가하지 않고 false를 반환한다.

2) isTokenBlacklisted(String tokenId)

  • 기능: 주어진 JWT 토큰이 블랙리스트에 있는지 확인한다.
  • 매개변수:
    • email: JWT 토큰에 있는 사용자 이메일
  • 반환값: 토큰이 블랙리스트에 있는 경우 true, 없는 경우 false를 반환
  • 동작:
    • redisTemplate.hasKey(key): 주어진 키가 Redis에 존재하는지 확인하여 존재하면 true, 그렇지 않으면 false를 반환한다. redisTemplatenull인 경우에는 무조건 false를 반환하도록 예외 처리가 추가되어 있다.

JWT

JwtTokenProvider


    private final JwtTokenRedisRepository jwtTokenRedisRepository;
    private final UserDetailsService userDetailsService;
    
    // 리프레시 토큰 검증
    public boolean validateRefreshToken(String refreshToken) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    
    // HttpServletRequest에서 Refresh 토큰을 추출
    public String resolveRefreshToken(HttpServletRequest request) {
        String refreshToken = request.getHeader("Refresh-Token");
        return refreshToken;
    }


    // Refresh 토큰을 블랙리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.
    public boolean blacklistRefreshToken(String refreshToken) {
        try {
            String tokenId = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken).getBody().getId();

            if (tokenId == null) {
                throw new IllegalArgumentException("토큰에 jti 클레임이 포함되어 있지 않습니다.");
            }

            return jwtTokenRedisRepository.addTokenToBlacklist(tokenId, refreshTokenValiditySeconds);
        } catch (Exception e) {
            return false;
        }
    }

    // 블랙리스트에 있는지 확인 (Refresh Token)
    public boolean isRefreshTokenBlacklisted(String refreshToken) {
        String tokenId = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(refreshToken).getBody().getId();
        return jwtTokenRedisRepository.isTokenBlacklisted(tokenId);
    }

    // Refresh 토큰을 사용하여 새로운 Access 토큰 생성
    public String createTokenFromRefreshToken(String refreshToken) {
        if (!validateRefreshToken(refreshToken)) {
            throw new IllegalArgumentException("Invalid refresh token");
        }

        String email = getEmail(refreshToken);
        CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
        return generateToken(new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()), accessTokenValiditySeconds);
    }

➕ 추가된 메서드

1) validateRefreshToken(String refreshToken)

  • 기능: 주어진 리프레시 토큰이 유효한지 검증한다.
  • 매개변수:
    • refreshToken: 검증할 리프레시 토큰
  • 반환값: 유효하면 true, 그렇지 않으면 false
  • 동작:
    • 주어진 리프레시 토큰을 파싱하여 유효성을 검사한다. 유효하지 않은 경우 예외를 발생시킨다.

2) resolveRefreshToken(HttpServletRequest request)

  • 기능: HttpServletRequest에서 리프레시 토큰을 추출한다.
  • 매개변수:
    • request: HTTP 요청 객체
  • 반환값: 추출된 리프레시 토큰
  • 동작:
    • 요청 헤더에서 "Refresh-Token" 헤더를 읽어와서 리프레시 토큰으로 반환한다.

3) blacklistRefreshToken(String refreshToken)

  • 기능: 리프레시 토큰을 블랙리스트에 추가한다.
  • 매개변수:
    • refreshToken: 블랙리스트에 추가할 리프레시 토큰
  • 반환값: 성공적으로 추가되면 true, 실패하면 false
  • 동작:
    • 리프레시 토큰을 파싱하여 이메일을 추출하고, 해당 이메일을 리프레시 토큰의 유효기간과 함께 블랙리스트에 추가한다. 이때, 해당 이메일이 이미 블랙리스트 안에 존재하면 성공으로 처리한다.

4) isRefreshTokenBlacklisted(String refreshToken)

  • 기능: 리프레시 토큰이 블랙리스트에 있는지 확인한다.
  • 매개변수:
    • refreshToken: 확인할 리프레시 토큰
  • 반환값: 블랙리스트에 있으면 true, 그렇지 않으면 false
  • 동작:
    • 리프레시 토큰을 파싱하여 이메일을 추출하고, 해당 이메일이 블랙리스트에 있는지 확인한다.

5) createTokenFromRefreshToken(String refreshToken)

  • 기능: 리프레시 토큰을 사용하여 새로운 액세스 토큰을 생성한다.
  • 매개변수:
    • refreshToken: 새로운 액세스 토큰을 생성하기 위해 사용할 리프레시 토큰
  • 반환값: 생성된 새로운 액세스 토큰
  • 동작:
    • 리프레시 토큰의 유효성을 검증하고, 이메일을 추출한 후 해당 이메일의 사용자 정보를 로드한다. 사용자 정보를 기반으로 새로운 액세스 토큰을 생성한다.

이번에 JwtTokenProvider에서는 리프레시 토큰에 대한 여러 메서드를 생성했다. 리프레시 토큰에 대한 중요도가 커진 만큼 검증, 추출, 블랙리스트 추가, 새로운 액세스 토큰 생성 등의 메서드를 통해 더욱 다양한 활용을 할 수 있을 것이다.

JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            // JWT 토큰 추출
            String jwt = jwtTokenProvider.resolveToken(request);
            if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
                // 토큰에서 사용자 정보 추출
                String email = jwtTokenProvider.getEmail(jwt);

                // 사용자 정보로부터 UserDetails 로드
                CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
                // 인증 객체 생성
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // SecurityContext에 인증 객체 설정
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (ExpiredJwtException e) {
            // 만료된 JWT 처리
            handleTokenExpiration(request, response);
            return;
        } catch (Exception e) {
            // 예외 발생 시 SecurityContext 초기화
            SecurityContextHolder.clearContext();
        }

        // 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    // 토큰 만료 처리 로직
    private void handleTokenExpiration(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // Refresh Token 추출
        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);
        if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
            // Refresh Token이 블랙리스트에 없으면 새로운 Access Token 발급
            if (!jwtTokenProvider.isRefreshTokenBlacklisted(refreshToken)) {
                String jwtToken = jwtTokenProvider.createTokenFromRefreshToken(refreshToken);
                response.setHeader("Authorization", "Bearer " + jwtToken);

                // 새로 발급받은 Access Token으로 UserDetails 로드
                String email = jwtTokenProvider.getEmail(jwtToken);
                CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(email);

                // 인증 객체 생성 및 SecurityContext에 설정
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } else {
                // Refresh Token이 블랙리스트에 있으면 Unauthorized 에러 반환
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Refresh token is blacklisted");
            }
        } else {
            // 유효하지 않은 Refresh Token일 경우 SecurityContext 초기화 및 Unauthorized 에러 반환
            SecurityContextHolder.clearContext();
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid refresh token");
        }
    }
}

JwtAuthenticationFilter의 바뀐 점

액세스 토큰이 만료되었을 때 새롭게 토큰을 생성하는 메서드가 새롭게 추가되었다. 또한 기존 메서드에서 부족했던 예외 처리를 추가함으로 더욱 상세하고 정확한 반환값을 건넬 수 있게 되었다.

  • 구성 요소:
    • JwtTokenProvider: JWT 토큰을 생성하고 검증하는 도구
    • CustomUserDetailsService: 사용자 정보를 로드하는 서비스

✅ 주요 메서드

1) doFilterInternal()

  • 기능: 요청이 필터를 통과할 때 실행되며, JWT 토큰을 검증하고 유효한 경우 인증 객체를 설정한다.
  • 매개변수:
    • HttpServletRequest request: HTTP 요청 객체
    • HttpServletResponse response: HTTP 응답 객체
    • FilterChain filterChain: 필터 체인
  • 반환값: 없음
  • 동작:
    • JWT 토큰을 요청에서 추출하고 유효성을 검사한다.
    • 유효한 경우, 토큰에서 사용자 이메일을 추출하여 CustomUserDetails 객체를 로드하고 인증 객체를 생성하여 SecurityContext에 설정한다.
    • 토큰이 만료된 경우 handleTokenExpiration 메서드를 호출하여 토큰 만료 처리를 수행한다.
    • 예외 발생 시 SecurityContext를 초기화한다.
    • 다음 필터로 요청을 전달한다.

2) handleTokenExpiration()

  • 기능: 토큰이 만료되었을 때 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급하고, 블랙리스트 확인 및 인증 객체 설정을 수행한다.
  • 매개변수:
    • HttpServletRequest request: HTTP 요청 객체
    • HttpServletResponse response: HTTP 응답 객체
  • 반환값: 없음
  • 동작:
    • 요청에서 리프레시 토큰을 추출하고 유효성을 검사한다.
    • 리프레시 토큰이 유효하고 블랙리스트에 없는 경우 새로운 액세스 토큰을 발급하여 응답 헤더에 설정한다.
    • 새로운 액세스 토큰으로 CustomUserDetails 객체를 로드하고 인증 객체를 생성하여 SecurityContext에 설정한다.
    • 리프레시 토큰이 블랙리스트에 있거나 유효하지 않은 경우 Unauthorized 에러를 반환한다.

Service

AuthService

    // Refresh 토큰을 블랙리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.
    public boolean logout(String token) {
        return jwtTokenProvider.blacklistRefreshToken(token);
    }

🔒 logout()

JwtTokenProvider의 블랙 리스트 추가 메서드를 통해 리프레시 토큰을 블랙 리스트에 추가하고, 성공적으로 추가되면 true를 반환한다.

  • 기능: 주어진 리프레시 토큰을 블랙리스트에 추가한다.
  • 매개변수:
    • String token: 블랙리스트에 추가할 리프레시 토큰
  • 반환값: 성공적으로 추가되면 true, 실패하면 false
  • 동작:
    • jwtTokenProvider.blacklistRefreshToken(token)을 호출하여 리프레시 토큰을 블랙리스트에 추가하고, 그 결과를 반환한다.

Controller

AuthController

    @DeleteMapping("/logout")
    public ResponseEntity<Void> logout(@RequestHeader(name = "Refresh-Token") String refreshToken) {
        boolean logoutSuccess = authService.logout(refreshToken);

        if (logoutSuccess) {
            return ResponseEntity.noContent().build(); // 204 No Content
        } else {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); // or INTERNAL_SERVER_ERROR
        }
    }

🔒 @DeleteMapping("/logout")

로그아웃은 따로 Body에 리턴값을 넘겨줄 필요가 없으므로 Delete API를 사용해 로그아웃 요청을 처리하고 HTTP Status를 응답한다.

  • 기능: 로그아웃 요청을 처리하고, 사용자에게 로그아웃 상태를 응답한다.
  • 매개변수:
    • @RequestHeader(name = "Refresh-Token") String refreshToken: 요청 헤더에서 "Refresh-Token"을 받아 리프레시 토큰으로 사용
  • 반환값: ResponseEntity<Void>: 로그아웃 결과에 따른 HTTP 응답
  • 동작:
    • authService.logout(refreshToken)을 호출하여 리프레시 토큰을 블랙리스트에 추가한다.
    • 로그아웃 성공 시 204 No Content 응답을 반환한다.
    • 로그아웃 실패 시 500 Internal Server Error 응답을 반환한다.

Test

지금까지의 코드를 빠짐없이 작성했다면 정상적으로 로그아웃 처리가 될 것이다. 여기서 주의해야 할 점이 있다. 로그아웃은 permitAll() 처리가 되어있지 않으므로 반드시 Authorization을 헤더에 추가해 리프레시 토큰과 함께 넘겨줘야 한다.

if 로그아웃 성공

로그아웃을 성공하면 화면과 같이 204 No Content를 반환한다.

if 로그아웃 실패

로그아웃을 실패하면 화면과 같이 500 Internal Server Error를 반환한다.


마치며

이번 로그아웃 과정은 생각보다 힘들었다. 특히 블랙리스트에 관해 고민하는 시간이 많았는데, 결과 나는 RefreshToken 안에 있는 고유 식별자(jti)를 키로 하여 블랙 리스트에 등록했지만 AccessToken을 키로 사용하는 케이스도 많으니 프로젝트의 상황에 맞춰 결정하면 될 것 같다.

RefreshToken를 키로 선택한 이유에 대해서는 JWT 토큰 관리 - 블랙리스트 (feat. Redis)에서 다루었으니 해당 게시물을 참고하자!

다음 게시물에서는 회원 CRUD API에 대해 다룰 것 같다. 어쨌든 JWT라는 큰 산을 넘은 것 같아 뿌듯하다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보