이번 포스팅에서는 Spring Security와 JWT를 사용한 헤이동동의 사용자 인증 구현 방법에 대해 설명한다.
헤이동동 유저의 ROLE은 CUSTOMER
와 ADMIN
두 가지이다.
ROLE에 따라 사용자 인증을 달리하며, Session과 비슷하게 stateless한 HTTP의 단점을 보완하여 로그인 상태를 유지할 수 있는 기술을 찾다가 JWT를 알게 되었다. 처음 듣는 기술이었지만, 포스팅들을 보며 나름대로 삽질과 구현을 해서 서비스에 적용해보았다.
원래는 Access Token만 구현하고 대충 넘어가려고 했는데, 기왕 시작한 김에 확실히 익혀보자는 마음으로 꼼꼼히 다시 정리하고 Refresh Token까지 구현하였다.
하나의 Access Token만 사용하면 크게 두 가지 문제점이 생길 수 있다.
이러한 문제점을 해결하기 위해, 유효기간이 짧은 Access Token과 유효기간이 긴 Refresh Token 두 가지를 함께 사용한다.
Refresh Token은 Access Token 재발행을 위한 인증 토큰이라고 할 수 있다.
최초 로그인 시 서버는 Refresh Token과 Access Token을 모두 클라이언트에 발급해준다. 그리고 Access Token은 DB에 저장하지 않으며, Refresh Token은 DB에 저장한다. 토큰을 받은 클라이언트는 로컬 안전한 곳에 Refresh Token을 저장하고, 통신에는 Access Token을 사용한다.
Access Token 유효기간이 만료되었다는 응답을 서버로부터 받은 클라이언트는 Refresh Token을 꺼내어 같이 재전송하고, 서버는 DB에 있는 Refresh Token과 받은 Refresh Token을 대조하여 Access Token 재발행 여부를 결정한다. Refresh Token도 만료된 경우에는 재로그인을 해야 한다.
따라서 적절한 유효기간을 부여하여 성능을 최적화하고 보안을 유지하는 것이 중요해 보인다. 보통 유효기간은 Access Token 1시간, Refresh Token 2주 정도로 잡는다고 한다.
Access token과 Refresh token을 각각 발행하여 response에 담아 보내고, Refresh token만 DB에 저장한다.
public String createJwtAccessToken(String userId, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userId);
claims.put("roles", roles);
Date now = new Date();
Date expiration = new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String createJwtRefreshToken(String value) {
Claims claims = Jwts.claims();
claims.put("value", value);
Date now = new Date();
Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
Refresh token을 request payload로 받아 validate하고, Refresh token과 Access Token을 재발행, 저장, 반환한다.
Jwts.parser().parseClaimsJws(jwtToken)
메소드에서 ExpiredJwtException
을 발생시킨다.JwtException
은 커스텀해놓은 GlobalExceptionHandler
에서 처리하고, 403 Forbidden을 반환한다.public String refreshUserTokens(JsonNode payload) {
User user = findRequiredUserById(payload.get("userId").asText());
String givenRefreshToken = payload.get("refreshToken").asText();
checkIfRefreshTokenValid(user.getRefreshTokenValue(), givenRefreshToken);
String[] jwtTokens = createJwtTokens(user, user.getRoles());
return buildRefreshUserTokensJsonResponse(user.getUserId(), jwtTokens);
}
private void checkIfRefreshTokenValid(String requiredValue, String givenRefreshToken) throws JwtException {
String givenValue = String.valueOf(jwtTokenProvider.getClaimsFromJwtToken(givenRefreshToken).getBody().get("value"));
if (!givenValue.equals(requiredValue))
throw new InvalidRequestParameterException("Invalid refreshToken");
}
private String[] createJwtTokens(User user, List<String> roles) {
String accessToken = jwtTokenProvider.createJwtAccessToken(user.getUserId(), roles);
String refreshTokenValue = UUID.randomUUID().toString().replace("-", "");
saveRefreshTokenValue(user, refreshTokenValue);
String refreshToken = jwtTokenProvider.createJwtRefreshToken(refreshTokenValue);
return new String[]{accessToken, refreshToken};
}
private String buildRefreshUserTokensJsonResponse(String userId, String[] jwtTokens) {
return jsonBuilder.buildJsonWithHeaderAndPayload(
jsonBuilder.buildResponseHeader("RefreshTokensResponse", userId),
jsonBuilder.buildResponsePayloadFromText(new String[]{"accessToken", "refreshToken"}, new String[]{jwtTokens[ACCESS], jwtTokens[REFRESH]})
);
}
request의 토큰을 꺼내서 Authentication을 진행하는 기능을 한다.
OncePerRequestFilter
를 상속받은 커스텀 필터를 정의하고 doFilterInternal
메소드를 오버라이드하여 User Authentication을 구현했다.GenericFilterBean
을 상속받고 doFilter
메소드를 오버라이드 했었는데, 디버깅 용으로 로그를 찍다가 한 request에 여러 번 찍히는 것을 보고 다른 방법을 찾아 구현하게 되었다.@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveJwtToken(request);
if (token != null && jwtTokenProvider.isTokenValid(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
JwtToken 관련 모듈을 제공하는 클래스다. 앞서 설명한 메소드들도 포함되어 있고, 그 외의 메소드들은 직관적으로 이해할 수 있다고 생각해서 설명을 줄인다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private String secretKey = YOUR_SECRET_KEY;
private final long ACCESS_TOKEN_VALID_TIME = 1 * 60 * 1000L; // 1분
private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 7 * 1000L; // 1주
private final UserDetailsService userDetailsService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public String createJwtAccessToken(String userId, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userId);
claims.put("roles", roles);
Date now = new Date();
Date expiration = new Date(now.getTime() + ACCESS_TOKEN_VALID_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String createJwtRefreshToken(String value) {
Claims claims = Jwts.claims();
claims.put("value", value);
Date now = new Date();
Date expiration = new Date(now.getTime() + REFRESH_TOKEN_VALID_TIME);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public String resolveJwtToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserId(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUserId(String token) {
return getClaimsFromJwtToken(token).getBody().getSubject();
}
public boolean isTokenValid(String jwtToken) {
try {
Jws<Claims> claims = getClaimsFromJwtToken(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
public Jws<Claims> getClaimsFromJwtToken(String jwtToken) throws JwtException {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
}
}
시큐리티의 세계는 정말 넓고 어렵다. 용어 정리가 꽤 되었다고 생각하는 데도 막상 구현하자니 못 하겠어서 다른 분들의 포스팅을 많이 찾아 보았다. 당장 모든 원리를 이해하지는 못했지만, 코드 한 줄 한 줄이 무슨 일을 하고 왜 필요한 지는 알 것 같다. 싹 지우고 다 다시 적어 보라면 못 하겠지만... 헤이동동을 좀 더 발전시키거나 다른 서비스에서도 시큐리티 관련 로직 구현을 한다면 더 빠르게, 더 안전하게, 더 정확하게 구현할 수 있을 것이라 믿는다.
[Server] JWT(Json Web Token)란?
JWT에 대해 알아보자!
쉽게 알아보는 서버 인증 2편(Access Token + Refresh Token)
[서버개발캠프] Spring boot + Spring security + Refresh JWT + Redis + JPA 1편
[JAVA] jjwt library 사용방법 - JWT(Java Web Token)
userid가 String이 아닌 Long값이나 int값이면 어떻게 해야 하나요?!