MSA와 보안
포스트
취소

MSA와 보안

서비스 통신 간 보안

모놀리식 아키텍처에서는 서비스를 호출하고 싶다면 단순히 호출하면 된다.
왜냐하면 모놀리식에서는 여러 가지의 서비스가 하나의 애플리케이션에
모두 포함되어 있고, DB 또한 공유하기 때문이다.

하지만 MSA의 경우에는 상황이 좀 다르다.
MSA의 각 서비스는 사실상 각각의 서버다.
그래서 게이트웨이를 통해서 서비스를 호출할 수도 있지만,
서비스 또한 자체적인 인스턴스를 갖고 있기 때문에
개별적인 서비스를 직접 호출할 수도 있다.

그래서 각 서비스 간의 통신이든,
아니면 서비스를 직접 호출하든 간에
모놀리스 아키텍처에서 보다는
더욱 많은 양의 통신이 발생한다.

그렇기 때문에 MSA에서는 더더욱 보안에 신경써야 한다.

JWT (JSON Web Token)

정의

사용자 인증 및 정보 전달을 위해 사용되는 토큰 기반 인증 방식이다.
JSON 형식의 데이터를 안전하게 주고받기 위해 사용되며,
특히 STATELESS한 애플리케이션을 구현할 때 많이 사용된다.

build.gradle

JWT를 사용하려면 build.gradle에 아래와 같은 의존성을 추가해주자.

implementation "io.jsonwebtoken:jjwt-api:0.12.6"
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

서비스에서의 보안

서비스 측에서 보안에 대해 처리하는 방법을 알아 보자.

스프링 시큐리티와의 연동

스프링 클라우드는 스프링 진영의 기술이기 때문에
다른 스프링 기술과 호환이 잘 된다.

보안을 위해 스프링 진영의 기술인 스프링 시큐리티를 적용해보자.
스프링 시큐리티에 대해서 따로 배우진 않아서,
하나씩 주석을 달면서 진행하였다.

참고로 참고로 시큐리티 버전은 6.4.4로 진행하였다.

build.gradle

스프링 시큐리티를 사용하려면 build.gradle에 아래의 의존성을 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-security'

환경설정

스프링 시큐리티는 브라우저에서 직접적으로 보여지기 위해 사용하는 역할이 아니라,
보안을 위해 서버에서 동작하는 프레임워크다.
그래서 환경설정에 명시한 방식에 따라 동작이 달라진다.

환경설정을 위해서는 환경설정용 클래스에 @EnableWebSecurity 애노테이션을 추가하면 된다.
그 다음에는 HttpSecurity http를 파라미터로 받는 SecurityFilterChain 인터페이스를
빈으로 등록해주면 된다.

과거에는 WebSecurityConfigurerAdapter를 상속받았으나,
5.8 버전에서 6 버전으로 마이그레이션 되면서 방식이 바뀌었다.

대략 이런 느낌으로 만들면 된다.

@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 필요한 내용 작성
        return http.build();
    }
}

시큐리티의 설정은 대부분 저 클래스의 filterChain 메소드에서 이루어진다.
해당 게시글에서 스프링 시큐리티 관련 내용은 filterChain 메소드에 추가되는 내용이다.

URL 패턴별 정보 설정하기

requestMatchers라는 메소드를 통해 URL 패턴별로 구성 정보를 변경할 수 있다.
만약 아래와 같은 코드가 있다고 가정해보자.

http.authorizeHttpRequests(
    authz ->
        authz
        .requestMatchers("/user-service/**").permitAll()
        .requestMatchers("/users/**").permitAll()
        .requestMatchers("/h2-console/**").permitAll()
);

메소드명 그대로 동작한다.
requestMatchers 메소드를 통해 URL 패턴을 지정하고,
permitAll 메소드를 통해 접속을 허용한다.

참고로 저렇게 패턴을 지정하게 되면,
그 이외의 패턴에 대해서는 접근이 불가능하다.
실제로 중간에 있는 패턴을 주석처리해보고
접근을 시도해보면 권한이 없다고 HTTP 상태 코드가 반환된다.

시큐리티 6 이전 버전이면 requestMatchers 대신에
antMatchers를 사용하면 된다.

프레임 처리

만약 별도의 설정이 없다면
스프링 시큐리티는 기본적으로 보안 상 frame 태그와 iframe 태그를 막는다.

그런데 h2를 사용해서 콘솔 페이지로 접속하고 싶다면
해당 설정을 비활성화해야 한다.
왜냐하면 h2 콘솔은 frame 기반으로 동작하기 때문이다.

frame 설정을 비활성화하고 싶다면 아래와 같은 코드를 추가하면 된다.

// frame or iframe에 대한 허용 (사유 : h2 concole)
http.headers(
    headers -> headers.frameOptions(config -> config.disable())
);

CSRF에 대한 처리

CSRF란 Cross site Request forgery의 약자로,
사이트 간 위조 요청을 의미한다.

그럼 사이트 간 위조 요청이란 무엇이냐 하면
특정 서버를 공격하려고 하는 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여
해당 서버에 사용자가 의도하지 않은 요청을 보내는 것을 의미한다.
간단히 해석하면 남의 정보 뺏어다가 요청을 그 사람인 것 마냥 요청을 보낸다는 뜻이다.

스프링 시큐리티에서는 기본적으로 이러한 CSRF를 기본적으로 막도록 설정되어 있다.
그래서 GET 요청말고 다른 POST나 PUT같이 상태를 변경할 수 있는 요청은
CSRF 토큰이라는 값이 있어야지만 받아들이게 되어 있다.
다만 공식 문서에서도 이러한 설정을 꺼도 괜찮다고 적어둔 케이스가 있는데,
바로 브라우저가 아닌 클라이언트를 위한 서비스다.

왜냐하면 REST API 전용 서버라면 서버에 세션 정보를 저장할 필요가 없기 때문이다.
REST API에서 권한이 필요한 요청을 보내려면 요청에 JWT 토큰처럼 항상 별도의 요청 정보를 담아야 한다.
그러면 이미 별도의 요청 정보를 매 요청마다 담아서 보내니 CSRF 토큰을 받을 필요도 없어진다.

CSRF에 대한 설정을 비활성화하고 싶다면 아래와 같이 하면 된다.

// csrf 토큰 사용 방지
http.csrf(config -> config.disable());

IP 제한하기

어느 IP의 요청만 받아들일지에 대한 설정도 적용할 수 있다.
과거 버전에서는 antMatchers 메소드에 바로 hasIpAddress 메소드를 통해 적용할 수 있었지만
스프링 시큐리티 6버전부터는 다른 방식을 적용해야 한다.

우선 지정할 IP에 대한 정보를 명시하자.

// 선언부
public static final String ALLOWED_IP_ADDRESS = "127.0.0.1";
public static final String SUBNET = "/32";
public static final IpAddressMatcher ALLOWED_IP_ADDRESS_MATCHER = new IpAddressMatcher(ALLOWED_IP_ADDRESS + SUBNET);

그런 다음에 AuthorizationDecision를 반환하는 메소드를 만들자.

/**
* IP 체크
* @param authentication
* @param object
* @return
*/
private AuthorizationDecision hasIpAddress(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
    return new AuthorizationDecision(ALLOWED_IP_ADDRESS_MATCHER.matches(object.getRequest()));
}

그런 다음에 requestMatchers 메소드 다음에 access 메소드를 통해 IP 관련 정보를 설정해주자.
아래는 관련된 간단한 예시다.

http.authorizeHttpRequests(
    authz ->
        authz
        .requestMatchers("/**").access(this::hasIpAddress)
    );

비밀번호 암호화

스프링 시큐리티에서는 비밀번호 단방향 암호화 인터페이스인 PasswordEncoder를 지원한다.
HttpSecurity에서 getSharedObject 메소드를 통해
AuthenticationManagerBuilder라는 빌더 클래스를 가져오자. 그런 다음에 해당 인터페이스를 구현한 구현체와 암호화를 진행할 서비스를 빌더에 등록하면 된다.

// 선언부
private final UserService userService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);

// AuthenticationManager를 빈으로 등록
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
    AuthenticationManagerBuilder managerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
    managerBuilder
        .userDetailsService(userService)
        .passwordEncoder(bCryptPasswordEncoder);

    return managerBuilder.build();
}

다만 그 전에 챙겨야 하는 중요 사항이 있는데,
userDetailsService 메소드에 등록할 서비스 인터페이스는
스프링 시큐리티에서 제공하는 UserDetailsService라는 인터페이스를 상속하거나 구현해야 한다.
그렇게 되면 loadUserByUsername라는 메소드를 구현하게 되는데,
아래와 같이 구현하면 된다.

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    UserEntity userEntity = userRepository.findByEmail(username);
    if (userEntity == null) {
        throw new UsernameNotFoundException(username);
    }

    return new User(
        userEntity.getEmail(), // 사용자의 아이디 (예: 이메일, 로그인 ID 등)
        userEntity.getEncryptedPwd(), // 인코딩된 비밀번호
        true, // 계정 활성화 여부
        true, // 계정이 만료되지 않았는지 여부
        true, // 비밀번호가 만료되지 않았는지 여부
        true, // 계정이 잠기지 않았는지 여부
        new ArrayList<>() // 사용자의 권한 목록 (ex: ROLE_USER, ROLE_ADMIN)
    );
}

AuthenticationManager을 빈으로 등록하는 이유

스프링 시큐리티 6 이전 버전에서는 아래와 같이 사용하기도 했다.

// 선언부
private final UserService userService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;

// configure 메소드 내부
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder);

그런데 스프링 시큐리티 6부터는 SecurityFilterChain을 구성하는 HttpSecurity 객체가
이미 내부적으로 AuthenticationManager를 구성되어 있다. 즉, 수동으로 AuthenticationManager를 build()해서 따로 생성하면
스프링 시큐리티가 자동으로 구성하려는 AuthenticationManager와 충돌이 발생할 수 있다.

그래서 빌더를 따로 생성해보면 아래와 같은 에러 메시지가 발생하게 된다.
Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration'
해당 메시지는 스프링이 내부적으로 WebSecurityConfiguration에서 AuthenticationManager 빈을 만들려고 할 때,
이미 생성된 다른 인스턴스와 충돌이 생긴다는 것을 의미한다.

스프링 시큐리티 6 이상의 버전의 사용하는데
일반적인 자료들을 보고 필터에서 쓰려고
configure 메소드 내부에서 빌더를 통해 AuthenticationManager의
인스턴스를 생성하게 되면 원인 찾기 어려운 꽤나 힘든 상황이 될 것이다…

로그인 요청 시 인증 및 JWT 토큰을 반환하는 필터 만들기

스프링 시큐리티의 로그인 요청을 처리하는 기본 필터인
UsernamePasswordAuthenticationFilter 클래스를 상속받은 필터를 만들어보자.

@RequiredArgsConstructor
@Slf4j
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final UserService userService;
    private final AuthenticationManager authenticationManager;

    /**
     * 인증 시도
     * @param req
     * @param res
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
        try {
            //요청 정보에서 로그인에 필요한 정보 추출하기
            RequestLogin creds = new ObjectMapper().readValue(req.getInputStream(), RequestLogin.class);

            // 회원 정보 조회 (실제로는 존재하는지만 확인하는 짧은 쿼리를 사용하는 것을 추천)
            UserDto user = userService.getUserDetailsByEmail(creds.getEmail());
            if (user == null) {
                throw new UsernameNotFoundException("회원 정보가 존재하지 않습니다.");
            }

            // 인증을 위한 정보를 담아서 반환
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(creds.getEmail(), creds.getPassword());

            return authenticationManager.authenticate(authenticationToken);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 인증 성공
     * @param req
     * @param res
     * @param chain
     * @param auth
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(
            HttpServletRequest req,
            HttpServletResponse res,
            FilterChain chain,
            Authentication auth
    ) throws IOException, ServletException {
        String secret = env.getProperty("token.secret"); // JWT 토큰을 암호화하기 위한 key
        Long expireTime = Long.parseLong(env.getProperty("token.expiration_time")); // JWT 토큰의 유효 시간 (단위 : 밀리초)

        // 인증 시도 정보에서 고유값 추출
        String userName = ((User) auth.getPrincipal()).getUsername();

        // 회원 정보 조회 (실제 회원 정보를 조회하는 상세한 쿼리를 사용하는 것을 추천)
        UserDto userDetails = userService.getUserDetailsByEmail(userName);

        // JWT 서명을 위한 비밀키 생성
        byte[] secretKeyBytes = Base64.getEncoder().encode(secret.getBytes());
        SecretKey secretKey = Keys.hmacShaKeyFor(secretKeyBytes);

        // 현재 시간을 기반으로 발급 시간 및 만료 시간 설정
        Instant now = Instant.now();

        // JWT 토큰 생성
        String token =
            Jwts.builder()
            .subject(userDetails.getUserId()) // 식별자에 고유 사용자 ID 설정
            .issuedAt(Date.from(now)) // 발급 시간 설정
            .expiration(Date.from(now.plusMillis(expireTime))) // 만료 시간 설정
            .signWith(secretKey) // 서명 키 설정
            .compact(); // 최종 JWT 문자열 생성

        // 결과 데이터 설정
        HashMap<String, Object> resultData = new HashMap<>();
        resultData.put("message", "로그인 성공");
        resultData.put("token", token);

        // 결과 반환
        res.addHeader("token", token);
        res.addHeader("userId", userDetails.getUserId());
        res.setContentType("application/json");
        res.getWriter().write(new ObjectMapper().writeValueAsString(resultData));
    }

    /**
     * 인증 실패
     * @param req
     * @param res
     * @param failed
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse res, AuthenticationException failed) throws IOException, ServletException {
        // 결과 데이터 설정
        HashMap<String, Object> resultData = new HashMap<>();
        resultData.put("message", "로그인 실패");
        resultData.put("token", failed.getMessage());

        // 결과 반환
        res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        res.setContentType("application/json");
        res.getWriter().write(new ObjectMapper().writeValueAsString(resultData));
    }
}

필터 적용하기

필터를 적용하는 방법은 매우 간단하다.
Filter 인터페이스를 구현한 필터를 addFilter 메소드를 통해 등록해주면 된다.

필터_클래스 필터_인스턴스 = new 필터_클래스();

//필터 적용
http.addFilter(필터_인스턴스);

게이트웨이에서의 보안

게이트웨이 측에서 보안에 대해 처리하는 방법을 알아 보자.

JWT 토큰을 검증하는 필터 만들기

기존에 게이트웨이 쪽에서 적용햇던 필터를 만들었던 것처럼,
AbstractGatewayFilterFactory 클래스를 상속받은 필터 클래스를 만들어주자.

@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
    Environment env;

    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class);
        this.env = env;
    }

    public static class Config {
        // 환경설정
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            // 요청 가져오기
            ServerHttpRequest request = exchange.getRequest();

            // 요청 헤더에 인증에 대한 정보가 있는 지 확인
            if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
            }

            // 요청 헤더에서 JWT 토큰 추출
            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String jwt = authorizationHeader.replace("Bearer ", "");

            // JWT 토큰의 유효 여부 확인
            if (!isJwtValid(jwt)) {
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            return chain.filter(exchange);
        };
    }

    /**
     *  에러가 발생한 경우에 대한 처리
     * @param exchange
     * @param err
     * @param httpStatus
     * @return
     */
    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);
        log.error(err);

        byte[] bytes = "The requested token is invalid.".getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        return response.writeWith(Flux.just(buffer));
    }

    /**
     * JWT 토큰의 유효 여부 확인
     * @param jwt JWT 토큰
     * @return
     */
    private boolean isJwtValid(String jwt) {
        String secret = env.getProperty("token.secret"); // JWT 토큰을 암호화하기 위한 key
        
        // JWT 서명을 위한 비밀키 생성
        byte[] secretKeyBytes = Base64.getEncoder().encode(secret.getBytes());
        SecretKey secretKey = Keys.hmacShaKeyFor(secretKeyBytes);

        String subject;

        try {
            // JWT 토큰 번역하기
            JwtParser jwtParser =
                Jwts
                .parser() // parserBuilder => parser
                .verifyWith(secretKey) // setSigningKey => verifyWith
                .build();
            
            // 고유 값 가져오기
            subject =
                jwtParser
                .parseSignedClaims(jwt) // parseClaimsJws => parseSignedClaims
                .getPayload() // getBody => getPayload
                .getSubject();

            if (subject != null && !subject.isEmpty()) {
                return true;
            }
        } catch (Exception ex) {
            return false;
        }
        return false;
    }
}

필터 적용하기

이제 JWT 인증이 필요한 부분에 필터를 적용해주자.

spring:
  application:
    name: apigateway-service
  cloud:
    gateway:
      # 중간 생략
      routes:
        # 중간 생략
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/**
            - Method=GET
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            - AuthorizationHeaderFilter

RemoveRequestHeader

요청 헤더의 특정 항목을 지워주는 역할을 한다.

RewritePath

MSA의 마이크로서비스는 엄연히 각각의 서비스다.
그런데 게이트웨이를 위해 API 주소에 별도의 주소를 추가하게 된다면
그건 게이트웨이에 종속이 되어 버리기 때문에 MSA 장점을 잃어버린다.

그래서 존재하는 것이 RewritePath 옵션이다.
만약 위처럼 RewritePath=/user-service/(?<segment>.*), /$\{segment}라고 명시했다고 가정해보자.
그랬을 때 게이트웨이에 /user-service/users라는 API를 호출했다면,
기존에는 게이트웨이가 서비스에 동일한 주소를 보냈을 것이다.
하지만 RewritePath 옵션을 적용하게 되면
해당 API의 주소에서 /user-service를 제외하고
서비스에는 /users라는 API를 호출한다.

이렇게 설정함으로써 게이트웨이의 역할도 유지하고, MSA의 장점도 유지할 수 있다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

서비스 디스커버리 (Service Discovery)

Spring Cloud Config