인증 관련해서 쿠키나 세션만 사용해봤기에.. JWT 공부한 내용을 정리해서 올립니다.
보통 서버에서 클라이언트 인증을 확인하는 방법은 쿠키, 세션, 토큰 3가지 입니다. 토큰 방식도 단점이 있지만 쿠키와 세션의 단점이 더 크기에 토큰 방식을 선호하는듯 합니다.
JWT는 웹 애플리케이션 간에 정보를 안전하게 전달하기 위해 사용되는 Json 토큰입니다. 그리고 JWT를 이용해 서버가 클라이언트를 식별하는 방식을 JWT기반 인증 이라고 합니다. 특히 사용자 인증과 정보 교환에 주로 활용됩니다.
JWT가 어떤 종류의 토큰인지와 어떤 알고리즘을 사용하여 서명이나 암호화되었는지를 정의
실제 전송하고자 하는 데이터가 담겨 있는 부분입니다. 클레임(claim)으로 불리는 데이터 조각들이 포함되며, 이는 사용자 정보, 권한, 토큰 만료 시간 등을 포함할 수 있습니다. 클레임은 등록된(Claims registered), 공개(Public), 비공개(Private) 클레임으로 구분됩니다.
시그니처의 구조는 (헤더 + 페이로드)와 서버가 갖고 있는 유일한 key 값을 합친 것을 헤더에서 정의한 알고리즘으로 암호화합니다.
공식사이트에서 직접 JWT 토큰을 생성해 연습할 수 있습니다.
Payload 부분은 암호화가 된게 아니라 Base64로 Encoding 된 것 뿐이기 때문에 다시 Decoding 하면 정보가 그대로 노출됩니다. 그래서 페이로드에는 비밀번호와 같은 민감한 정보는 넣지 말아야 합니다.
JWT의 목적은 정보 보호가 아닌 위조 방지입니다.
제 3자에게 토큰 탈취의 위험성이 있기 때문에, 그대로 사용하는것이 아닌 Access Token, Refresh Token 으로 이중으로 나누어 인증을 하는 방식을 현업에선 취한다.
Access Token 과 Refresh Token은 둘다 똑같은 JWT이다. 다만 토큰이 어디에 저장되고 관리되느냐에 따른 사용 차이일 뿐이다.
정리하자면, Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰의 역할로 사용되는 JWT 이라고 말할 수 있다.
스프링부트(3.1) 환경에서 진행했습니다.
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenHelperIfs tokenHelperIfs;
// access 토큰 발급
public TokenDto issueAccessToken(Long userId){
var data = new HashMap<String, Object>();
data.put("userId", userId);
return tokenHelperIfs.issueAccessToken(data);
}
// refresh 토큰 발급
public TokenDto issueRefreshToken(Long userId){
var data = new HashMap<String, Object>();
data.put("userId", userId);
return tokenHelperIfs.issueRefreshToken(data);
}
// 토큰 검증
public Long validationToken(String token){
var map = tokenHelperIfs.validationTokenWithThrow(token);
var userId = map.get("userId");
Objects.requireNonNull(token, ()-> {throw new ApiException(ErrorCode.NULL_POINT);});
return Long.parseLong(userId.toString());
}
}
경우에 따라 다른 토큰 서비스를 쓸 수 있도록 TokenHelperIfs를 만들고 interface를 구현한 클래스에 로직 구현
@Component
public class JwtTokenHelper implements TokenHelperIfs {
@Value("${token.secret.key}")
private String secretKey;
@Value("${token.access-token.plus-hour}")
private Long accessTokenPlusHour;
@Value("${token.refresh-token.plus-hour}")
private Long refreshTokenPlusHour;
@Override
public TokenDto issueAccessToken(Map<String, Object> data) {
var expiredLocalDateTime = LocalDateTime.now().plusHours(accessTokenPlusHour);
return getTokenDto(data, expiredLocalDateTime);
}
private TokenDto getTokenDto(Map<String, Object> data, LocalDateTime expiredLocalDateTime) {
var expireAt = Date.from(expiredLocalDateTime.atZone(ZoneId.systemDefault()).toInstant());
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var jwtToken = Jwts.builder()
.signWith(key, SignatureAlgorithm.HS256)
.setClaims(data)
.setExpiration(expireAt)
.compact();
return TokenDto.builder()
.token(jwtToken)
.expiredAt(expiredLocalDateTime)
.build();
}
@Override
public TokenDto issueRefreshToken(Map<String, Object> data) {
var expiredLocalDateTime = LocalDateTime.now().plusHours(refreshTokenPlusHour);
return getTokenDto(data, expiredLocalDateTime);
}
@Override
public Map<String, Object> validationTokenWithThrow(String token) {
var key = Keys.hmacShaKeyFor(secretKey.getBytes());
var parser = Jwts.parserBuilder()
.setSigningKey(key)
.build();
try{
var result = parser.parseClaimsJws(token);
return new HashMap<String, Object>(result.getBody());
}catch (Exception e){
if(e instanceof SignatureException){
// 토큰이 유효하지 않을 떄
throw new ApiException(TokenErrorCode.INVALID_TOKEN);
}else if(e instanceof ExpiredJwtException){
// 만료 토큰
throw new ApiException(TokenErrorCode.EXPIRED_TOKEN);
}else{
// 그 외 에러
throw new ApiException(TokenErrorCode.TOKEN_EXCEPTION, e);
}
}
}
}
검증이 필요한 모든 요청에 필요하기 떄문에 서비스에서 일일이 호출하면 비효율적입니다. Controller에 도달하기 전 Interceptor 에서 요청을 가로채서 검증을 진행합니다.
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
private final TokenBusiness tokenBusiness;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("Authorization Interceptor url : {}", request.getRequestURI());
// option 요청인 경우
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
// js, img 등 resource 자원일 경우
if (handler instanceof ResourceHttpRequestHandler) {
return true;
}
var accessToken = request.getHeader("authorization-token");
if (accessToken == null) {
throw new ApiException(TokenErrorCode.AUTHORIZATION_TOKEN_NOT_FOUND);
}
var userId = tokenBusiness.validationToken(accessToken);
if (userId != null) {
var requestContext = Objects.requireNonNull(RequestContextHolder.getRequestAttributes());
requestContext.setAttribute("userId", userId, RequestAttributes.SCOPE_REQUEST);
return true;
}
return false;
}
}
인가는 어디서 검증하나요? 그리고 토큰 발급 시 인증에 대한 권한은 어디서 설정하나요?