package com.dasd412.api.writerservice.adapter.in.security.jwt;
@Component
public class JWTTokenProvider {
...
public String issueNewJwtAccessToken(String username, Long writerId,String requestURI, Collection<? extends GrantedAuthority> authorities) {
Claims claims = Jwts.claims().setSubject(username);
claims.put(USER_ID,writerId);
List<String> roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
claims.put(ROLE, roles);
return Jwts.builder().addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRED_TIME))
.setIssuedAt(new Date())
.signWith(SignatureAlgorithm.HS512, SECRET)
.setIssuer(requestURI)
.compact();
}
}
위 코드에서 claims.put()
을 주목하자. 이 코드를 통해서 JWT에 원하는 정보를 넣을 수 있다. 나는 컨트롤러 코드들에 작성자 id
가 파라미터가 필요했기 때문에 해당 정보를 넣어줬다.
package com.dasd412.api.writerservice.adapter.in.security.filter;
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
String accessToken = jwtTokenProvider.issueNewJwtAccessToken(principalDetails.getUsername(), principalDetails.getWriter().getId(), request.getRequestURI(), principalDetails.getAuthorities());
LocalDateTime expired = LocalDateTime.ofInstant(jwtTokenProvider.retrieveExpiredTime(accessToken).toInstant(), ZoneId.systemDefault());
//중략
Map<String, Object> responseBody = Map.of(
"accessToken", accessToken,
"expired", expired.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
);
new ObjectMapper().writeValue(response.getOutputStream(), responseBody);
}
}
그리고 인증이 성공되었을 때 발동되는 successfulAuthentication()
에서 JWT 정보를 response body에 넣어주었다.
package com.dasd412.api.gatewayserver.filter.security;
import com.dasd412.api.gatewayserver.security.JWTTokenProvider;
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final JWTTokenProvider jwtTokenProvider;
public AuthorizationHeaderFilter(JWTTokenProvider jwtTokenProvider) {
super(Config.class);
this.jwtTokenProvider = jwtTokenProvider;
}
static class Config {
}
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
HttpHeaders headers = request.getHeaders();
//프론트에서 요청을 쏠 때, Authorization header에 토큰이 담겨 있어야 한다.
if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
throw new NoAuthorizationHeaderException("authorization header not exist");
}
String authorizationHeader = headers.get(HttpHeaders.AUTHORIZATION).get(0);
String token = authorizationHeader.replace("Bearer", "");
jwtTokenProvider.validateJwtToken(token);
String subject = jwtTokenProvider.retrieveSubject(token);
String writerId = jwtTokenProvider.retrieveWriterId(token);
ServerHttpRequest requestWithHeader = request.mutate()
.header("user-name", subject)
.header("writer-id", writerId)
.build();
return chain.filter(exchange.mutate().request(requestWithHeader).build());
});
}
}
제일 먼저 인증 서비스에서 사용된 JWTTokenProvider를 게이트웨이에서도 작성해야한다.
여기서 중요한 부분은 두 가지이다. 나머지는 헤더에서 파싱하거나, 토큰에서 파싱하는 것뿐이다.
claims.put(USER_ID,writerId);
에서 넣은 정보를 토큰에서 해석해내는 코드이다.String writerId = jwtTokenProvider.retrieveWriterId(token);
ServerHttpRequest requestWithHeader = request.mutate()
.header("user-name", subject)
.header("writer-id", writerId)
.build();
return chain.filter(exchange.mutate().request(requestWithHeader).build());
spring:
cloud:
gateway:
routes:
- id: diary-service
uri: lb://diary-service
predicates:
- Path=/diary/**
filters:
- AuthorizationHeaderFilter
- RewritePath=/diary/(?<segment>.*),/$\{segment}
- id: writer-service
uri: lb://writer-service
predicates:
- Path=/writer/login
- Method=POST
filters:
- RewritePath=/writer/(?<segment>.*),/$\{segment}
- id: writer-service
uri: lb://writer-service
predicates:
- Path=/writer/signup
- Method=POST
filters:
- RewritePath=/writer/(?<segment>.*),/$\{segment}
- id: writer-service
uri: lb://writer-service
predicates:
- Path=/writer/**
filters:
- AuthorizationHeaderFilter
- RewritePath=/writer/(?<segment>.*),/$\{segment}
주목할 부분은 filters
이다. AuthorizationHeaderFilter
를 특정 경로에 매핑해주고 있는데, 해당 경로로 진입하면 매핑된 필터를 거치게 된다.
로그인과 회원가입은 당연히 JWT 검증을 할 수 없기 때문에 필터를 넣지 않았다.
2.에서 게이트웨이는 헤더에 writer-id
를 담은 후, 다른 마이크로 서비스에 전파하고 있다.
그리고 각 서비스들의 컨트롤러는 @RequestHeader
를 활용해서 해당 정보를 받아온다.
@RestController
public class DiaryDeleteRestController {
public ApiResult<?> deleteDiary(@PathVariable Long diaryId, @RequestHeader(value = "writer-id") String writerId){
...
}
}
mockMvc.perform(MockMvcRequestBuilders.post(URL)
.contentType(MediaType.APPLICATION_JSON)
.header("writer-id", "1")
.content(new ObjectMapper().writeValueAsString(postRequestDTO)))
모킹할 때 헤더 정보로 주입하면 된다.
Authroization 헤더에 Bearer (발급받은 JWT 토큰 값)
을 넣고 요청하면 된다.
게이트웨이에서 전부 검증하기 때문에 다른 마이크로 서비스들은 인증
과 관련한 코드를 집어 넣을 필요가 없어졌다.
이전 모놀리식 프로젝트에서는 각 도메인 컨트롤러마다@AuthenticationPrincipal
이라는 어노테이션을 매번 붙여야 했다.
즉, MSA와 JWT를 같이 적용함으로써 각 도메인은 자기 할일에만 더 집중할 수 있게 됬다. (응집성)
물론, 게이트웨이가 뻗으면 SPOF가 되기 때문에 게이트웨이 가용성을 높여놔야 안정적일 것이다.