[헤이동동 #06] Spring Security + JWT 사용자 인증 구현

Jiwoo Kim·2020년 11월 16일
6
post-thumbnail

☕헤이동동 : 생협 음료 원격 주문 서비스

이번 포스팅에서는 Spring Security와 JWT를 사용한 헤이동동의 사용자 인증 구현 방법에 대해 설명한다.


JWT

헤이동동 유저의 ROLE은 CUSTOMERADMIN 두 가지이다.

ROLE에 따라 사용자 인증을 달리하며, Session과 비슷하게 stateless한 HTTP의 단점을 보완하여 로그인 상태를 유지할 수 있는 기술을 찾다가 JWT를 알게 되었다. 처음 듣는 기술이었지만, 포스팅들을 보며 나름대로 삽질과 구현을 해서 서비스에 적용해보았다.

원래는 Access Token만 구현하고 대충 넘어가려고 했는데, 기왕 시작한 김에 확실히 익혀보자는 마음으로 꼼꼼히 다시 정리하고 Refresh Token까지 구현하였다.

Access Token

하나의 Access Token만 사용하면 크게 두 가지 문제점이 생길 수 있다.

  1. 유효기간이 긴 경우, 한 번 탈취당한 토큰은 해커가 긴 시간동안 악용할 수 있어 위험하다.
  2. 유효기간이 짧은 경우, 토큰이 만료될 때마다 사용자는 다시 로그인을 해야 하기 때문에 UX에 치명적이다.

이러한 문제점을 해결하기 위해, 유효기간이 짧은 Access Token과 유효기간이 긴 Refresh 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주 정도로 잡는다고 한다.


구현

로그인 시 token 발행

Access token과 Refresh token을 각각 발행하여 response에 담아 보내고, Refresh token만 DB에 저장한다.

  • Access token: userId, userRoles, 발행일자, 유효기간을 담아 암호화
  • 암호화 알고리즘은 가장 기본인 HS256, 암호키는 Base64 인코더를 사용했다. 보안이 그렇게 뛰어난 것은 아니라는데, 이것보다는 로직을 이해하는 것이 최우선 목표였기 때문에 일단은 가장 기본적인 것들을 적용했다.
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();
}
  • Refresh token: random key value (나는 UUID 랜덤 스트링을 약간 변형했다), 발행일자, 유효기간을 담아 암호화
  • DB에 저장하는 것은 token 전체가 아니고 random key value다.
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();
}

기간 만료 시 token 재발행

Refresh token을 request payload로 받아 validate하고, Refresh token과 Access Token을 재발행, 저장, 반환한다.

  • user를 DB에서 조회하여 저장된 Refresh token value를 불러오고, payload에 담겨온 Refresh token과 비교한다.
  • 만약 클라이언트가 넘긴 Refresh 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]})
    );
}

JwtAuthenticationFilter

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);
    }
}

JwtTokenProvider

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)


전체 코드는 Github에서 확인하실 수 있습니다.

1개의 댓글

comment-user-thumbnail
2021년 7월 16일

userid가 String이 아닌 Long값이나 int값이면 어떻게 해야 하나요?!

답글 달기