이 글은 인프런 Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의를 듣고 쓴 글입니다.
Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) - 인프런 | 강의
Spring framework의 Spring Cloud 제품군을 이용하여 마이크로서비스 애플리케이션을 개발해 보는 과정입니다. Cloud Native Application으로써의 Spring Cloud를 어떻게 사용하는지, 구성을 어떻게 하는지에 대해
www.inflearn.com
User Microservice 기능 추가 - Login
기능 | URI (API Gateway 사용 시) | URI (API Gateway 미사용 시) | HTTP Method |
사용자 로그인 | /user-service/login | /login | POST |
섹션 6 에서는 로그인 기능을 구현할 것이다. API Gateway의 필터에서 비밀번호를 검증하고 Service 클래스를 조정할 것이다.
또한 마지막에선 앞에서 말했듯이, /user-service/login 과 같이 서비스명을 같이 쓰지 않고 /login 과 같이 서비스명을 몰라도 API 에 접근할 수 있도록 할 것이다.
(앞에서 말했듯이, WebSecurity 설정은 filter를 bean으로 올리는 방법이 아니라 WebSecurityConfigurerAdapter 를 상속하는 방식으로 하겠다)
로그인을 하기 위한 요청 vo 인 RequestLogin 클래스를 다음과 같이 작성하자.
@Data public class RequestLogin { @NotNull(message = "Email cannot be null") @Size(min = 2, message = "Email not be less than two characters") @Email private String email; @NotNull(message = "Password cannot be null") @Size(min = 8, message = "Password must be equals or greater than 8 characters") private String password; }
AuthenticationFilter 추가
이제 로그인 요청 때 검증을 할 필터를 직접 구현할 것이다. 이 필터는 UsernamePasswordAuthenticationFilter 라는 클래스를 상속해서 구현한다.
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class); return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( creds.getEmail(), creds.getPassword(), new ArrayList<>()) ); } catch (IOException e) { throw new RuntimeException(e); } } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { // super.successfulAuthentication(request, response, chain, authResult); } }
- 많은 메소드들 중 구현할 메소드는 실제 요청이 들어오면 실행할 attemptAuthentication 메소드와 로그인이 성공하면 실행하게 될 successfulAuthentication 메소드이다. 각각은 요청과 응답의 서블릿을 파라미터로 가진다.
- attemptAuthentication 메소드
- attemptAuthentication 메소드에서는 request 서블릿에서 getInputStream()으로 ServletInputStream 으로 불러오고, ObjectMapper를 통해 body 부분을 원하는 vo 에 매핑시킨다.
- 이제 필터에 넘겨줄 인증정보를 생성한다. 스프링 시큐리티에서 인증에 활용할 수 있는 값으로 변형하는 것인데, 이것에 해당하는 클래스가 UsernamePasswordAuthenticationToken 이다. 여기에 username(우리는 이메일), password, 권한 리스트(아직은 빈 리스트) 를 넘기면 된다. 이 인증정보를 그대로 리턴한다.
successfulAuthentication은 로그인이 성공했을 때, 어떤 토큰을 어떻게 줄 것인지, 만료시간은 어떻게 할 것인지 등등을 설정하면 된다. 나중에 하고 넘어가자.
이제 이전에 하던 WebSecurity 클래스로 넘어가자. 이전에는 /users/** 인 모든 요청에 대해 허용을 했는데, 이제 바꿔보자.
@RequiredArgsConstructor @Configuration @EnableWebSecurity public class WebSecurity { private final UserService userService; private final BCryptPasswordEncoder bCryptPasswordEncoder; private final ObjectPostProcessor<Object> objectPostProcessor; private final Environment env; @Bean protected SecurityFilterChain config(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception { http.csrf(csrf -> csrf.disable()); http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())); http.authorizeHttpRequests(authorize -> authorize .requestMatchers(mvc.pattern("/**")).permitAll() .requestMatchers(PathRequest.toH2Console()).permitAll() .requestMatchers(new IpAddressMatcher("192.168.0.17")).permitAll()); http.addFilter(getAuthenticationFilter()); return http.build(); } public AuthenticationManager authenticationManager(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService).passwordEncoder(bCryptPasswordEncoder); return auth.build(); } private AuthenticationFilter getAuthenticationFilter() throws Exception { AuthenticationFilter authenticationFilter = new AuthenticationFilter(userService, env); AuthenticationManagerBuilder builder = new AuthenticationManagerBuilder(objectPostProcessor); authenticationFilter.setAuthenticationManager(authenticationManager(builder)); return authenticationFilter; } @Bean AuthenticationManager authenticationManager( AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Scope("prototype") @Bean MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) { return new MvcRequestMatcher.Builder(introspector); } }
- 일단 회원가입과 관련된 "/users/**" 등에 모든 접근을 허용했다. 이걸 허용하지 않으면 회원가입, 로그인을 못 한다. 추가적으로 h2 console 에 대한 접근도 허용했다.
- 내 사설 ip로 접근을 하면 접근을 허용했다.
- 이제 아까 우리가 작성했던 필터를 추가한다.
- 이외에 objectPostProcesser 나 authenticationManager 를 활용한 코드들은 모두 다음 글을 참고했다. spring security 최신버전을 따로 공부할 필요가 있어 보인다.
- 밑의 url 과 다른 점은 MvcRequestMatcher 부분이다. 정말 고생 많이 한 부분이
Spring Security 최신버전(Spring Boot 3.X.X 대)의 WebSecurity 설정 공유드립니다. - 인프런 | 질문 & 답변
최신버전으로 진행하다보니 막혔었는데요. 구글링, ChatGPT 등을 통해서 동작하는 코드 공유드립니다.정확한 구현은 아닐 수 있겠지만, 강의를 진행하는 데는 문제 없는 것 같습니다. 참고만 부탁
www.inflearn.com
이 url의 코드와 다른 점은 MvcRequestMatcher 부분이다. 정말 고생 많이 한 부분이다...
requestMathers 의 인자로 string 혹은 string[] 형이 들어갈 수 있지만, 그렇게 했을 때 다음과 같은 오류가 나왔다.
This method cannot decide whether these patterns are Spring MVC patterns or not. If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); otherwise, please use requestMatchers(AntPathRequestMatcher).
H2 데이터베이스와 스프링 시큐리티가 충돌한 듯 했다. 대강 이유를 검색해보니, H2 데이터베이스에 접근하는 서블릿은 MVC 패턴이 아니라서 두 종류의 서블릿이 존재하므로, requestMatchers 의 인자로 일반적인 string 말고, MvcRequestMatcher 라는 자료형으로 바꿔줘야 한다는 것 같다.
중요한 건 아닌 것도 같고, 굉장히 최근에 발견된 따끈따끈한 취약점인 것 같다. 아래를 참고하자.
CVE-2023-34035: Spring Security Authentication Bypass Vulnerability
1. Summary Vulnerability Name Openfire Authentication Bypass Vulnerability (CVE-2023-32315) Release Date July 19, 2023
www.sangfor.com
Spring security method cannot decide pattern is mvc or not spring boot application exception
When I try to run an application it fails to start and throws this exception. This method cannot decide whether these patterns are Spring MVC patterns or not. If this endpoint is a Spring MVC endpo...
stackoverflow.com
참고
다음 글은 spring security의 옛날 버전과 최신 버전을 migration 하는 데에 도움을 줄 수 있다.
https://covenant.tistory.com/277
WebSecurityConfigurerAdapter Deprecated 대응법
WebSecurityConfigurerAdapter란? 스프링 시큐리티를 사용하면 기본적인 시큐리티 설정을 하기 위해서 WebSecurityConfigurerAdapter라는 추상 클래스를 상속하고, configure 메서드를 오버라이드하여 설정하였습
covenant.tistory.com
https://covenant.tistory.com/279
스프링 부트 2에서 스프링 부트 3로 업그레이드 가이드
0. 시작하며 22년 11월 스프링 부트 3가 정식 릴리즈 되었습니다. 18년 3월 1일 스프링 부트 2가 나온 이후 3년 9개월의 시간이 지난 오랜만의 메이저 업데이트 입니다. 기존의 프로젝트를 스프링 부
covenant.tistory.com
loadUserByUsername() 구현
우리는 UserEntity의 email과 password를 각각 username과 password에 대응시키면서 작업을 해왔다. 근데 정작 실제 UserService는 spring security와는 전혀 상관 없는 클래스이다. spring security에서 제공하는 UserDetailService라는 클래스를 상속받게 해서 spring security에서 제공하는 로그인 기능을 UserEntity를 통해 사용할 수 있도록 하자.
우선, UserService 인터페이스가 UserDetailService 클래스를 상속받게 하자.
public interface UserService extends UserDetailsService { UserDto createUser (UserDto userDto); UserDto getUserByUserId(String userId); Iterable<UserEntity> getUserByAll(); }
이러면 UserService가 loadByUsername() 이라는 메소드를 구현해야 한다. 이 메소드는 사용자가 입력한 username(우리는 email)로부터 데이터베이스에 있는 userEntity를 꺼내와 비교할 수 있도록 해준다.
@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(), userEntity.getEncryptedPwd(), true, true, true, true, new ArrayList<>()); }
userRepository에 findByEmail이 없다면 적당히 넣어준다. 인자의 true들은 일단 넘어가자... 빈 리스트는 권한 리스트이다.
API GATEWAY의 Routing 변경
apigateway-service의 라우팅 설정을 조금 바꿔주자. 같은 서비스라도 다른 Path에 대해 라우팅을 다르게 하기 위해서이다. 여기서는 라우팅을 다르게 하진 않지만, 나중에 다르게 하기 위해서 설정를 만져준다고 생각하면 편할 것이다.
apigateway-service의 application.yml 파일의 user-service 부분을 다음과 같이 바꿔주자
- id: user-service uri: lb://USER-SERVICE predicates: - Path=/user-service/login - Method=POST filters: - RemoveRequestHeader=Cookie - RewritePath=/user-service/(?<segment>.*), /$\{segment} - id: user-service uri: lb://USER-SERVICE predicates: - Path=/user-service/users - Method=POST filters: - RemoveRequestHeader=Cookie - RewritePath=/user-service/(?<segment>.*), /$\{segment} - id: user-service uri: lb://USER-SERVICE predicates: - Path=/user-service/** - Method=GET filters: - RemoveRequestHeader=Cookie - RewritePath=/user-service/(?<segment>.*), /$\{segment}
- 2개의 Post 요청 (/user-service/login, /user-service/users), 로그인과 회원가입에 대해 각각 따로 설정을 하려고 한다. 또한 이외의 모든 Get 요청에 대해서도 따로 설정을 한다. 아직은 모두 같은 설정이다 (나중에 바꾸려고!)
- POST로 전달되는 데이터 값은 매번 새로운 데이터로 인식될 수 있게 RemoveRequestHeader 를 Cookie 로 설정한다.
- RewritePath는 ,를 기준으로 앞에 있는 값을 뒤에 있는 값으로 바꾼다. 아직 자세히 파고들진 말자. 이렇게 바꾸면, 우린 localhost:8000/user-service/** 의 요청을 localhost:(랜덤포트)/** 로 바꿀 수 있다.
로그인 테스트
로그인이 잘 되는지 테스트하고, 디버그 모드를 이용해 필터에서 실제로 생각한대로 작동되는지 보자.


회원가입과 로그인은 의도대로 잘 작동한다. 디버거를 이용해 더 자세히 뜯어보자. 그리고 테스트를 위해 successfulAthentication 메소드에 다음과 같은 코드를 작성하자. 인증이 성공하면 해당 유저의 username을 출력하는 코드이다.
log.debug( ((User)authResult.getPrincipal()).getUsername() );
user-service 프로젝트를 디버그 모드로 실행한 뒤, 적당한 곳에 중단점을 두고 디버깅해보자. 회원가입을 완료하고 로그인을 하면서 멈추는 곳을 보면 된다.
디버그 모드를 잘 활용하는 것은 중요해 보인다. 중단한 곳에서의 상태에서 원하는 수식을 검증해볼 수도 있다.

AuthenticationFilter 의 코드 중, servlet으로부터 RequestLogin 객체를 받아온 부분이다. 원하던 email과 password 가 잘 들어있다.
로그인이 끝난 후 다음과 같이 로깅도 잘 된다.

이제, 로그인이 끝난 사용자에게 JWT 토큰을 발급해 인증에 쓰일 수 있도록 해보자.
JWT 발급
인증 후 UserDto 반환
먼저, 인증 성공 후에 처리될 메소드인 successfulAuthentication() 을 다음과 같이 작성한다.
@Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { String userName = ((User)authResult.getPrincipal()).getUsername(); UserDto userDetails = userService.getUserDetailsByEmail(userName); }
사용자의 username으로 데이터베이스에 있는 UserEntity를 참조해 UserDto를 가져오는 것이다. UserDto에서 userId 값을 이용해 JWT 토큰을 만들 것이다.
여기에 필요한 userService와 나중에 필요할지도 모르는 Environment를 변수로 생성해 사용하자. Constructor 주입을 하면 된다.
이러면 WebSecurity에 살짝의 변경점이 다음과 같이 생긴다.
private AuthenticationFilter getAuthenticationFilter() throws Exception { AuthenticationFilter authenticationFilter = new AuthenticationFilter(userService, env); AuthenticationManagerBuilder builder = new AuthenticationManagerBuilder(objectPostProcessor); authenticationFilter.setAuthenticationManager(authenticationManager(builder)); return authenticationFilter; }
AuthenticationFilter의 생성자에 userService와 env객체를 넣어주어야 한다.
또, UserService의 getUserDetailsByEmail() 메소드는 아는대로 잘 구현해주면 된다. 최종 구현된 함수는 다음과 같다.
@Override public UserDto getUserDetailsByEmail(String email) { UserEntity userEntity = userRepository.findByEmail(email); if (userEntity == null) throw new UsernameNotFoundException(email); UserDto userDto = new ModelMapper().map(userEntity, UserDto.class); return userDto; }
이제 진짜 JWT를 생성해보자.
JWT 생성
jwt 생성에 필요한 dependency를 추가하자.
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>
밑의 jaxb-api 를 추가하지 않아서 한참이나 오류를 겪었다.
이제 jwt의 유효시간과 비밀키를 application.yml 파일에 추가하자.
token: expiration_time: 86400000 # one day secret: user_token # random string
이제 successfulAuthentication으로 돌아가자. 다음과 같이 작성해준다.
@Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { String userName = ((User)authResult.getPrincipal()).getUsername(); UserDto userDetails = userService.getUserDetailsByEmail(userName); String token = Jwts.builder() .setSubject(userDetails.getUserId()) .setExpiration(new Date(System.currentTimeMillis() + Long.parseLong(env.getProperty("token.expiration_time")))) .signWith(SignatureAlgorithm.HS512, env.getProperty("token.secret")) .compact(); response.addHeader("token", token); response.addHeader("userId", userDetails.getUserId()); }
토큰을 생성하고, userId와 함께 헤더에 넣어서 응답해준다. 다시 실행해보고 회원가입 후, 로그인해보자.

Header에 token과 userId를 키로 하는 값들이 들어있는 것을 볼 수 있다. 성공!!!
이제 이 JWT 발급이 끝난 후, 이것을 이용해 인증하는 과정을 보자.
JWT 처리 과정
전통적으로 인증 시스템은 세션과 쿠키를 이용해서 이루어졌다. 예전에는 백엔드와 프론트엔드가 나눠지지 않고 합쳐진 모놀리스 방식이 많았는데, 이때는 시스템 전체에서 세션과 쿠키를 공유하는데 문제가 없었다. 하지만 지금은 백엔드와 프론트엔드가 나눠지고, 서비스들도 많아지면서 공유가 어려워졌다. 게다가 웹이 아닌 모바일 환경에서는 이런 문제가 더 심해지고, 모바일에서는 HTML이 아니라 JSON 등의 포맷이 필요하다. 따라서 토큰 기반 인증 시스템이 대두되었다.
여기에 활용되는 표준 토큰이 바로 JWT 이다. Json Web Token 의 줄임말로, 두 시스템끼리 안전한 방법으로 통신하게 해준다. 이 토큰은 실제 값으로 쉽게 decoding 된다. jwt.io 사이트에 들어가 방금 생성한 토큰값을 넣으면 decoding 되는 걸 볼 수 있다.
NodeJS, PHP, Java, Ruby, .NET, Python 등이 모두 지원된다.
JWT의 장점으로는 다음이 있다.
- 클라이언트 독립적인 서비스 (Stateless)
- CDN
- No Cookie-Session (No CSRF, 사이트간 요청 위조)
- 지속적인 토큰 저장
이제 API GATEWAY에서 회원가입과 로그인을 제외한 모든 요청에 JWT 인증 절차가 들어가도록 구현해보자.
API GATEWAY에서의 인증 절차
apigateway-service 의 pom.xml에 아까 user-service에 추가했던 것을 똑같이 추가해준다. jwt 검증과정에 필요하다.
이제 인증하는 필터를 만들자.
@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 { } //login -> token -> users (with token) -> header (include token) @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); } String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0); String jwt = authorizationHeader.replace("Bearer", ""); if (!isJwtValid(jwt)) { return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED); } return chain.filter(exchange); }; } 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; } return returnValue; } // Mono, Flux -> Spring WebFlux 에서 나오는 새로운 단위 private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(httpStatus); log.error(err); return response.setComplete(); } }
- 이전에 했던 대로 상속을 받고 생성자를 만든다. 그리고 Config 가 파라미터인 apply 를 implement 한다.
- request에서부터 Http 헤더의 Authorization에 들어있는 문자열을 갖고와서, Bearer라는 부분을 없애준다.
- 이게 옳은 JWT인지 확인하는 메소드가 isJwtValid 이다. user-service의 application.yml 에 넣었던 토큰의 유효시간과 secret 값을 이 서비스에도 넣은 뒤, secret으로 해당 JWT 가 옳은지 확인하는 것이다.
- 여기서 Subject, 즉 userId 값을 가져온다. 이게 비어있다면 false를 리턴한다.
하지만 뭔가 이상하다,,, 제대로 된 코드가 아니라고 생각이 든다. 이 코드에서는 JWT 가 내 secret code로 만들어진 정상적인 토큰인지만 확인하고, 그게 실제 로그인한 회원의 사용자인 것을 확인하지 않는다. 즉, 코드에서 subject 변수가 비어있지 않기만 하면 보안을 통과시키는 게 이상하다는 것!! 이 글 마지막에 더 자세한 실험을 해보겠다.
이제 application.yml 파일에 다음과 같이 회원가입, 로그인을 제외한 모든 GET 요청에 필터를 적용시킨다.
- id: user-service uri: lb://USER-SERVICE predicates: - Path=/user-service/** - Method=GET filters: - RemoveRequestHeader=Cookie - RewritePath=/user-service/(?<segment>.*), /$\{segment} - AuthorizationHeaderFilter
JWT 테스트
다시 API Gateway를 실행시키고 회원가입과 로그인을 실행한다. 여기서 나온 JWT 를 이용해 GET 요청의 권한을 처리할 것이다.
만약, GET 요청을 기존과 같이 보낸다면, API Gateway 에서 막히는 것을 확인할 수 있다.
토큰 값을 다음과 같이 요청의 Authorization의 Bearer Token에 넣는다.

이렇게 올바르게 토큰을 넣고 요청을 하면 원래와 같이 정상적으로 요청이 처리되는 것을 볼 수 있다.
아 시큐리티때문에 죽을 뻔 했네 진짜..
JWT 테스트2
위에서 언급한대로, 로그인할 때 실제 사용자인지 확인하지 않고 JWT가 내 secret code로 만들어진 올바른지만 확인하는 것을 실험해보겠다.
우선 user-service를 기동하고 회원가입 후 로그인을 해서 JWT 를 얻는다.

여기서 얻은 토큰은 정상적으로 만들어진 토큰이다.
여기서 user-service를 재시작하면 어떻게 될까? H2 데이터베이스는 모두 날아가서 회원이 한 명도 없는 서비스가 된다.

그럼 방금 발급받은 토큰은 이 서비스의 실제 사용자가 아닌 토큰인 것이다.
자, 이 토큰으로 서비스에 GET 요청을 날려보자. 정상적인 보안이라면, 서비스와 상관없는 토큰이므로 접근을 막아야 할 거라고 생각이 든다.

API 가 잘 작동된다. 이게 맞는 검증절차인지는... 잘 모르겠다 추후에 알아내면 추가하도록 하겠다
'[MSA] Spring Cloud로 개발하는 마이크로서비스 애플리케이션' 카테고리의 다른 글
섹션 8 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.08.11 |
---|---|
섹션 7 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.08.04 |
섹션 5 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.28 |
섹션 4 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.28 |
섹션 3 : Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) (0) | 2023.07.21 |