기존 인증 방식의 단점

기존의 인증이 성공된 사용자 정보를 관리하는 방법은 두 가지가 있었는데 바로 쿠키와 세션이다. 각 인증 방법의 단점은 다음과 같다.

쿠키

  • 클라이언트에서 정보 확인이 가능하기 때문에 보안에 취약하다.
  • 브라우저별 쿠키에 대한 지원 형태 문제로 브라우저 간 공유가 불가능하다.
  • 쿠키의 사이즈가 커질수록 네트워크의 부하가 가중된다.

세션

  • 사용자의 세션 정보를 서버 메모리에 저장해야 하기 때문에 서버 자체가 무상태가 아니게 되어 수평적 확장이 불리해진다.
  • 세션에서 정보를 조회할 때 Scale out / up이 요구되어 Scale out 시 로드 밸런싱 등의 성능을 신경써야 한다.
  • 세션의 값을 사용해 다른 클라이언트로 위장이 가능하다.
  • 매번 요청 시 세션 저장소를 조회해야 한다.

토큰 인증 방식

따라서 세션과 쿠키를 이용하여 인증 정보를 관리하는 건 적절하지 못한 설계이다. 때문에 최근에는 클라이언트에 서버에 사용자 요청을 보냈을 때 유일 값인 토큰을 발급하여 인증이 필요한 경우 클라이언트에서 요청을 보낼 때 요청 헤더에 토큰을 삽입하여 보낸다. 물론 이 토큰 인증 방식의 단점도 존재한다는 것을 유념해야 한다.

단점

  • 토큰 자체의 데이터 길이가 길어 인증 요청이 많은 서비스면 네트워크 부하가 심해질 수 있다.
  • 클라이언트에서 보내는 메시지 바디인 페이로드 자체는 암호화되지 않아 중요 정보를 저장할 수 없다.
  • 네트워크 전송 방식이기 때문에 토큰을 탈취 당할 수 있다.

탈취에 대한 부분은 JWT에서 자체 만료시간을 두어 토큰이 탈취당하면 파싱 과정에서 유효하지 않은 토큰임을 판별할 수 있게 했다.

JWT는 무엇일까?

JWT는 Json Web Token의 약자이다. 인증에 필요한 정보를 암호화 시킨 JSON 토큰으로 Base64 URL-Safe-Encode를 통해 인코딩하여 직렬화한다. 또한 토큰 내부에 위/변주 방지를 위한 개인키를 통한 전자 서명을 포함한다.

JWT 토큰 구조

토큰 구조를 살펴보자.

구성 요소 설명
헤더 JWT에서 사용할 토큰의 타입과 암호화 알고리즘 정보로 구성하여 키-값의 형태로 구성한다.
페이로드 서버로 보낼 사용자 권한 정보와 데이터로 구성한다. (키-값) 토큰에 담을 클레임 정보를 포함하는데, 이때 클레임이란 페이로드에 담는 정보의 한 조각(키-값)이다. 여러 개의 클레임 정보를 담을 수 있지만 암호화가 되어 있지 않기 때문에 민감 정보를 다루는 건 조심해야 한다.
서명 서버의 개인 키를 포함하여 암호화하여 토큰의 유효성을 검증하기 위한 문자열을 담는다.

일반적으로 JWT를 사용할 때에는 엑세스 토큰과 리프래시 토큰을 두 개 발급한다. 여기서는 엑세스 토큰의 페이로드에 사용자 번호를 담을 것이고, 리프래시 토큰은 엑세스 토큰이 만료되었을 때 클라이언트에서 재발급 받는 용도로 사용할 것이다. 클라이언트에서는 서버로부터 메시지 바디를 통해 응답을 받아서 엑세스 토큰을 로컬 또는 세션 스토리지에 저장하고, 리프레시 토큰은 서버에서 응답을 할 때 쿠키로 전달한다.

OAuth + JWT - 1

OAuth 인증이 성공하면, 이제 사용자 정보가 아닌 JWT를 반환하도록 할 것이다.

사전 설정

의존성 설정

// JJWT

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.6'

JWT를 다루기 위해서 라이브러리 설정을 해야 한다. JWT 관련 라이브러리 중에 jjwt가 범용성이 좋다고 하여 선택하였다. 라이브러리를 찾던 중 spring-jwt를 발견했는데, 이것도 나중에 다룰 수 있으면 다루겠다.

서명 및 토큰 발급 만료 시간 설정

우선 JWT 공식 홈페이지로 가서 서명을 발급받아야 한다.

jwt.io

여기서 서명 부분을 사용하면 된다. 이 값을 설정 파일에 다음과 같이 설정할 것이다.

# application-auth.yml

jwt:
  secret: nENNz0tGIfhz2ehg4XBoe30K4nLg2vPs8JQjfKde_m8
  refresh-token-expiration-time: 604800000
  access-token-expiration-time: 3600000

JWT 발급 클래스 설계

이제 JWT를 발급하고 파싱하는 클래스를 설계해야 한다. 하나하나 살펴보자.

설정 파일에서 값 불러오기

@Component
public class JwtUtil {
    // 서명 설정을 위한 키
    private final SecretKey secretKey;
    // 엑세스 토큰 만료 시간
    private final long accessTokenExpirationTIme;
    // 리프레시 토큰 만료 시간
    private final long refreshTokenExpirationTIme;
    // 레디스 대용
    private final TempStore tempStore;

    public JwtUtil(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.access-token-expiration-time}") long accessTokenExpirationTIme,
            @Value("${jwt.refresh-token-expiration-time}") long refreshTokenExpirationTime,
            TempStore tempStore
    ) {
        this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpirationTIme = accessTokenExpirationTIme;
        this.refreshTokenExpirationTIme = refreshTokenExpirationTime;
        this.tempStore = tempStore;
    }
}

참고로 이 유틸 클래스를 사용하는 곳은 모두 스프링 빈으로 등록이 되어 있기 때문에 굳이 정적 메소드로 사용할 이유가 없다고 생각하여서 @Component로 의존성 관리를 하였다.

그리고 TempStore는 레디스에 리프레시 토큰을 저장하기 전에 사용할 임시 저장소다. 원래 간편하게 세션으로 관리하려고 했는데, 토큰 발급 과정에서 JSESSION이 한 번 초기화되어 제대로 조회가 되지 않아 Map 자료구조를 이용해서 임시 저장소를 만들었다.

@Component
public class TempStore {
    private static final Map<String, Object> userStore = new HashMap<>();

    public void store(String token, Object userId) {
        userStore.put(token, userId);
    }

    public Object get(String token) {
        return userStore.get(token);
    }

    public void remove(String token) {
        userStore.remove(token);
    }
}

토큰 발급하기

Jwts가 빌더를 잘 만들어놔서 어떤 것을 사용해야 토큰을 발급할 수 있는지만 참고하면 발급 자체는 쉽다.

public UserToken generateToken(String id) {
    String accessToken = createToken(id, accessTokenExpirationTIme);
    String refreshToken = createToken("REFRESH_TOKEN", refreshTokenExpirationTIme);
    tempStore.store(refreshToken, id);
    return new UserToken(refreshToken, accessToken);
}

private String createToken(String id, long expirationTime) {
    // 토큰 발행 시간
    Date issuedData = new Date();
    // 토큰 만료 시간
    Date expirationData = new Date(issuedData.getTime() + expirationTime);
    // 주제(사용자 정보), 발급 시간, 만료 시간, 서명
    return Jwts.builder()
            .claim("id", id)
            .issuedAt(issuedData)
            .expiration(expirationData)
            .signWith(secretKey, Jwts.SIG.HS256)
            .compact();
}
public record UserToken(
        String refreshToken,
        String accessToken
) {
}

claim()으로 토큰의 페이로드에 id 값을 받았다. 이때, JWT는 토큰의 페이로드에 담긴 값의 변수 타입을 기억한다고 한다. 따라서 사용자 정보를 가져오고자 엑세스 토큰을 파싱할 때 적절한 타입 변환이 필요하다. 단순하게 토큰의 값만 페이로드에 저장하고 싶으면 subject()라는 메소드도 있다. 그리고 발급받은 리프레시 토큰과 id 값을 리프레시 토큰 - 사용자 번호 구조로 저장하였다.

토큰 검증하기

토큰을 검증하는 것 또한 Jwts가 다 해준다.

private Jws<Claims> parseToken(String token) {
    try {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token);
    } catch (UnsupportedJwtException | IllegalArgumentException e) {
        throw new InvalidJwtException(NOT_SUPPORTED_TOKEN_FORMAT);
    }
}

// 파싱 도중 예외가 발생하면 만료된 토큰으로 취급
private void validateAccessToken(String accessToken) {
    try {
        parseToken(accessToken);
    } catch (JwtException e) {
        throw new TokenExpiredException(ACCESS_TOKEN_EXPIRED);
    }
}

private void validateRefreshToken(String refreshToken) {
    try {
        parseToken(refreshToken);
    } catch (JwtException e) {
        throw new TokenExpiredException(REFRESH_TOKEN_EXPIRED);
    }
}

private void validateTokens(String accessToken, String refreshToken) {
    validateAccessToken(accessToken);
    validateRefreshToken(refreshToken);
}

Jwtsparser()는 파싱 도중 발생하는 다양한 JWT 관련 예외를 던져준다. 심지어 토큰 만료 시간 마저도 감지한다!! 따라서 이 예외를 감지하여 적절한 서버 예외 처리를 해주면 된다. parseToken()에서 반환 타입은 Jws<Claims>이다. 이거는 JWT의 해석의 결과를 담고 있는 객체라고 생각하면 된다. 따라서 이 객체 인스턴스에 접근하면 헤더, 페이로드, 시그니처를 가져올 수 있다.

사용자 번호 가져오기

엑세스 토큰으로부터 사용자 번호를 가져오기 전에, 클라이언트 입장에서 엑세스 토큰을 헤더에 보낼 때 다음 형식으로 보낸다.

Authorization: "Bearer access-token"

여기서 Bearer 키워드는 토큰의 타입을 말한다. JWT나 OAuth에 대한 토큰을 나타내는 것에 사용한다. (RFC 6750)
그렇기 때문에 이 키워드를 파싱해야 한다.

private String bearerParse(String accessToken) {
    return accessToken.replace("Bearer ", "");
}

이제 엑세스 토큰을 이용해 사용자 번호를 가져와보자.

public Long getUserId(String accessToken, String refreshToken) {
    String parsedAccessToken = bearerParse(accessToken);
    validateTokens(parsedAccessToken, refreshToken);

    if (tempStore.get(refreshToken) == null) {
        throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
    }

    return Long.parseLong(parseToken(parsedAccessToken).getPayload().get("id", String.class));
}

토큰을 발급받을 때 사용자 번호를 페이로드에 저장했었다. 따라서 파싱 후 페이로드에 접근해서 사용자 아이디를 가져오면 된다. 또한 저장소에서 리프레시 토큰을 키 값으로 한 결과가 null이라면 토큰이 탈취되었다고 가정하여 예외를 반환하도록 하였다.

토큰 재발급 받기

토큰을 재발급 받을 때는 별도의 저장소에서 저장한 사용자 번호 값을 가져와서 재발급 받으면 된다.

public String regenerateAccessToken(String refreshToken) {
    validateRefreshToken(refreshToken);
    String userId = String.valueOf(tempStore.get(refreshToken));
    if (userId == null) {
        throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
    }
    return createToken(userId, accessTokenExpirationTIme);
}

저장소에서 토큰 제거

사용자가 로그아웃을 할 때 임시 저장소에서 토큰을 제거해야 한다.

public void cacheOutRefreshToken(String refreshToken) {
    tempStore.remove(refreshToken);
}

JwtUtil 코드 참고

참고 자료

JWT 사용법 + JWT 코드 공유

서버) JWT 사용할때 Header에 Bearer을 적는 이유

카테고리:

업데이트:

댓글남기기