기본 단일 인증 서버에 작성했던 filter와 parser들이 역할 분배가 불분명하고 서로 맞물려서 중복되는 등 더러워서 참고만 하고 새로 깔끔하게 작성해보기로 했다.
먼저 파싱과 검사를 위한 util 클래스를 만들 것이다.
jsonwebtoken을 사용하기 위해 먼저 pom.xml에 디펜던시를 추가한다.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
JWT payload에 나중에 토큰 파싱만으로 기능 서버에 연결해줄 수 있는 최소한의 정보만을 담으려면 어떤 것을 담아야 할까. 먼저 서버에서 인증할 때도 필요하고 파싱만으로도 클라이언트에 유저 정보를 알려줄 때도 필요한 username이 필요하다. 그 다음으로 gateway 단에서 파싱만으로 role을 확인해 기능 서버 각각에 적절한 role이 있는 지 확인할 수 있도록 권한까지 payload에 넣을 것이다.
Gateway에서 검사할 때는 User DB에 전혀 접근하지 않도록 parser만을 이용할 것이다. 간단하게 jwt string을 받아 payload에서 유저이름, 역할, 만료여부를 직접 꺼내 쓸 수 있는 parser와 이 parser를 이용해 validation해주는 메소드를 작성했다. Exception은 주로 만료됐거나 signature 문제일 것 같아서 둘만 따로 처리했다.
package com.quadcore.gateway.jwt;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.util.*;
@Component
public class JwtValidator implements Serializable {
private static final long serialVersionUID = -2550185165626007488L;
@Value("${jwt.secret}")
private String secret;
private static final Logger logger = LoggerFactory.getLogger(JwtValidator.class);
public Map<String, Object> getUserParseInfo(String token) {
Claims parseInfo = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
Map<String, Object> result = new HashMap<>();
//expiration date < now
boolean isExpired = !parseInfo.getExpiration().before(new Date());
result.put("username", parseInfo.getSubject());
result.put("role", parseInfo.get("role", List.class));
result.put("isExpired", isExpired);
return result;
}
private boolean isValidate(String token) {
try {
Map<String, Object> info = getUserParseInfo(token);
}
// token is expired
catch (ExpiredJwtException e) {
logger.warn("The token is expired.");
return false;
}
// signature is wrong
catch (SignatureException e) {
logger.warn("Signature of the token is wrong.");
return false;
}
// format is wrong
catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
logger.warn("The token string is wrong format.");
return false;
}
return true;
}
}
그런데 Spring Security에서 인증을 받고 넘어가려면
SecurityContextHolder.getContext().setAuthentication(authencation객체)
식으로 받아야 하고, 이 authentication 객체는 보통 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, authorities)
형태로 만들어진다는 것인데... 여기서 userDetails를 만드려면
UserDetails userDetails = User.builder().username(String.valueOf(parseInfo.get("username"))).authorities(rolesCollection).password("dummy").build();
이런식으로 만들게 된다. 문제는 UserDetails를 이렇게 마음대로 build하려면 username, password, authorities 세 개 모두가 필수로 필요하다는 것이다. 나는 한 번 발급한 JWT에 대해 매번 DB에 접근해서 password를 받기 싫은데. 그래서 이전에는 userDetails의 비밀번호를 더미로 주고 만들었었다. 이번에 뭔가 해결책을 얻고 싶어서 스택오버플로우에 일단 질문을 올렸다. 해답을 얻기 전까지는 더미 비밀번호를 준 상태로 진행할 것 같다.
가장 먼저 Access Token과 Refresh Token을 인식하기 위해 Header를 검사해야 한다. Gateway단의 필터이므로 요청을 보내야 할 지 말 지를 철저히 따진 후에 인증 서버에는 더 이상의 조건 검사가 필요 없게 JWT 발급 요청만을 보낼 것이다. 인증 서버와 Gateway의 로직 분리를 철저히 해야 깔끔한 분리가 될 것 같다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String jwtToken = null;
String requestTokenHeader = request.getHeader("Authorization");
logger.info("tokenHeader: " + requestTokenHeader);
//가지고 있는 token이 아예 없을 때
if (requestTokenHeader == null) {
//login page로 보내기
//처음부터 access token과 refresh token을 발급받도록 유도한다.
}
//Refresh token을 보냈을 때
else if ( try to refresh) {
//인증 서버로 refresh token을 보내서 인증을 받아온다.
//Refresh token이 유효하지 않거나 만료되면
if ( refresh token is not validate ) {
//login page로 보내기
//처음부터 access token과 refresh token을 발급받도록 유도한다.
}
}
//가지고 있는 token이 있을
else {
//validate 할 때
if (jwtValidator.isValidate(token)) {
//Authentication 객체를 얻어서 통과시켜줌
Authentication auth = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
//Response Header에 username을 줌
Map<String, Object> info = jwtValidator.getUserParseInfo(token);
response.setHeader("username", (String)info.get("username"));
}
//가지고 있는 token이 expired 됐을 때
else if ( expired ) {
//refresh token을 달라는 response를 보낸다.
}
//그냥 유효하지 않은 토큰일 때
else {
//login page로 보내기
//처음부터 access token과 refresh token을 발급받도록 유도한다.
}
}
chain.doFilter(request, response);
}
이 로직대로 잘 구현해봐야겠다.. 인증 서버에서도 토큰 Generation과 저장, logout 관리만 하도록 로직을 단순화시켜야겠다.