
해당 포스팅은 인프런에 "Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)" 강의를 기반으로 작성됐습니다.
포스팅의 모든 사진 자료는 해당 강의 출처임을 밝힙니다.
지난 포스팅에서는 Catalog & Order Microservice 구현해봤습니다.
이번 강의에서는 User Microservice 에서도 인증과 인가에 관련된 기능을 구현하려 합니다.
📖 학습목표
- AuthenticationFilter에 대한 이해와 구현
- Routes 정보 변경
- 로그인 처리 과정에 대한 이해
- JWT 생성 원리 이해 및 처리 과정 구현

@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 = "Email cannot be null")
@Size(min=8, message="Password must be equals or grater than 8 characters")
private String password;
}
사용자 로그인 정보를 저장하기 위한 VO 클래스입니다.
Sprign Security를 이용한 로그인 요청 발생 시 제일 먼저 호출되어 인증을 처리 해주는 Custom Filter 클래스 입니다.
UsernamePasswordAuthenticationFilter를 상속 하여, 인증 처리 과정에서 관련 로직을 처리하도록 메서드를 재정의 해줍니다.
attemptAuthentication(), successfulAuthentication() 함수들을 재정의해줍니다.
요청 데이터를 UsernamePasswordAuthenticationToken의 형태로 변환해주는 기능을 합니다.
인증 성공 후 로직을 정의하는 메서드입니다. 추후 JWT 생성로직을 구현 예정입니다.
AuthenticationFilter를 거치도록 filter-chain 에 추가해줍니다.


기존의 UserService를 활용하여 UserDetailsService를 구현하도록 합니다.
public interface UserService extends UserDetailsService {
UserDto createUser(UserDto userDto);
UserDto getUserByUserId(String userId);
Iterable<UserEntity> getUserByAll();
UserDto getUserDetailsByEmail(String email);
}
// 인증 요청 시 요청 회원의 이메일 및 비밀번호를 체크해주는 함수
@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.getEncryptedPw(),
true,true,true,true,
new ArrayList<>());
}
User Service에 대한 Routes 정보를 수정해줍니다.

filters를 통해서 ReWritePath 설정 및 RemoveRequestHeader 관련 설정을 추가해줍니다.
:
관련 설정은 매번 POST 요청을 새로운 요청으로 받도록 하기 위해서 요청 헤더를 초기화 시켜주기 위한 설정입니다.
:
요청으로 패턴1의 url 요청이 오게되면, 게이트웨이 서비스에서 서버로 요청할 때, 패턴2의 형태로 요청 url 형태를 변경해주는 설정입니다.
라우팅 설정에서 predicates의 path에 전체 경로를 명시하면, 해당 경로 하나에만 라우팅을 걸어주는 설정입니다.
이를 통해서 각각의 라우트마다 API Gateway 필터를 다르게 설정하는 것이 가능합니다.
ReWritePath 설정을 통해서 게이트웨이에서 서버로 요청 시, 클라이언트에서 보낸 요청 내 naming을 빼고 보내주도록 설정하는 것이 가능합니다.

따라서 위처럼 UserController에 prefix로 설정했던 user-service를 지워도 정상적으로 매핑됩니다.
attemptAuthentication, successfulAuthentication, loadUserByUsername 과 같이 인증 처리 과정에서 어떤 메서드들이 순서대로 호출되는지 디버거를 통해서 확인이 가능합니다.
각각의 메서드에 breakpoint를 지정한 뒤 디버깅 모드로 실행해줍니다.
# AuthenticationFilter # attemptAuthentication

위에서 언급한대로 인증 요청 시 처음 실행되는 클래스는 AuthenticationFilter 클래스입니다.
# UserServiceImpl # loadUserByUsername

디버거 모드를 통해서 변수의 상태값을 함께 확인이 가능합니다.

또한, 다음과 같이 디버거에 관련 사항에 대해 물어볼 수 있습니다.

username이 null인지 여부를 물어보니 result = false 가 나오게 됩니다.
#AuthenticationFilter #successfulAuthentication

실패

성공


위 로직은 기존의 로그인 방식에서 인증에 성공하게 되면, JWT 토큰을 생성 및 발급하여 HTTP 응답 헤더에 추가하는 방식으로 구현하려고 합니다.

위 그림은 기존에 session과 cookie를 사용하여 인증/인가 방식을 구현한 것입니다.
애플리케이션 개발 시 웹 환경에서 제공하는 sessionID를 모바일 환경에서 유효하게 사용할 수가 없습니다.(공유 불가)
또한, MSA 아키텍처의 웹앱에서는 부하 분산을 위해 하나의 서비스에 여러대의 인스턴스가 존재하는 경우가 많습니다.
인증/인가 서버의 경우, 인스턴스가 여러대로 나눠지게 되면 세션 정보가 서버마다 달라지게 되어 한 회원이 인증된 세션 정보가 다른 서버에서는 사용할 수 없게되는 문제가 발생하게 됩니다.
이러한 이유에서 다양한 User Interface를 제공하는 애플리케이션에서는 주로 JWT를 통한 인증/인가 로직을 구현하게 됩니다.

Jwt 관련된 자세한 설명은 다음의 블로그 포스팅에서 설명하니, 궁금하신 분들께서는 참고 바랍니다.
💡 JWT 토큰이란?
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.debug(((User)authResult.getPrincipal()).getUsername());
String userName = (((User) authResult.getPrincipal()).getPassword());
UserDto userDetails = userService.getUserDetailsByEmail(userName);
}
JWT 생성 시 사용자의 user-id 값을 담아서 생성하기 위해 회원 이메일 정보로 회원을 조회하도록 구현하였습니다.

UserService에 이메일 정보로 회원을 조회하는 기능을 추가해줍니다.
@Override
public UserDto getUserDetailsByEmail(String email) {
UserEntity userEntity = userRepository.findByEmail(email);
if(userEntity == null)
throw new UsernameNotFoundException("email not found : "+email);
UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);
return userDto;
}
위처럼 UserServiceImpl에서 구현하여 회원정보를 Dto로 변환하여 AuthenticationFilter로 전달하도록 구현습니다.
@Configuration
@EnableWebSecurity
public class WebSecurity{
private UserService userService;
private BCryptPasswordEncoder passwordEncoder;
private Environment env;
public WebSecurity(UserService userService,
BCryptPasswordEncoder passwordEncoder,
Environment env) {
this.userService = userService;
this.passwordEncoder = passwordEncoder;
this.env = env;
}
...
private AuthenticationFilter getAuthenticationFilter(AuthenticationManager authenticationManager) throws Exception{
return new AuthenticationFilter(authenticationManager, userService, env);
}
}
위처럼 WebSecurity 클래스에서 UserService 의존을 추가하여 구현하도록 수정해줍니다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken' , name: 'jjwt-gson', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
log.debug(((User)authResult.getPrincipal()).getUsername());
String userName = (((User) authResult.getPrincipal()).getUsername());
UserDto userDetails = userService.getUserDetailsByEmail(userName);
SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(env.getProperty("token.secret")));
String token = Jwts.builder()
.setSubject(userDetails.getUserId())
.setExpiration(new Date(System.currentTimeMillis() +
Long.parseLong(Objects.requireNonNull(env.getProperty("token.expiration_time")))))
.signWith(secretKey,SignatureAlgorithm.HS512)
.compact();
response.addHeader("token",token);
response.addHeader("userId",userDetails.getUserId());
}
token 생성 후에 userId도 함께 헤더에 담아주는 것은 token 내 userId 값과 실제 userId 값이 같은지 여부를 확인하기 위함입니다.
postman을 사용하여 jwt 토큰 발급 여부를 확인해보겠습니다.
다음과 같이 token이 생성된 것을 확인할 수 있습니다.


API Gateway는 MVC 형태가 아닌 WebFlux라는 비동기 처리 환경입니다.
이를 위해서 사용하는 개념이 Mono와 Flux라는 개념이 있습니다.
Spring WebFlux 라는 개념이 있습니다.
API Gateway에서 요청에 대한 인증 및 인가를 처리하기 위해 Authorization 헤더에 담긴 jwt 토큰을 parsing 해야하기 때문에 JWT 관련 의존을 추가해줍니다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken' , name: 'jjwt-gson', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
@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();
// 1. 헤더에 포함된 JWT 정보 확인
if(!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)){
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
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 {
JwtParser parser = Jwts.parserBuilder().setSigningKey(env.getProperty("token.secret")).build();
subject = parser.parseClaimsJws(jwt).getBody().getSubject();
}catch(Exception ex){
returnValue = false;
}
if(subject == null || subject.isEmpty()){
returnValue = false;
}
return returnValue;
}
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
}
기존에 정의했던 CustomFilter와 동일한 형태로 AbstractGatewayFilterFactory를 구현해줍니다.
해당 필터 내부에서 요청 헤더에 Baerer Token 정보 유무 확인 및 JWT가 존재할 경우, JWTParser 객체를 활용하여 내부에 저장되어 있는 subject 정보를 확인합니다.
해당 정보가 비어있거나 조회가 되지 않을 경우 onError라는 메서드를 호출하여 UNAUTHORIZED(401) 응답 상태 코드 반환 및 에러메시지가 로그에 찍히도록 구현했습니다.
커스텀한 필터를 Router에 등록하기 위해 application.yml에 필요한 곳에 fitler를 등록해줍니다.
...
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
Users Microservice에 GET 요청 시에만 해당 필터를 거치도록 설정했습니다.
그 다음으로 postman을 통해서 테스트해보겠습니다.
회원가입 후 로그인 시 아래와 같이 정상적으로 토큰이 발급된 것을 확인할 수 있습니다.

우선, 반환받은 토큰 정보를 헤더에 넣지 않고 요청을 보내보겠습니다.


위처럼 401 응답 상태 코드가 반환 및 오류 메시지가 출력된 것을 확인할 수 있습니다.
다음으로 정상적으로 리소스를 가져오기 위해서 로그인 시 발급 받았던 Token 값을 Authorization Header에 Baerer 토큰 타입으로 넣어보겠습니다.

그 다음으로 요청해본 결과 정상적으로 응답이 오게됩니다.
