[로그아웃 API (Redis) ]

1.레디스를 이용한 로그아웃

JWT와 레디스를 이용한 로그인/로그아웃 로직을 구현하였다.
로그인 시 {사용자 닉네임 , jwt 엑세스 토큰}를 한 쌍으로 redis에 등록하고,
로그아웃 시 해당 jwt를 블랙리스트에 {사용자 닉네임, jwt 엑세스 토큰}을 올리는 식이다.

그리고 이후 사용자의 API요청이 있을때마다 먼저 블랙리스트를 확인하고,
만약 그 블랙리스트 안에 해당 엑세스 토큰이 있으면 접근을 금지 시키는 필터 또한 구현하였다.

1.1 컨트롤러

  /**
     * 로그아웃 API
     * @param request 사용자 로그아웃 요청
     * @return 로그아웃 메시지
     */
    @Operation(summary = "로그 아웃 API" ,description = "로그아웃")
    @PostMapping("/logout")
    public ResponseEntity<String> logout(HttpServletRequest request){

        authService.logout(request);

        return ResponseEntity.ok("Logout Success ! Your AccessToken is updated at blacklist ");
    }

사용자의 요청을 받아서 서비스의 로그아웃 메서드로 요청을 넘긴다.

1.2 서비스

 /**
     * 로그아웃 메서드
     * @param request 사용자 요청
     */
    public void logout(HttpServletRequest request){

        // Get AccessToken & nickname
        String AccessToken = jwtService.extractAccessToken(request);
        String nickname = jwtService.extractUsername(AccessToken);

        // Extract token's expiration time
        Date expirationDate = jwtService.extractClaims(AccessToken).getExpiration();
        log.info("{}'s expirationDate : {}",nickname,expirationDate);

        // Set ttl(Remain time for AccessToken)
        long ttl = expirationDate.getTime() - System.currentTimeMillis();
        double ttlInMinutes = ttl / (1000.0 * 60); // 부동소수점 연산
        log.info("{}'s ttl : {} minutes", nickname, ttlInMinutes);

        // Upload to BlackList
        redisTemplate.opsForValue().set("blacklist:" + nickname, AccessToken, ttl, TimeUnit.MILLISECONDS);

    }

컨트롤러에서 넘어온 리퀘스트를 받아서 다음과 같은 로직을 처리한다.
1.엑세스 토큰, 닉네임 추출
2.엑세스 토큰의 만료일과 TTL 추출 후 로그처리
3.{닉네임,엑세스 토큰,TTL}형식으로 Redis에 블랙리스트를 업로드

(여기서 나의 궁금증이 생긴 것은... 블랙리스트가 아닌, 기존의 로그인 redis에 있는 jwt엑세스 토큰에는 아무런 처리를 안해도 되는 것인가?? 라는 궁금증이 생겼다. 추후 다시 꼭 알아보자)

1.3 필터(JwtAuthenticationFilter.java)


@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final MemberMapper memberMapper;

    /* 권한이 필요 없는 API Path 선언 */
    private static final List<String> PUBLIC_APIS = List.of(
            "/auth/login/email",
            "/auth/login/id",
            "/member/register",
            "/member/findID/option1",
            "/member/findID/option2"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authorizationHeader = request.getHeader("Authorization");
        String nickname = null;
        String jwt = null;

        // 권한이 필요 없는 API 경로는 필터를 건너뜀
        if (isPublicApi(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            nickname = jwtService.extractUsername(jwt);
        }

        Member member = (nickname != null) ? memberMapper.findMemberByNickname(nickname) : null;
        if (nickname == null || member == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("User not found or unauthorized");
            response.getWriter().flush();
            return;
        }

        if (jwt != null && jwtService.validateToken(jwt)) {
            Collection<? extends GrantedAuthority> authorities =
                    Collections.singletonList(new SimpleGrantedAuthority(member.getRole()));
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(member, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else if (jwt != null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid or expired JWT token");
            response.getWriter().flush();
            return;
        }

        filterChain.doFilter(request, response);

    }

    /**
     * 권한 불필요 API들은 넘기는 메서드
     * @param request 사용자 요청
     * @return ture/false
     */
    private boolean isPublicApi(HttpServletRequest request) {
        String path = request.getRequestURI();
        return PUBLIC_APIS.stream().anyMatch(path::startsWith);
    }
}

1.3.1 권한이 필요없는 api들은 넘기기


    /* 권한이 필요 없는 API Path 선언 */
    private static final List<String> PUBLIC_APIS = List.of(
            "/auth/login/email",
            "/auth/login/id",
            "/member/register",
            "/member/findID/option1",
            "/member/findID/option2"
    );

권한 확인이 필요없는 api들은 필터 초반부에 선언해놓고,

 // 권한이 필요 없는 API 경로는 필터를 건너뜀
        if (isPublicApi(request)) {
            filterChain.doFilter(request, response);
            return;
        }

이 부분에서 확인 없이 바로 넘긴다.
이때 호출하는 메서드는 아래에 있는 다음 메서드다.

 /**
     * 권한 불필요 API들은 넘기는 메서드
     * @param request 사용자 요청
     * @return ture/false
     */
    private boolean isPublicApi(HttpServletRequest request) {
        String path = request.getRequestURI();
        return PUBLIC_APIS.stream().anyMatch(path::startsWith);
    }

1.3.2 권한이 필요한 api의 경우

 if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            nickname = jwtService.extractUsername(jwt);
        }

헤더가 널값이 아니고, bearer로 시작하면,
헤더에서 jwt,nickname을 추출한다.

 Member member = (nickname != null) ? memberMapper.findMemberByNickname(nickname) : null;
        if (nickname == null || member == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("User not found or unauthorized");
            response.getWriter().flush();
            return;
        }

닉네임이 널값이 아닌경우 닉네임을 통해 DB에서 member를 찾아오고,
nickname이 null이거나, 데이터베이스에서 해당 사용자를 찾을 수 없으면, 401 Unauthorized 응답을 반환한다.

  if (jwt != null && jwtService.validateToken(jwt)) {
            Collection<? extends GrantedAuthority> authorities =
                    Collections.singletonList(new SimpleGrantedAuthority(member.getRole()));
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(member, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } else if (jwt != null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid or expired JWT token");
            response.getWriter().flush();
            return;
        }

여기서 jwt를 검증하고,
마지막으로 다음 필터체인을 진행한다.

filterChain.doFilter(request, response);

2.결과

5번사용자와 6번 사용자의 로그인 이후 로그아웃 시 정상적으로 블랙리스트에 등록됨을 알 수 있다.

로그 기록에는,
만료 시간과 생존 시간 등이 표시 됨을 확인할 수 있다.

3. 블랙리스트에 유저가 한명밖에 안 올라가는 문제

5,6번 사용자를 로그인 하고, 5번 사용자는 정상적으로 블랙리스트에 올라갔다.
하지만, 6번 사용자는 로그아웃을 해도 블랙리스트에 올라가지 않는다.
이전에도 한명밖에 블랙리스트에 업로드 되지 않아서 DB를 재부팅 해봤는데도 이러는 것을 보니, 코드에 문제가 있나 보다.
코드를 검토해 보자.
.
.
.
코드에는 문제가 없었다.
알고보니, 5번 사용자와 6번 사용자의 동일한 HttpServletRequest를 가지고 요청을 보내서 하나의 블랙리스트만 업로드 된 순전히 나의 실수였다.

profile
내가 있는 그 조직에서 가장 성실하기만 하자

0개의 댓글