OAuth - JWT 적용해보기 - 1
기존 인증 방식의 단점
기존의 인증이 성공된 사용자 정보를 관리하는 방법은 두 가지가 있었는데 바로 쿠키와 세션이다. 각 인증 방법의 단점은 다음과 같다.
쿠키
- 클라이언트에서 정보 확인이 가능하기 때문에 보안에 취약하다.
- 브라우저별 쿠키에 대한 지원 형태 문제로 브라우저 간 공유가 불가능하다.
- 쿠키의 사이즈가 커질수록 네트워크의 부하가 가중된다.
세션
- 사용자의 세션 정보를 서버 메모리에 저장해야 하기 때문에 서버 자체가 무상태가 아니게 되어 수평적 확장이 불리해진다.
- 세션에서 정보를 조회할 때 Scale out / up이 요구되어 Scale out 시 로드 밸런싱 등의 성능을 신경써야 한다.
- 세션의 값을 사용해 다른 클라이언트로 위장이 가능하다.
- 매번 요청 시 세션 저장소를 조회해야 한다.
토큰 인증 방식
따라서 세션과 쿠키를 이용하여 인증 정보를 관리하는 건 적절하지 못한 설계이다. 때문에 최근에는 클라이언트에 서버에 사용자 요청을 보냈을 때 유일 값인 토큰을 발급하여 인증이 필요한 경우 클라이언트에서 요청을 보낼 때 요청 헤더에 토큰을 삽입하여 보낸다. 물론 이 토큰 인증 방식의 단점도 존재한다는 것을 유념해야 한다.
단점
- 토큰 자체의 데이터 길이가 길어 인증 요청이 많은 서비스면 네트워크 부하가 심해질 수 있다.
- 클라이언트에서 보내는 메시지 바디인 페이로드 자체는 암호화되지 않아 중요 정보를 저장할 수 없다.
- 네트워크 전송 방식이기 때문에 토큰을 탈취 당할 수 있다.
탈취에 대한 부분은 JWT에서 자체 만료시간을 두어 토큰이 탈취당하면 파싱 과정에서 유효하지 않은 토큰임을 판별할 수 있게 했다.
JWT는 무엇일까?
JWT는 Json Web Token의 약자이다. 인증에 필요한 정보를 암호화 시킨 JSON 토큰으로 Base64 URL-Safe-Encode를 통해 인코딩하여 직렬화한다. 또한 토큰 내부에 위/변주 방지를 위한 개인키를 통한 전자 서명을 포함한다.
토큰 구조를 살펴보자.
구성 요소 | 설명 |
---|---|
헤더 | 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 공식 홈페이지로 가서 서명을 발급받아야 한다.
여기서 서명 부분을 사용하면 된다. 이 값을 설정 파일에 다음과 같이 설정할 것이다.
# 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);
}
Jwts
의 parser()
는 파싱 도중 발생하는 다양한 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);
}
댓글남기기