JWT 사용기

Caesars·2023년 8월 26일
1

Springboot

목록 보기
3/5

인증 관련해서 쿠키나 세션만 사용해봤기에.. JWT 공부한 내용을 정리해서 올립니다.


쿠키와 세션의 단점

보통 서버에서 클라이언트 인증을 확인하는 방법은 쿠키, 세션, 토큰 3가지 입니다. 토큰 방식도 단점이 있지만 쿠키와 세션의 단점이 더 크기에 토큰 방식을 선호하는듯 합니다.

  • 쿠키의 단점
    • 보안에 취약 : 브라우저에서 쉽게 조작 가능.
    • 용량 제한
    • 디바이스나 브라우저간 공유가 불가능.
  • 세션의 단점
    • 인증 요청할 때마다 세션 저장소에서 검증하므로 오버헤드 발생
  • 토큰의 단점
    • Payload는 암호화되지 않기 때문에 중요한 정보를 담을 수 없다.
    • 토큰을 탈취당하면 대처하기 어렵다.

JWT란

JWT는 웹 애플리케이션 간에 정보를 안전하게 전달하기 위해 사용되는 Json 토큰입니다. 그리고 JWT를 이용해 서버가 클라이언트를 식별하는 방식을 JWT기반 인증 이라고 합니다. 특히 사용자 인증과 정보 교환에 주로 활용됩니다.

JWT의 구성요소

Header와 Payload, Signature으로 이루어지며 각 구역이 . 기호로 구분됩니다. .을 기준으로 좌측부터 Header, Payload, Signature를 의미합니다.

JWT가 어떤 종류의 토큰인지와 어떤 알고리즘을 사용하여 서명이나 암호화되었는지를 정의

Payload

실제 전송하고자 하는 데이터가 담겨 있는 부분입니다. 클레임(claim)으로 불리는 데이터 조각들이 포함되며, 이는 사용자 정보, 권한, 토큰 만료 시간 등을 포함할 수 있습니다. 클레임은 등록된(Claims registered), 공개(Public), 비공개(Private) 클레임으로 구분됩니다.

Signature

시그니처의 구조는 (헤더 + 페이로드)와 서버가 갖고 있는 유일한 key 값을 합친 것을 헤더에서 정의한 알고리즘으로 암호화합니다.

신뢰성을 가지는 이유

만약 위와 같이 JWT를 발급 받은 유저가 역할을 변경해서 보낸다고 가정해보겠습니다. payload는 단순히 Encoding한 값이므로 쉽게 수정이 가능합니다. 하지만 데이터를 Encoding한 'eyJhY2NvdW50IjoiQm9iIiwicm9sZSI6Ik1hc3RlciJ9' 로 payload를 변경해서 검증하면 실패하게 됩니다. 'role: Master' 로 구성된 signature는 'ZkdfsomBMB4FFlbZmUgAWRZf2r0BSzSDAQKr2iNX3hg' 입니다. 만약 위 경우에 payload와 signature를 동시에 수정해서 보낸다면 보안이 뚫리게 됩니다. 하지만 secret key 값을 알지 않는 이상 그런 일은 불가능 합니다.

공식사이트에서 직접 JWT 토큰을 생성해 연습할 수 있습니다.

JWT은 인증이 목적

Payload 부분은 암호화가 된게 아니라 Base64로 Encoding 된 것 뿐이기 때문에 다시 Decoding 하면 정보가 그대로 노출됩니다. 그래서 페이로드에는 비밀번호와 같은 민감한 정보는 넣지 말아야 합니다.
JWT의 목적은 정보 보호가 아닌 위조 방지입니다.

Access Token / Refresh Token

제 3자에게 토큰 탈취의 위험성이 있기 때문에, 그대로 사용하는것이 아닌 Access Token, Refresh Token 으로 이중으로 나누어 인증을 하는 방식을 현업에선 취한다.

Access Token 과 Refresh Token은 둘다 똑같은 JWT이다. 다만 토큰이 어디에 저장되고 관리되느냐에 따른 사용 차이일 뿐이다.

  • Access Token : 클라이언트가 갖고있는 실제로 유저의 정보가 담긴 토큰으로, 클라이언트에서 요청이 오면 서버에서 해당 토큰에 있는 정보를 활용하여 사용자 정보에 맞게 응답을 진행
  • Refresh Token: 새로운 Access Token을 발급해주기 위해 사용하는 토큰으로 짧은 수명을 가지는 Access Token에게 새로운 토큰을 발급해주기 위해 사용. 해당 토큰은 보통 데이터베이스에 유저 정보와 같이 기록.

정리하자면, Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰의 역할로 사용되는 JWT 이라고 말할 수 있다.

동작 방식

  1. id와 pwd을 담아 서버로 로그인 요청 전송
  2. 유저를 위한 토큰 생성 후 클라이언트로 응답
  3. 클라이언트는 발급받은 토큰을 저장
  4. 추후 요청마다 헤더에 JWT를 담아 전송
  5. 토큰 검증
    5-1. accessToken이 유효하다면 통과
    5-2. accessToken이 만료됬다면 RefreshToken을 요청하는 메시지를 반환.
    5-3. RefreshToken을 검증하고 유효하다면 새로운 AccessToken을 발급하고 기존 요청을 처리

코드

스프링부트(3.1) 환경에서 진행했습니다.

build.gradle

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'

TokenService.java

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

}

JwtTokenHelper.java

경우에 따라 다른 토큰 서비스를 쓸 수 있도록 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);
            }
        }
    }
}

AuthorizationInterceptor.java

검증이 필요한 모든 요청에 필요하기 떄문에 서비스에서 일일이 호출하면 비효율적입니다. 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;
    }
}

참고

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC

profile
잊기전에 저장

1개의 댓글

comment-user-thumbnail
2023년 9월 4일

인가는 어디서 검증하나요? 그리고 토큰 발급 시 인증에 대한 권한은 어디서 설정하나요?

답글 달기