OAuth - JWT 적용해보기 - 1

Sehako·2025년 2월 18일

OAuth

목록 보기
4/6

기존 인증 방식의 단점

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

쿠키

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

세션

  • 사용자의 세션 정보를 서버 메모리에 저장해야 하기 때문에 서버 자체가 무상태가 아니게 되어 수평적 확장이 불리해진다.
  • 세션에서 정보를 조회할 때 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가 범용성이 좋다고 하여 선택하였다.

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

우선 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((byte[]) Decoders.BASE64.decode(secretKey));
        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는 JSON 포맷을 따르기 때문에 Java의 구체적인 숫자 타입(Long 등)이 완벽하게 보존되지 않고 일반적인 숫자형(Number)으로 직렬화된다. 이 때문에 엑세스 토큰을 파싱하여 id를 꺼낼 때는 라이브러리가 임의로 해석한 타입(Integer 등)과 충돌하지 않도록 명시적인 타입 변환이 필요하다.

만약 이러한 타입 이슈 없이 단순히 식별자 값만 저장하고 싶다면, 모든 값을 문자열로 취급하는 표준 클레임인 subject() 메서드를 활용하는 방법도 있다. 한편, 발급받은 리프레시 토큰은 검증을 위해 리프레시 토큰 - 사용자 번호 구조로 매핑하여 저장소에 저장하였다.

토큰 검증하기

토큰을 검증하는 것 또한 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을 적는 이유

profile
Java, Kotlin, Spring을 이용한 백엔드 개발자 지망생

0개의 댓글