[Spring Cloud] Microservice(MSA) 구축 : (3) MSA에서의 Authentication / Authorization
이번 포스팅에서는 MSA 아키텍처 구조에서의 인가(Authorization)에 대해서 다루어 보려고 한다.
아래의 내용은 일반적인 JWT 기반의 인증과 인가 플로우이다. 그렇다면, 여러개의 서버가 존재하는 MSA 구조에서는 해당 과정의 처리를 어떻게 해야 할지 알아보도록 하자.
클라이언트 | 서버 | |
인증 | ||
1 | 로그인 요청 | |
2 | 데이터베이스에서 ID와 비밀번호 대조 후 일치여부 확인 | |
3 | 일치 시 암호화된 토큰 생성 | |
4 | 응답으로 토큰을 반환 | |
5 | 클라이언트는 토큰을 저장 | |
인가 | ||
1 | API 요청 시 헤 토큰을 포함시켜 요청 | |
2 | 토큰을 복호화하여 유효성 검증 | |
3 | 검증 완료되었다면 API 로직 처리 후 응답 | |
4 | 응답을 받음 |
프로젝트 구조
User-Service에서의 인증(Authorization)
일반적으로 Application에서 인증(Authentication) 과정에서 사용되는 JWT 인증 방식은, 로그인 시 토큰을 발급받는다.
아래의 로직은 Microservice들 중 UserService에서 로그인을 처리하기 위해서 작성한 비즈니스 로직의 내용이다.
Spring Security 인증에 대해서는 자세히 다룬 포스팅이 있으니 만약 상세한 설명이 필요하다면 아래의 포스팅을 참조하도록 하자.
https://sjh9708.tistory.com/84
해당 포스팅에서 적용된 로그인 인증관련 구조를 요약해 보자면 다음과 같다.
1. WebSecurity (extends WebSecutiryConfigurerAdapter)
- configure(HttpSecurity http) : AuthenticationFilter를 Spring Security Context에 등록하여 사용하게 한다.
- configure(AuthenticationManagerBuilder auth) :
인증 방식에 대해서 설정을 하며, 이 때 회원가입 시 사용했던 비밀번호 암호화 방식PasswordEncoder의 종류에 대해 설정하여 비교할 수 있도록 한다.
2. AuthenticationFilter (extends UsernamePasswordAuthenticationFilter)
- attemptAuthentication : 로그인을 시도할 때 가장 먼저 실행된다. 인증 과정에 대한 로직을 작성한다.
- successfulAuthentication :인증 성공 시의 Action을 작성한다.
3. UserService (extends UserDetailsService)
- loadByUsername : attemptAuthentication에서 authenticate()가 호출 될 때 실행되는 메서드이다. 여기서 데이터베이스의 사용자의 존재 여부를 확인하고 그에 대한 정보를 가져온다.
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class WebSecurity extends WebSecurityConfigurerAdapter {
private final Environment env;
private final UserService userService;
private final BCryptPasswordEncoder passwordEncoder;
//권한 Config
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
// http.authorizeRequests().antMatchers("/users/**").permitAll();
http.authorizeRequests().antMatchers("/**")
.hasIpAddress("172.30.1.29") //허용할 IP
.and()
.addFilter(getAuthenticationFilter()); //나머지에 대해서는 인증필터 처리
http.headers().frameOptions().disable(); //H2 Console처럼 HTML Frame 문제 해결
}
private AuthenticationFilter getAuthenticationFilter() throws Exception {
//1. 커스텀 필터 적용
AuthenticationFilter authenticationFilter = new AuthenticationFilter(userService, env);
//2. Spring Security의 Manager(configure에 설정된 내용)를 필터에 등록.
authenticationFilter.setAuthenticationManager(authenticationManager());
return authenticationFilter;
}
//인증로직 Config
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//userDetailsService을 통해서 loadByUserName을 통해 데이터베이스에서 존재여부를 따진 후 패스워드 인증
auth.userDetailsService(userService).passwordEncoder(passwordEncoder);
}
}
@Slf4j
@AllArgsConstructor
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final UserService userService;
private final Environment env;
// 로그인을 시도할 때 가장 먼저 실행되는 함수
// 로그인에 대해서 Request를 비교하여 인증 처리
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
try{
//1. Request의 값을 Object로 변경한다.
RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);
//3. authenticate를 통해 토큰에 대한 인증을 처리한 후 성공,실패 여부를 반환한다.
//WebSecurity.configure에서 설정된 대로 loadByUserName를 통해 DB에 있는 유저를 탐색 후 패스워드를 비교하게 됨
return getAuthenticationManager().authenticate(
//2. 인증 토큰 형태로 변경. ArrayList는 권한(Role) 목록
new UsernamePasswordAuthenticationToken(creds.getEmail(), creds.getPassword(), new ArrayList<>())
);
}
catch(IOException e){
throw new RuntimeException(e);
}
}
// 인증 성공 시의 Action
// 여기서 JWT Token을 반환하는 로직
@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());
log.debug(username);
}
}
@Configuration
public class PasswordEncoderConfig {
//PasswordEncoder Bean
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
/**
* 인증을 위한 상속받은 UserDetailService의 loadByUserName 구현
* 사용자가 존재하는지 아닌지 판단한다.
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = this.userRepository.findByEmail(username);
if(userEntity == null){
throw new UsernameNotFoundException(username);
}
return new User(userEntity.getEmail(), userEntity.getEncryptedPwd(),
true, true, true, true,
new ArrayList<>());
//Spring Security의 Object로 반환한다.
//ArrayList는 권한 리스트(Role)을 반환
}
@Override
public UserDto createUser(UserDto userDto) {
userDto.setUserId(UUID.randomUUID().toString());
ModelMapper mapper = new ModelMapper();
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
UserEntity userEntity = mapper.map(userDto, UserEntity.class);
userEntity.setEncryptedPwd(passwordEncoder.encode(userDto.getPwd()));
userRepository.save(userEntity);
UserDto returnUserDto = mapper.map(userEntity, UserDto.class);
return returnUserDto;
}
@Override
public UserDto getUserByUserId(String userId) {
UserEntity userEntity = userRepository.findByUserId(userId);
if(userEntity == null){
throw new UsernameNotFoundException("User Not Found");
}
UserDto dto = new ModelMapper().map(userEntity, UserDto.class);
List<ResponseOrder> orders = new ArrayList<>();
dto.setOrders(orders);
return dto;
}
@Override
public Iterable<UserEntity> getUserByAll() {
return userRepository.findAll();
}
@Override
public UserDto getUserDetailsByEmail(String email) {
UserEntity entity = this.userRepository.findByEmail(email);
if(entity == null){
throw new UsernameNotFoundException(email);
}
UserDto userDto = new ModelMapper().map(entity, UserDto.class);
return userDto;
}
}
Microservice에서의 인가
MSA 구조에서 Microservices들 중 하나인 User-Service에서 로그인을 처리하고 JWT를 제공하는 인증을 담당한다고 생각해보자. 그렇다면 다른 Microservices들에서도 해당 JWT를 가지고 인가 절차를 밟아야 한다.
물론 각각의 Microservice들에 JWT를 검증하는 로직을 추가하면 해결되겠지만, 이는 생산성 측면에서 적합하지 않다.
하지만 우리는 Microservice에 진입하기 이전 프록시 역할을 하는 단일 진입점인 API Gateway를 만들어두었다.
특히 Spring Cloud Gateway는 유동적인 필터 기능을 제공하는 것이 큰 장점이다.
따라서 게이트웨이에서 이에 대한 검증을 한 이후에 마이크로서비스에 라우팅을 해주는 필터를 추가한 후, 인가 통과 시, 서비스들에 라우팅해주면 될 것이다.
ApiGatewayService
ApiGatewayService 프로젝트에서 다음처럼 여러개의 필터를 만들 수 있다.
그 중 우리는 AuthorizationHeaderFilter를 생성하여, JWT를 검증하는 역할을 하도록 게이트웨이에서 사용자의 Request를 필터링 할 것이다.
▶ AuthorizationHeaderFilter.java
package com.example.apigatewayservice.filter;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHeaders;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@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 Authrization Header", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
//JWT 토큰 검증
if(!isJwtValid(jwt)){
return onError(exchange, "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 : Spring MVC -> Spring WebFlux에서 사용하는 비동기식 데이터 처리타입(Mono:단일, Flux:복수)
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
}
- AuthorizationHeaderFilter : AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> 상속받는다. 이는 Spring Cloud Gateway에서 필터를 정의하는 데 사용되는 추상 클래스이다.
- Config : 필요에 따라 구성 옵션을 추가할 수 있다. 설정할 옵션이 없으면 비워 놓으면 된다.
- apply :
- apply 메서드는 필터의 주된 로직을 정의한다.
- ServerHttpRequest를 이용하여 클라이언트의 요청을 확인하고, 요청 헤더에 Authorization이 있는지 확인한다.
- JWT를 추출하고, isJwtValid 메서드를 통해 토큰의 유효성을 검사한다.
- 토큰이 유효하지 않으면 onError 메서드를 호출하여 에러 응답을 반환한다.
- 토큰이 유효하면 chain.filter(exchange)를 호출하여 계속 다음 필터 또는 서비스로 요청을 전달한다.
- isJwtValid : 주어진 JWT를 검증하는 메서드
- onError : 에러 응답을 생성한다.
▶ application.yml
server:
port: 8000
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
token:
expiration_time: 86400000 #1일
secret: mySecretSecc123
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
# User-service : 로그인
- id : user-service
uri : lb://USER-SERVICE
predicates:
- Path=/user-service/login
- Method=POST
filters:
- RemoveRequestHeader=Cookie
# user-service를 빼고 뒤쪽의 URL 패턴으로 재정의
# /user-service/(?<segment>.*) -> /$\{segment}
# == /user-service/users -> /users
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
# User-service : 회원 가입
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/users
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
# User-service : Etc
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
# Catalog-service : Etc
- id: catalog-service
uri: lb://CATALOG-SERVICE
predicates:
- Path=/catalog-service/**
filters:
- CustomFilter
- AuthorizationHeaderFilter
# Order-service : Etc
- id: order-service
uri: lb://ORDER-SERVICE
predicates:
- Path=/order-service/**
filters:
- CustomFilter
- AuthorizationHeaderFilter
이제 ApiGatewayService의 yml파일을 작성하여, 라우팅 규칙들에 Authorization 필터를 추가해주면 된다.
우선 이전에 ApiGatewayService를 Eureka Naming Server에 등록시켜 주었었으며, Token에 대한 Secret 정보가 함께 있는 것을 볼 수 있다.
필터를 추가할 곳은 spring.cloud.gateway.routes이다.
해당 부분에 라우팅 패턴을 정의해두었으며, filters에 로그인이 되어야만 사용할 수 있도록 처리할 라우팅에 대해서 filters에 AuthorizationFilter를 추가해주면 된다.
해당 과정까지 마쳤다면, Eureka Server와 Api Gateway 및 UserService를 포함한 Microservice들을 실행시켜 보자.
Gateway를 통해 요청을 할 때 로그인 인증이 필요한 경로일 경우, 게이트웨이를 통해 1차 검문을 당하게 될 것이고,
Header에 JWT가 포함되어 있지 않거나 유효하지 않다면 Unauthorization을 응답으로 반환하게 될 것이다.
함께 보기
JWT 인증 방식
https://sjh9708.tistory.com/46
Spring security : 로그인 처리
https://sjh9708.tistory.com/83
Spring security : 인증 처리
https://sjh9708.tistory.com/84
Spring security : 인가 처리
https://sjh9708.tistory.com/85