Spring Security, Spring Cloud, JWT, MSA
JWT + Spring Security + Spring Cloud = MSA 2. Spring Security์— ์ด์–ด์„œ ApiGateway๋ฅผ ์‚ฌ์šฉํ•ด๋ณด์ž

ApiGateway๋Š” Load Balancer๋ผ๊ณ ๋„ ํ•˜๋ฉฐ ํด๋ผ์ด์–ธํŠธ์˜ request๊ฐ€ ๋“ค์–ด์˜ค๋ฉด ์„ค์ •ํ•ด ๋†“์€ ๋ผ์šฐํŒ… ์„ค์ •์— ๋”ฐ๋ผ์„œ ๊ฐ๊ฐ์˜ endpoint๋กœ ํด๋ผ์ด์–ธํŠธ ๋Œ€์‹ ์— ์š”์ฒญ์„ ๋ณด๋‚ด๊ณ  ์‘๋‹ต์„ ๋ฐ›์•„ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ์ „๋‹ฌํ•˜๋Š” Proxy์—ญํ• ์„ ํ•œ๋‹ค

1. application.yml

server:
  port: 8090
  
eureka:
  client:
    fetch-registry: true
    register-with-eureka: true
    service-url:
      defaultZone: http://localhost:8761/eureka
      
      
spring:
  application:
    name: apigateway-service
  redis:
    host: localhost
    port: 6379
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Cloud Gateway Global Filter
            preLogger: true
            postLogger: true
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/
          filters:
            - AuthorizationHeaderFilter
        ...
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/tested  #requestํ•œ controller
            - Method=GET
          filters:
            - AuthorizationAdminFilter # ๊ด€๋ฆฌ์ž๋งŒ ์ ‘๊ทผ๊ฐ€๋Šฅํ•˜๋‹ค
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/jwt_check
            - Method=GET
          filters:
            - AuthorizationFilter #ํšŒ์›๋งŒ ์ ‘์† ๊ฐ€๋Šฅํ•˜๋‹ค
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/membertest
            - Method=GET
          filters:
            - AuthorizationHeaderFilter #ํšŒ์› ๋น„ํšŒ์› ๋‘˜๋‹ค ์ ‘์†ํ• ์ˆ˜์žˆ์ง€๋งŒ ์‚ฌ์šฉํ• ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ๋ฒ”์œ„๊ฐ€ ๋‹ค๋ฆ„
        
        ...

        # ํ•„ํ„ฐ๋ฅผ ํ†ตํ•ด ๊ฐ ์„œ๋น„์Šค ์„œ๋ฒ„๋Š” ๋น„์ง€๋‹ˆ์Šค ๋กœ์ง์—๋งŒ ์‹ ๊ฒฝ์“ฐ๋ฉด ๋จ

token:
  expiration_time: 86400000
  secret: user_token
  • routes์—์„œ id, uri๋Š” Service Discovery์— ๋“ฑ๋กํ•ด๋†“์€ ๊ฐ’์„ ํ†ตํ•ด ํด๋ผ์ด์–ธํŠธ์˜ request๊ฐ€ ์–ด๋Š endpoint๋กœ ๊ฐˆ์ง€ ์ •ํ•ด์ค€๋‹ค
  • endpoint์— ๋ชจ๋“  request๊ฐ€ ์ „์†ก๋˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ filter๋ฅผ ํ†ตํ•ด request์˜ ๊ฐ’์„ rewriteํ•ด์ค€๋‹ค

์—ฌ๊ธฐ์„œ๋Š” ์ด3๊ฐ€์ง€์˜ ํ•„ํ„ฐ๋ฅผ ๊ฐœ๋ฐœํ–ˆ๋‹ค
AuthorizationHeaderFilter: ํšŒ์› ๋น„ํšŒ์› ๋‘˜๋‹ค ์ ‘์†ํ• ์ˆ˜์žˆ์ง€๋งŒ ์‚ฌ์šฉํ• ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ๋ฒ”์œ„๊ฐ€ ๋‹ค๋ฆ„
AuthorizationFilter: ํšŒ์›๋งŒ ์ ‘์† ๊ฐ€๋Šฅํ•˜๋‹ค
AuthorizationAdminFilter: ๊ด€๋ฆฌ์ž๋งŒ ์ ‘๊ทผ๊ฐ€๋Šฅํ•˜๋‹ค

2. Filter

  • ๋ชจ๋“  ํ•„ํ„ฐ๋Š” AbstractGatewayFilterFactory๋ฅผ ์ƒ์†๋ฐ›์•„ apply()๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ํ•„ํ„ฐ๋ง ์„œ๋น„์Šค๋ฅผ ๊ฐœ๋ฐœํ•œ๋‹ค
  • isJwtValid()๋ฅผ ํ†ตํ•ด ํ† ํฐ์„ ๊ฒ€์ฆํ•œ๋‹ค

    2.1 AuthorizationHeaderFilter

  • ํšŒ์›๊ณผ ๋น„ํšŒ์› ํ•„ํ„ฐ
  • ํšŒ์›๊ณผ ๋น„ํšŒ์› ๋ชจ๋‘๊ฐ€ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค์ผ ๊ฒฝ์šฐ ์ด ํ•„ํ„ฐ๋ฅผ ํƒ€๊ฒŒ ๋œ๋‹ค
  • ๋น„ํšŒ์›์€ ์„œ๋น„์Šค ์ด์šฉ์ด ๊ฐ€๋Šฅํ•˜์ง€๋งŒ request header์— userId๊ฐ’์„ ๋„ฃ์–ด์ฃผ์ง€์•Š๋Š”๋‹ค
  • ํšŒ์›์ผ ๊ฒฝ์šฐ header์— userId๋ฅผ ๋„ฃ์–ด์ค€๋‹ค. ์ด๋ฅผ ํ†ตํ•ด Controller๋Š” userId๋ฅผ ์†์‰ฝ๊ฒŒ ํ™•์ธํ• ์ˆ˜ ์žˆ๋‹ค
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
    Environment env;
    private final RedisService redisService;

    @Autowired
    public AuthorizationHeaderFilter(Environment env, RedisService redisService) {
        super(Config.class);
        this.env = env;
        this.redisService = redisService;
    }

    public static class Config {
        // Put configuration properties here
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();


            MultiValueMap<String, HttpCookie> cookies = request.getCookies();
            String jwt = "";
            try {
                jwt = cookies.get("token").get(0).getValue();
            }
            catch (NullPointerException e) {
                return chain.filter(exchange);
            }

            if (redisService.isAccessTokenStored(jwt) || !isJwtValid(jwt)) {
                log.info("ํ† ํฐ({})์€ ์‚ฌ์šฉ๋ถˆ๊ฐ€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.", jwt);
                return chain.filter(exchange);
            }


            //์‚ฌ์šฉ์ž ์•„์ด๋””(pk)๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ
            String id = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();

            request.mutate()
                    .header("userId", id)
                    .header("token", jwt)
                    .build();
            ServerWebExchange exchange1 = exchange.mutate().request(request).build();

            log.info("exchange1 {}", id);

            log.info("ํ† ํฐ์ •๋ณด : {}", jwt);
            return chain.filter(exchange1);
        };
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(err);
        return response.setComplete();
    }

    private boolean isJwtValid(String jwt) {
        boolean returnValue = true;

        String subject = null;

        try {
            subject = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();

        } catch (Exception ex) {
            returnValue = false;
        }

        if (subject == null || subject.isEmpty()) {
            returnValue = false;
        }

        log.info("subject {}", subject);

        return returnValue;
    }

}

redis๋Š” jwtํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์–ด์žˆ๋Š”์ง€ ํ™•์ธ์„ ์œ„ํ•˜์—ฌ ์‚ฌ์šฉํ•œ๋‹ค. ๋กœ๊ทธ์•„์›ƒ์„ ํ• ์‹œ ํ† ํฐ์ด redis์— ์ €์žฅ๋˜๋Š”๋ฐ ํ•„ํ„ฐ์—์„œ ํ•ด๋‹น ํ† ํฐ์ด redis์— ์žˆ๋Š”์ง€ ์—†๋Š”์ง€ ๊ฒ€์‚ฌํ•˜๊ณ  ์žˆ๋‹ค๋ฉด ๋งŒ๋ฃŒํ† ํฐ์ด๊ธฐ๋•Œ๋ฌธ์— ์ ‘์†ํ•˜์ง€๋ชปํ•œ๋‹ค.

redis

  • ๋กœ๊ทธ์•„์›ƒ์„ ํ•œ ๊ฒฝ์šฐ user-service ์—์„œ logout()๋กœ์ง์„ ํƒœ์šด๋‹ค. user-service์— ๋Œ€ํ•˜์—ฌ๋Š” JWT + Spring Security + Spring Cloud = MSA 2. Spring Security์—์„œ ์„ค๋ช…ํ•˜์˜€๋‹ค.
    @Transactional
      public void logout(String token) {
          Date expiration = jwtTokenProvider.getExpiredTime(token);
          redisService.setAccessToken(token, expiration); // ํ† ํฐ์„ ์ €์žฅ
      }
    

    ์•„์ฐธ, RedisService.class๋Š” ์•„๋ž˜์ฝ”๋“œ

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, String> redisTemplate;

    public void setAccessToken(String accessToken, Date expiration) {
        redisTemplate.opsForValue()
                .set(accessToken, "logout", expiration.getTime() - new Date().getTime(), TimeUnit.MILLISECONDS);
    }

    public boolean isAccessTokenStored(String accessToken) {
        return redisTemplate.opsForValue()
                .get(accessToken) != null;
    }
}

2.2 AuthorizationFilter

  • ํšŒ์›๊ณผ ๋น„ํšŒ์› ํ•„ํ„ฐ
  • ๋น„ํšŒ์›์€ 401ํ™”๋ฉด์„ ๋ณด์—ฌ์ค€๋‹ค -> ์ด ๋•Œ onError()๋ฅผ ํ†ตํ•ด HTTP ์ƒํƒœ์ฝ”๋“œ๋ฅผ ์ถœ๋ ฅ
@Component
@Slf4j
public class AuthorizationFilter extends AbstractGatewayFilterFactory<AuthorizationFilter.Config> {
    Environment env;
    private final RedisService redisService;

    @Autowired
    public AuthorizationFilter(Environment env, RedisService redisService) {
        super(Config.class);
        this.env = env;
        this.redisService = redisService;
    }

    public static class Config {
        // Put configuration properties here
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            MultiValueMap<String, HttpCookie> cookies = request.getCookies();
            //log.info("cookie {}({})", cookies, cookies.get("token").get(0).getValue());

            String jwt = "";
            try {
                jwt = cookies.get("token").get(0).getValue();
            }
            catch (NullPointerException e) {
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            if (redisService.isAccessTokenStored(jwt) || !isJwtValid(jwt)) {
                log.info("ํ† ํฐ({})์€ ์‚ฌ์šฉ๋ถˆ๊ฐ€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.", jwt);
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            String id = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();

            request.mutate()
                    .header("token", jwt)
                    .header("userId", id)
                    .build();
            ServerWebExchange exchange1 = exchange.mutate().request(request).build();

            log.info("ํ† ํฐ์ •๋ณด : {}", jwt);




            return chain.filter(exchange1);
        };
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(err);
        return response.setComplete();
    }

    private boolean isJwtValid(String jwt) {
        boolean returnValue = true;

        String subject = null;

        try {
            subject = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();
        } catch (Exception ex) {
            returnValue = false;
        }

        if (subject == null || subject.isEmpty()) {
            returnValue = false;
        }

        log.info("subject {}", subject);

        return returnValue;
    }

}

2.3 AuthorizationAdminFilter

๊ด€๋ฆฌ์ž ํ•„ํ„ฐ์ด๋‹ค
์œ ์ € ๊ด€๋ฆฌ ํŽ˜์ด์ง€๋‚˜ ๊ด€๋ฆฌ์ž ๋ชจ๋“œ ํŽ˜์ด์ง€์— ์ ‘์†ํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ํ•„ํ„ฐ๋งํ•ด์ค€๋‹ค

  • ๊ด€๋ฆฌ์ž์ธ์ง€๋ฅผ ํ™•์ธํ•ด์•ผํ•˜๊ธฐ๋•Œ๋ฌธ์— Spring Security Role์„ ์‚ฌ์šฉํ•œ๋‹ค

@Component
@Slf4j
public class AuthorizationAdminFilter extends AbstractGatewayFilterFactory<AuthorizationAdminFilter.Config> {
    Environment env;
    private final RedisService redisService;

    @Autowired
    public AuthorizationAdminFilter(Environment env, RedisService redisService) {
        super(Config.class);
        this.env = env;
        this.redisService = redisService;
    }

    public static class Config {
        // Put configuration properties here
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            MultiValueMap<String, HttpCookie> cookies = request.getCookies();
            String jwt = "";
            try {
                jwt = cookies.get("token").get(0).getValue();
            }
            catch (NullPointerException e) {
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            if (redisService.isAccessTokenStored(jwt)) {
                log.info("ํ† ํฐ({})์€ ์‚ฌ์šฉ๋ถˆ๊ฐ€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.", jwt);
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }


            if (StringUtils.isEmpty(isJwtValid(jwt))) {
                System.out.println("jwt๋Š” null = " + jwt);
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            String id = Jwts.parser().setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();

            request.mutate()
                    .header("token", jwt)
                    .header("userId", id)
                    .build();
            ServerWebExchange exchange1 = exchange.mutate().request(request).build();

            log.info("ํ† ํฐ์ •๋ณด : {}", jwt);




            return chain.filter(exchange1);
        };
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(err);
        return response.setComplete();
    }

    private String isJwtValid(String jwt) {

        String role = null;

        try {
            role = String.valueOf(Jwts.parser()
                    .setSigningKey(env.getProperty("token.secret"))
                    .parseClaimsJws(jwt)
                    .getBody().get("role"));
        } catch (Exception ex) {
            role = null;
        }

        if (role == null || role.isEmpty() || role.equals("null") || role.equals("ROLE_USER")) {
            role = null;
        }
        log.info("role {}", role);
        return role;
    }

}

์œ„ ํ•„ํ„ฐ๋ฅผ ํ†ตํ•ด ํšŒ์› header์— userId๋ฅผ ์ €์žฅํ•  ์ˆ˜ ์žˆ๋‹ค

์˜ˆ๋ฅผ ๋“ค์–ด ๋‹ค๋ฅธ ์„œ๋น„์Šค ํด๋ผ์ด์–ธํŠธ์—์„œ ์•„๋ž˜์™€ ๊ฐ™์ด ์‚ฌ์šฉํ•œ๋‹ค

@GetMapping("/board-service/test/nickname")
@ResponseBody
public String nicknameEx(HttpServletRequest req){
    String userId = req.getHeader("userId");
    String token = req.getHeader("token");
    log.info("userId {}", userId);

    String userName = api.requestName(userId, token);
    return userName;
}

Spring

Java

๋Œ“๊ธ€ ์“ฐ๊ธฐ