[Reach Rich 개발기] Spring Cloud, Spring Security, JWT를 사용한 API Gateway 인증/인가

wannabeking·2023년 3월 24일
0

Reach Rich 개발기

목록 보기
9/10

Reach Rich 프로젝트에서 Redis Session을 사용한 인증/인가를 구현한 뒤, 이 방법이 정말 효율적일까? 라는 의문이 들었습니다.

  • 각 MS가 모두 Spring Security 의존성을 가져야 한다는 점
  • 결국 Stateless 하지 않아 REST API라 해도 될지?

요즘 아주 핫한 ChatGPT에게도 물어봤습니다.

의문과 별개로 동일한 세션 저장소에 의존성이 생기기 때문에 MS 간의 결합도가 증가하는 단점도 알 수 있었습니다.


따라서 저는 기존 아키텍처를 수정해야했고, 그 내용은 다음과 같습니다.

  • Session에 의존하지 않기 위해 JWT 사용
  • User MS는 이메일 인증을 위해 예외적으로 Redis Session 사용
  • JWT는 User MS에서 발급
  • Refresh Token은 유효 기간이 길기 때문에 Redis로 관리
  • 각 MS에서 Spring Security를 사용하지 않기 위해 Spring Cloud Gateway 사용
  • Gateway에서 모든 요청을 받아 인증/인가 처리 후 각 MS로 요청 위임

할 일이 정말 많아졌지만, 즐거운 마음으로 차근차근 구현해 봅시다! 🔥



로그인 시 Refresh Token 발급, 로그아웃 시 삭제

우선 로그인 시 User MS에서 Refresh Token을 발급하여 Client에 응답하는 것을 구현해봅니다.

사용한 외부 의존성은 다음과 같습니다.

    implementation 'org.springframework.session:spring-session-data-redis'
    implementation group: 'org.springframework.security', name: 'spring-security-crypto', version: '5.7.7'
    implementation 'com.auth0:java-jwt:3.19.2'

Redis는 기존에서 사용 중이었고, Spring Security는 Gateway에서만 사용할 것이기 때문에 비밀번호 암호화에 필요한 spring-security-crypto를 받아줍니다.

JWT 구현에는 java-jwt를 사용했습니다.


우선 JWT 구현을 위한 Config을 살펴보겠습니다.


JwtConfig

@Configuration
public class JwtConfig {

    @Value("${spring.jwt.issuer}")
    private String issuer;

    @Value("${spring.jwt.client-secret}")
    private String clientSecret;

    @Value("${spring.jwt.access-token-expiry-seconds}")
    private long accessTokenExpirySeconds;

    @Value("${spring.jwt.refresh-token-expiry-seconds}")
    private long refreshTokenExpirySeconds;

    @Bean
    public JwtGenerator jwtGenerator() {
        return JwtGenerator.builder()
            .issuer(issuer)
            .clientSecret(clientSecret)
            .accessTokenExpirySeconds(accessTokenExpirySeconds)
            .refreshTokenExpirySeconds(refreshTokenExpirySeconds)
            .build();
    }

    @Bean
    @Qualifier("jwtVerifier")
    public JWTVerifier jwtVerifier() {
        return JWT.require(Algorithm.HMAC512(clientSecret))
            .withIssuer(issuer)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

JWT와 관련된 변수는 Yaml에서 관리해주므로, Value 어노테이션을 사용해 가져와줍니다.

JWT를 발급해주는 JwtGenerator를 만들어 Bean으로 등록하고, Access Token 발급 시 Refresh Token을 검증해야 하므로, JWTVerifier도 Bean으로 등록합니다.

이때 이미 해당 빈 이름이 존재하는 것인지 Spring이 duplicated bean name 오류를 뱉으므로 Qulifier 어노테이션을 사용해주었습니다.

비밀번호 암호화에 필요한 클래스도 Config 클래스를 하나 더 만들기는 애매하므로 같이 Bean으로 등록해 줍니다!


JwtGenerator

public class JwtGenerator {

    private final String issuer;
    private final Algorithm algorithm;
    private final long accessTokenExpirySeconds;
    private final long refreshTokenExpirySeconds;

    @Builder
    public JwtGenerator(String issuer, String clientSecret, long accessTokenExpirySeconds,
        long refreshTokenExpirySeconds) {
        this.issuer = issuer;
        this.algorithm = Algorithm.HMAC512(clientSecret);
        this.accessTokenExpirySeconds = accessTokenExpirySeconds;
        this.refreshTokenExpirySeconds = refreshTokenExpirySeconds;
    }

    public String generateAccessToken(String nickname) {
        return generateToken(nickname, accessTokenExpirySeconds);
    }

    public String generateRefreshToken(String nickname) {
        return generateToken(nickname, refreshTokenExpirySeconds);
    }

    private String generateToken(String nickname, long expirySeconds) {
        Date now = new Date();
        JWTCreator.Builder builder = com.auth0.jwt.JWT.create();
        builder.withIssuer(issuer);
        builder.withIssuedAt(now);
        builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L));
        builder.withClaim("aud", nickname);
        return builder.sign(algorithm);
    }
}

해당 클래스는 JWT를 생성하는 책임을 가지고 있습니다. 발행자, 암호화 알고리즘, 유효기간을 통해 Access, Refresh token을 발급합니다.

우선은 간단하게 iss, iat, exp, aud 정도만 넣어주겠습니다.

참고로 Access Token은 30분, Refresh Token은 2주로 유효기간을 설정했습니다.


UserApplicationService

    @Transactional(readOnly = true)
    public String login(LoginDto loginDto) {
        User user = userService.getUserByEmail(loginDto.getEmail())
            .orElseThrow(() -> new CustomException(LOGIN_DENIED));

        if (!user.isPasswordMatch(passwordEncoder, loginDto.getPassword())) {
            throw new CustomException(LOGIN_DENIED);
        }

        String refreshToken = refreshTokenService.generateRefreshToken(user.getNickname());
        refreshTokenService.createRefreshToken(user.getNickname(), refreshToken);
        return refreshToken;
    }

    public void logout(LogoutDto logoutDto) {
        refreshTokenService.deleteRefreshToken(logoutDto.getNickname());
    }

로그인과 로그아웃은 위와 같이 구현했습니다.

이메일 비밀번호 일치를 확인한 후 작성한 JwtGenerator에게 토큰 발급을 요청합니다.

Refresh Token은 유효 기간이 길기 때문에 따로 관리하기 위하여 Redis에도 nickname을 key로 저장해주겠습니다.

로그아웃 시에는 Refresh Token을 다시 사용하지 못하도록 삭제합니다!


한 가지 고민 중인 사항이 있는데... 로그인 시 Refresh Token만 발급할지, Access Token도 함께 발급해줄지 여부입니다.

Refresh Token을 통한 Access Token 발급 API가 구현되어 있으므로, 로그인 API에서 두 가지 책임을 모두 가지는 것이 아닌가 하는 생각에서 우선 분리했습니다.

물론 한꺼번에 발급해준다면, API를 또 다시 호출할 필요가 없기에 성능적으로는 더 좋을 것 같지만서도요...😂



Access Token 재발급

Refresh Token으로 Access Token을 재발급하는 방법은 간단합니다.

  1. Refresh Token이 유효한지 검증
  2. Redis와의 일치성 검사

2번 절차를 진행하는 이유는, 보안상의 이유로 사용자의 Refresh Token을 삭제했을 수 있으며 로그아웃 후 똑같은 토큰으로 재발급 요청하는 경우, 서버에서 발급한 토큰이 아닌 경우를 모두 검증할 수 있기 때문입니다.

그렇다면 이제 구현해봅니다!


RefreshTokenService

    public String generateAccessToken(String refreshToken) {
        DecodedJWT decodedRefreshToken;

        try {
            decodedRefreshToken = jwtVerifier.verify(refreshToken);
        } catch (JWTVerificationException e) {
            log.warn("Refresh Token 검증 실패 : {}", e.getMessage());
            throw new CustomException(ACCESS_TOKEN_REISSUE_FAIL);
        }

        if (!isValidRefreshToken(decodedRefreshToken)) {
            log.warn("유효하지 않은 Refresh Token");
            throw new CustomException(ACCESS_TOKEN_REISSUE_FAIL);
        }

        String nickname = decodedRefreshToken.getAudience().get(0);

        RefreshToken refreshTokenEntity = refreshTokenRepository.findById(nickname)
            .orElseThrow(() -> {
                log.warn("차단된 Refresh Token 사용");
                throw new CustomException(ACCESS_TOKEN_REISSUE_FAIL);
            });

        if (!refreshToken.equals(refreshTokenEntity.getValue())) {
            log.warn("발급하지 않은 Refresh Token 사용");
            throw new CustomException(ACCESS_TOKEN_REISSUE_FAIL);
        }

        return jwtGenerator.generateAccessToken(nickname);
    }

Service에서는 http only cookie에서 꺼내온 Refresh Token을 사용하여 Access Token을 재발급합니다.

JWT 그 자체의 검증 (형식, 유효기간, 이슈어) -> Redis에 존재하는지 -> 값은 같은지

순으로 검증한 뒤 발급합니다.


UserController

    @GetMapping("/reissue-access-token")
    public ResponseEntity<Void> generateAccessToken(
        @CookieValue(value = "Refresh-Token", defaultValue = EMPTY_REFRESH_TOKEN_VALUE) String refreshToken) {

        String accessToken = userApplicationService.generateAccessToken(refreshToken);
        ResponseCookie cookie = ResponseCookie.from(ACCESS_TOKEN_HEADER, accessToken)
            .secure(true)
            .httpOnly(true)
            .path("/")
            .maxAge(ACCESS_TOKEN_EXPIRY_SECONDS)
            .build();

        return ResponseEntity.ok().header(SET_COOKIE, cookie.toString()).build();
    }

검증을 마치면 cookie에 set 해줍니다.

SSL/TLS를 붙여 HTTPS 통신을 사용한다면 secure cookie로 보내줘야하는데... 시간이 부족하여 아직 붙이지 못했습니다ㅠㅠㅠ

인증서/도메인 붙이는 것은 TODO로 남겨줍시다!

참고로 클라이언트의 요청 Cookie에 Refresh Token이 없을 수도 있으니, defaultValue를 설정해야 합니다.



Spring Cloud Gateway, Spring Security로 Gateway 구현

JWT 발급에 대한 개발이 어느정도 끝이 났으므로, Gateway를 구현해줍니다.

이미 Spring Security를 사용한 인증/인가 구현을 경험해봤으므로 빨리 끝날줄 알았는데... Spring Cloud는 기본적으로 Netty 기반 Reactive App이기 때문에 Spring Security도 WebFlux에 맞게 구현해야 하더라고요...?

그래서 처음부터 공부하면서 구현한건 안비밀...ㅎㅎㅎ (사실 동기로 구현해도 되는데 오기로...😎)


우선 Gateway의 라우팅 기능부터 구현해봅시다!


WebFluxConfig

@Configuration
@EnableWebFlux
public class WebFluxConfig {

}

Spring Cloud Gateway를 WebFlux를 사용해서 구현할 것이므로, EnavleWebFlux 어노테이션을 달아줍니다.

물론 기본 Application 클래스에 달아도 되지만 저는 @SpringBootApplication 하나만 달려있는게 좋더라고요... 나만 그런가?


application-gateway.yml

spring:
  cloud:
    gateway:
      routes:
        - id: user
          uri:
          predicates:
            - Path=/user/**
          filters:
            - RewritePath=/user/(?<path>.*),/$\{path}
      httpclient:
        ssl:
          enabled: true
          key-store-type: PKCS12
          key-store:
          key-store-password:

Routing 설정은 Yaml로 해도 되고, Java로 해도 됩니다.

Yaml로 민감 데이터를 감추고 Java에서 꺼내와서 설정해도 되겠지만... 그러면 일을 두 번하는 느낌이잖아요? 아직 복잡한 것은 없으니, 그냥 Yaml로 작성해주겠습니다.

predicates를 사용하여 Gateway로 날라오는 요청 중 해당 서비스로 라우팅할 요청을 선별하며,
filters를 사용하여 Endpoint를 바꿔줄 수 있습니다.

저는 Gateway에 MS를 구분하기 위한 prefix(user)를 떼어주도록 구현했습니다.

만약 MS를 추가하게 된다면 해당 Yaml에 추가하면 됩니다!


그럼 이제 라우팅 설정이 끝났으니, WebFluxSecurity로 넘어가겠습니다.



WebFluxSecurity

WebFlux를 사용하면서 Spring Security를 사용하기 위해선, WebSecurity가 아닌 WebFluxSecurity를 사용합니다.

구조적으로 크게 다른 것은 없고 Spring MVC를 사용하느냐 WebFlux를 사용하느냐, 즉 동기 블로킹과 비동기 논블로킹 차이가 큽니다.

Gateway의 경우 Server와 통신이 이루어지므로 동기 블로킹 환경에서 스레드가 노는 시간이 많아지니, 비동기 논블로킹인 WebFlux가 효율적이라고 생각합니다.

물론 그만큼의 트래픽을 기대하기도 힘들겠고요...ㅠㅠ


JwtAuthenticationFilter

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        MultiValueMap<String, HttpCookie> cookies = exchange.getRequest().getCookies();
        List<HttpCookie> cookie = cookies.get(ACCESS_TOKEN_HEADER);

        try {
            if (!isNull(cookie) && cookie.size() == 1) {
                String accessToken = cookie.get(0).getValue();
                DecodedJWT decodedJWT = jwtVerifier.verify(accessToken);

                if (isValidAccessToken(decodedJWT)) {
                    String audience = decodedJWT.getAudience().get(0);

                    // TODO: credentials, authorities 다룰지?
                    Authentication jwtAuthenticationToken =
                        new JwtAuthenticationToken(audience, null, null);

                    return chain.filter(exchange)
                        .contextWrite(ReactiveSecurityContextHolder.withAuthentication(
                            jwtAuthenticationToken));
                }
            }
        } catch (JWTVerificationException e) {
            log.warn("잘못된 access token 포함한 요청입니다. : {}", e.getMessage());
        }

        return chain.filter(exchange);
    }

    private boolean isValidAccessToken(DecodedJWT decodedJWT) {
        return decodedJWT.getExpiresAt().after(new Date())
            && ISSUER.equals(decodedJWT.getIssuer());
    }

사실, 기존 Web MVC의 Spring Security와 크게 다른 점은 없습니다.

Request와 Response가 ServerWebExchange로 관리된다는점, 필터를 적용하는데 WebFilterChain 인터페이스를 사용한다는 점, 단일 값도 비동기로 함수형프로그래밍을 지원하는 Mono가 보인다는점 정도가 다른 것 같습니다.

쿠키에 Access Token이 존재하고, 검증에 성공한 경우에 Authentication을 할당해주는 과정으로 인증을 구현합니다.

아직 프로젝트 초기라 사용자 권한에 구분이 없어 Role을 구분짓진 않았는데... 추후 개발해야될 수도 있을 것 같습니다. credential도 로그인 과정이 포함되지 않기 때문에 구현해야할 필요성을 못느꼈습니다.

권한이 필요한 API 호출은 Authentication이 없다면 Handler에서 잡아줄테니, 인증에 실패하면 그대로 넘겨줍니다.


CustomAuthenticationEntryPoint

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String responseJson = "{\"message\": \"인증이 필요한 요청입니다.\"}";
        return response.writeWith(
            Mono.just(response.bufferFactory().wrap(responseJson.getBytes())));
    }

인증이 필요한 요청에 인증이 없는 경우 handler를 작성해줍니다.

현재는 권한에 Level이 존재하지 않기 때문에 해당 handler만 있으면 되지만, 인증이 되었지만 권한이 낮은 경우를 handling 해야되는 경우가 존재할 수도 있습니다.

ServerHttpResponse의 writeWith 메소드에 Publisher 객체를 넘겨 response body에 JSON을 써줍니다.

인증이 없는 경우 response이기 때문에 status code는 401로 해줍니다.


WebFluxSecurityConfig 부분은 Spring MVC를 사용한 Spring Security 설정과 비슷하기 때문에 pass하고, 궁금하시면 이전 Reach Rich 개발기 포스팅에 구현한 부분이 있으므로, 참고하시면 좋을 것 같습니다!

요즘 살짝 바빠져서 플젝에 많은 시간을 투자할 수가 없네요...🥹



profile
내일은 개발왕 😎

0개의 댓글