이 프로젝트에서는 JWT(JSON Web Token)를 사용한 인증 및 인가 시스템을 구축하였습니다. 이를 통해 사용자는 로그인 시 발급된 Access TokenRefresh Token을 사용하여 보호된 리소스에 접근할 수 있으며, 로그아웃 시 토큰이 블랙리스트에 등록되어 이후 재사용을 방지할 수 있습니다.

1. JWT 검증 및 필터링 (LocalJwtAuthenticationFilter)

Gateway 레벨에서 JWT를 검증하는 GlobalFilter를 구현하여, API 요청 시 JWT 토큰을 검증(auth 서비스에 요청)하는 과정을 처리합니다. 이를 통해 /api/auth, 회원가입(POST /api/member) 경로를 제외한 모든 요청에 대해 JWT가 검증됩니다.

@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {

    private final AuthService authService;

    public LocalJwtAuthenticationFilter(@Lazy AuthService authService) {
        this.authService = authService;
    }

    @Override
    public Mono<Void> filter(final ServerWebExchange exchange, final GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();
        String method = exchange.getRequest().getMethod().name();

        // /api/auth 경로의 모든 요청은 검증하지 않습니다.
        if (path.startsWith("/api/auth")) {
            return chain.filter(exchange);
        }

        // /api/member 경로의 POST 요청은 검증하지 않습니다.
        if (path.startsWith("/api/member") && method.equalsIgnoreCase("POST")) {
            return chain.filter(exchange);
        }

        // JWT 토큰 추출 및 검증
        String token = extractToken(exchange);
        if (token == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        Map<String, Object> claimsMap = validateToken(token);
        if (claimsMap == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // 헤더에 유저 정보를 추가한 후 체인을 통해 다음 필터로 이동
        exchange.getRequest().mutate()
                .header("user_id", claimsMap.get("user_id").toString())
                .header("username", claimsMap.get("username").toString())
                .header("role", claimsMap.get("role").toString())
                .header("email", claimsMap.get("email").toString())
                .build();

        return chain.filter(exchange);
    }

    private String extractToken(ServerWebExchange exchange) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }

    private Map<String, Object> validateToken(String token) {
        try {
            return authService.verifyJwt(token);
        } catch (Exception e) {
            return null;
        }
    }
}
@FeignClient(name = "auth")
public interface AuthClient extends AuthService {
    @GetMapping("/api/auth/verify") // Jwt 검증 API
    Map<String, Object> verifyJwt(@RequestParam(value = "token") String token);
}

이 필터는 Gateway에서 JWT를 검증하는 역할을 하며, AuthService를 통해 JWT를 검증하여 사용자의 정보를 확인하고 요청 헤더에 추가합니다. 이를 통해 모든 요청에 대해 필터링 및 검증이 이루어집니다.

2. 토큰 관리 및 블랙리스트 구현 (AuthServiceTokenService)

2.1 토큰 블랙리스트 관리

로그아웃 시 사용된 Access TokenRefresh Token을 Redis에 저장하여 블랙리스트를 관리합니다. 블랙리스트에 등록된 토큰은 이후 재사용이 불가능하게 처리됩니다.

@Service
public class AuthService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final TokenService tokenService;

    public AuthService(RedisTemplate<String, Object> redisTemplate, TokenService tokenService) {
        this.redisTemplate = redisTemplate;
        this.tokenService = tokenService;
    }

    // 로그아웃 처리 및 블랙리스트에 토큰 추가
    public void logout(String accessToken, String refreshToken) {
        addTokenToBlacklist(accessToken);
        addTokenToBlacklist(refreshToken);
    }

    private void addTokenToBlacklist(String token) {
        try {
            long expiration = tokenService.getExpirationFromToken(token);
            if (expiration > 0) {
                redisTemplate.opsForValue().set(token, "true", expiration, TimeUnit.MILLISECONDS);
            }
        } catch (ExpiredJwtException e) {
            // 토큰이 이미 만료된 경우 무시
        }
    }

    // 토큰이 블랙리스트에 등록되었는지 확인
    private boolean isTokenInBlacklist(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(token));
    }
}

2.2 TokenService를 통한 토큰 생성 및 검증

JWT 토큰의 생성과 검증은 TokenService에서 처리됩니다. 이 서비스는 JWT의 만료 시간 및 유효성을 확인하고, Redis에 저장된 블랙리스트와 비교하여 유효한 토큰인지 검증합니다.

@Service
public class TokenService {

    private final RedisTemplate<String, Object> redisTemplate;

    // Access Token 생성
    public String createAccessToken(UserDetailsDto user) {
        return generateToken(user, accessExpiration);
    }

    // Refresh Token 생성
    public String createRefreshToken(UserDetailsDto user) {
        return generateToken(user, refreshExpiration);
    }

    // Access Token 재발행
    public String refreshAccessToken(String refreshToken) {
        if (isTokenInBlacklist(refreshToken)) {
            throw new IllegalArgumentException("유효하지 않은 Refresh Token입니다.");
        }
        Jws<Claims> claims = parseToken(refreshToken);
        Integer userId = claims.getPayload().get("user_id", Integer.class);
        UserDetailsDto user = retrieveUser(Long.valueOf(userId));
        return generateToken(user, accessExpiration);
    }

    private boolean isTokenInBlacklist(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(token));
    }
}

2.3 TokenServiceAuthService의 책임 분리

TokenService는 JWT 토큰의 생성과 검증을 전담하며, AuthService는 블랙리스트 관리와 로그인/로그아웃의 비즈니스 로직을 담당합니다. 이를 통해 서비스의 책임을 분리하고, 각 기능이 명확히 구분됩니다.

3. Redis를 통한 블랙리스트 관리

로그아웃 시 사용된 Access TokenRefresh Token은 Redis에 저장됩니다. 이후 Redis에서 해당 토큰이 블랙리스트에 있는지 확인하여 토큰의 유효성을 검증합니다. Redis를 사용하여 블랙리스트를 관리함으로써, 중앙화된 저장소에서 토큰의 상태를 효율적으로 관리할 수 있습니다.

private void addTokenToBlacklist(String token) {
    long expiration = tokenService.getExpirationFromToken(token);
    redisTemplate.opsForValue().set(token, "true", expiration, TimeUnit.MILLISECONDS);
}

private boolean isTokenInBlacklist(String token) {
    return Boolean.TRUE.equals(redisTemplate.hasKey(token));
}