JWT는 무엇이고 어떻게 사용할까?

파이 ఇ·2023년 10월 12일
1
post-thumbnail

이번 프로젝트를 진행하면서 jwt를 이용한 로그인 기능을 구현해보면서 생겼던 의문점이나, 어려웠던 점에 대해 설명을 해보려고 합니다.
jwt를 설명하기 앞서 필요한 지식들을 먼저 배워보고 시작하겠습니다!

🖥️ HTTP 특징

  • 무상태 (Stateless)
  • 요청 - 응답 모델 (Request - Response)
  • 비연결성 (Connectionless)

HTTP는 인터넷 상에서 데이터를 주고 받기 위한 서버/ 클라이언트 모델을 따르는 프로토콜이다.
HTTP는 비연결성 및 무상태성이라는 특징을 가지고 있어, 클라이언트가 서버에게 요청(request)을 보내면, 서버는 응답(response)을 보냄으로써 데이터를 교환한다.
HTTP는 요청을 처리 한 후 연결을 끊어버리기 때문에, 클라이언트의 상태 정보 및 현재 통신 상태가 남아있지 않다.

이 비연결성의 장점은 서버의 자원 낭비를 줄일 수 있다는 것이다.
만약 다수의 클라이언트와 연결을 유지한다면 자원 낭비가 심해질 것이다.

하지만 비연결성은 클라이언트를 식별할 수 없다는 단점이 존재해, 로그인을 하더라도 다음 요청에서 해당 클라이언트를 기억하지 못한다.
이러한 단점때문에 사용자는 무한 로그인을 하거나, 심지어 브라우저 새로고침을 누를 때마다 로그인을 해야할 수 도 있는데,

이와 같은 HTTP 프로토콜의 특성이자 약점을 보완하기 위해서 CookieSession이라는 기술을 활용한다.

쿠키(Cookie)란 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각이다.

클라이언트는 서버에 데이터(ex. user = 김철수)를 담아 로그인 요청을 보내게 되면, 서버는 쿠키를 생성해 HTTP Set-Cookie 헤더에 입력한 데이터(ex.Set-Cookie : user = 김철수)를 쿠키에 포함시켜 전달해 준다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 해당 클라이언트가 요청을 보낼 때마다 저장된 쿠키를 전달하여, 서버는 쿠키에 담긴 정보를 바탕으로 해당 요청의 클라이언트가 누군지 식별하게 된다.

Cookie의 단점

  • 보안에 취약하다.
    • 요청 시 쿠키의 값을 그대로 보내어, 유출 및 조작당할 위험이 존재한다.
  • 용량 제한이 있어, 많은 정보를 담을 수 없다.
  • 웹 브라우저마다 쿠키에 대한 지원 형태가 다르기에, 브라우저 간 공유가 불가능하다.
  • 쿠키의 사이즈가 커질수록 네트워크에 부하가 심해진다.

🗃️ Session

세션은 쿠키를 기반하고 있지만, 사용자 정보 파일을 브라우저에 저장하는 쿠키와 달리 세션은 서버측에서 관리한다.

클라이언트는 로그인 요청에 대한 응답을 작성해 서버로 보내면, 서버는 인증 정보를 저장하고, 클라이언트 식별자인 SESSION-IDSet-Cookie헤더에 담아 보낸다. 이후 클라이언트는 요청을 보낼 때마다 SESSION-ID의 유효성을 판별해 클라이언트를 식별한다.

세션 기반 인증의 장점

  • 쿠키를 탈취당해도 사용자 정보가 아닌 무의미한 정보가 들어가 있어서 쿠키보다 안전하다.
  • 각 사용자마다 고유한 SESSIONID가 발급되기 때문에, 요청이 들어올 때마다 회원 정보를 바로 확인할 수 있다.

세션 기반 인증의 단점

  • 해커가 세션 ID를 중간에 탈취하여 클라이언트인 척 위장할 수 있다.
  • 서버의 세션 저장소를 사용하기 때문에 요청이 많아지면 서버에 부하가 생긴다.
  • 서버 증설 시 각기 다른 세션 저장소를 사용하기 때문에 세션 정보가 일치하지 않을 수 도 있다.
    • 이 경우 공통의 세션 DB를 만들어 관리하는 방법이 있다.

📇 토큰 (Token)

토큰 기반 인증 (JWT)

JWT(JSON Web Token)는 인증에 필요한 정보들을 암호화시킨 토큰이다.
JWT는 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별한다.

JWT는 위와 같이 세가지의 문자열 조합을 가지고 있으며, 구성은 아래와 같다.

  • Header
  • Payload
  • Signature

Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려준다.

{
	"alg": "HS256",
	"typ": "JWT"
}

alg는 정보를 암호화할 해싱 알고리즘을, typ토큰의 타입을 나타낸다.
위를 봤을때 HS256 알고리즘을 사용했고, JWT타입 인 것을 알 수 있다.

Payload

Payload는 실제로 토큰에 담을 정보를 지니고 있으며, Key-Value 형식으로 이루어진 한 쌍의 정보를 Claim이라고 한다.
주로 클라이언트 고유 ID, 유효 기간 등이 포함된다.

{
	"sub": "1234567890",
	"name": "John Doe",
	"iat": 1516230922
}

Signature는

Signature는 인코딩된 Header와 Payload를 더한 뒤, 비밀키로 해싱하여 생성한다.
Header 및 Payload는 단순 인코딩된 값이기 때문에 해커가 복호화하고 조작할 수 있지만, Signature는 서버 측에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없다.
따라서 Signature는 토큰의 위변조 여부를 확인하는 데 사용된다.

HMACSHA256(
	base64UrlEncode(header) + "." +
	base64UrlEncode(payload),
	secret_key
)

JWT의 장점

  • 확장성이 우수하다.
  • 인증 정보에 대한 별도의 저장소가 필요 없다. (I/O 처리 필요 없음)
  • Header와 Payload를 가지고 Signature를 생성하므로 데이터 위변조를 막을 수 있다.
  • JWT는 토큰에 대한 기본 정보와 전달할 정보 및 토큰이 검증됐음을 증명하는 서명 등 필요한 모든 정보를 자체적으로 지니고 있다.
  • 클라이언트의 인증 정보를 저장하는 세션과 다르게, 서버는 무상태(Stateless)가 된다.
  • 토큰 기반으로 다른 로그인 시스템에 접근 및 권한 공유가 가능하다. (토큰 서버 활용)
  • OAuth의 경우 Facebook, Google 등 소셜 계정을 이용해 다른 웹서비스에서도 로그인 할 수 있다.

JWT의 단점

  • 한 번 발급한 토큰에 대한 제어권이 없다.
    • JWT는 발급된 후에는 취소할 수 없으며 유효 기간이 지나기 전까지 계속 유효하다. 따라서 토큰을 강제로 만료시키거나 취소해야 할 때, 서버 측에서 추가적인 관리 및 로직이 필요하다.
  • 페이로드 크기 제한
    • JWT는 인코딩된 문자열이기 때문에 많은 정보를 담는 경우 페이로드의 크기가 커지며 이는 네트워크의 부하를 증가시킬 수 있다.
  • 데이터 변경 감지
    • 토큰에는 발급된 후에 데이터가 변경되었는지를 확인하는 기능이 없다. 따라서 토큰을 갱신하지 않고는 변경된 데이터를 알 수 없다.

JWT를 적용해보자

프로젝트의 구성
Java11
SpringBoot 2.7.16

의존성 추가

필요한 의존성을 추가합니다.

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

JWT 프로퍼티 설정

@Getter
@ConfigurationProperties("jwt")
public class JwtProperties {

	private final String secretKey;
	private final long accessTokenExpirationTime;
	private final long refreshTokenExpirationTime;

	@ConstructorBinding
	public JwtProperties(String secretKey, long accessTokenExpirationTime, long refreshTokenExpirationTime) {
		this.secretKey = secretKey;
		this.accessTokenExpirationTime = accessTokenExpirationTime;
		this.refreshTokenExpirationTime = refreshTokenExpirationTime;
	}
}

jwt의 비밀키는 노출되면 안되는 정보이기때문에 application-secret.yml 파일에 따로 저장해둡니다.
저장된 데이터를 사용할 때 @ConfigurationProperties 어노테이션을 이용해 .yml에 있는 비밀키와 토큰만료시간, 리프레쉬토큰의 만료시간 데이터를 가져와 바인딩해줍니다.

이후 JwtProperties 클래스를 읽어 Bean으로 등록합니다.

@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

}

@EnableConfigurationProperties는 @ConfigurationProperties 클래스를 Bean으로 등록하여 쓸 때(주입받을 때) 사용합니다.

⚡️ 비밀키는 어떻게 만드나요 ?
현재 저는 mac의 M1을 사용하고 있으며, 터미널에
echo -n any string | shasum -a 256 | awk '{ print $1 }'
라고 치면 비밀키를 만들 수 있습니다.

JWT 생성

@Component
public class JwtProvider {
	
    private final SecretKey secertKey;
    private final long accessTokenExpirationTime;
    
    public JwtProvider(JwtProperties jwtProperties) {
		this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
		this.accessTokenExpirationTime = jwtProperties.getAccessTokenExpirationTime();
    }
    
    public String createAccessToken(Long memberId) {
		Date now = new Date();
		Date accessTokenExpiration = new Date(now.getTime() + accessTokenExpirationTime);

		return Jwts.builder()
			.signWith(secretKey, SignatureAlgorithm.HS256)
			.setIssuedAt(now)
			.setExpiration(accessTokenExpiration)
			.addClaims(Map.of("memberId", memberId))
			.compact();
	}
}

createToken(String memberId) 메서드가 토큰을 발급하는 로직입니다.
로그인에 성공한 사용자를 대상으로 토큰을 발급해주기 때문에 사용자 정보가 담긴 memberId(PK)를 payload로 설정해 토큰을 생성합니다.

  • .signWith(secretKey, SignatureAlgorithm.HS256)
    • 먼저 JWT를 HS256알고리즘을 통해 secretKey로 서명합니다.
  • setIssuedAt(now)
    • JWT가 언제 발급되었는지 설정합니다.
  • setExpiration(new Date(now.getTime() + accessTokenExpirationTime))
    • JWT의 만료시간을 설정합니다.
  • addClaims(Map.of("memberId", memberId))
    • JWT의 payload의 클레임을 설정합니다.

⚡️ 주의 ?
setClaims() 사용 시 위에서 설정한 issuedAt과 Expiration의 정보가 삭제되고 memberId라는 payload만 claims에 담기게 됩니다. 때문에 addClaims를 사용하였습니다.

✔️ 로그인 성공시 토큰 생성 예시

{
    "tokenType": "Bearer",
    "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIyIn0.bvKP6d_hTx2stQj0k4ROa7LjDaD-ddncZjZ1jmd1VfY"
}

토큰 검증

서버(스프링) 앞에는 여러 필터들이 존재하는데 서버로 진입하기 전 인증되지 않은 사용자 등을 검증을 위한 로직입니다. (혹 인증되지 않은 사용자라면 너 우리가 발급한 토큰 가지고있어? 없으면 서버 내부로 진입하지 못해 하고 필터링을 해줍니다. 쉽게 말해 입구컷?하는 느낌입니다.)

public class JwtFilter extends OncePerRequestFilter {

	private static final String BEARER = "bearer";

	private final AntPathMatcher pathMatcher = new AntPathMatcher();
	private final List<String> excludeUrlPatterns = List.of("/api/auth/**");

	private final JwtProvider jwtProvider;

	public JwtFilter(JwtProvider jwtProvider) {
		this.jwtProvider = jwtProvider;
	}

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) {
		return excludeUrlPatterns.stream()
			.anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI()));
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		if (CorsUtils.isPreFlightRequest(request)) {
			filterChain.doFilter(request, response);
			return;
		}

		String token = extractJwt(request)
			.orElseThrow(() -> new UnAuthorizedException(ErrorCode.INVALID_TOKEN));
		jwtProvider.validateToken(token);

		filterChain.doFilter(request, response);
	}

	private Optional<String> extractJwt(HttpServletRequest request) {
		final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (!StringUtils.hasText(header) || !header.toLowerCase().startsWith(BEARER)) {
			return Optional.empty();
		}
		return Optional.of(header.split(" ")[1]);
	}
}
  • OncePerRequestFilter를 상속받는 JwtFilter를 생성합니다.
    • 인증/인가에 대해서는 한 번의 검증만 필요하기 때문에 OncePerRequestFilter를 상속받았습니다.
  • shouldNotFilter메서드를 오버라이딩해서 회원가입/로그인에 대해서는 인증/인가 로직을 수행하지 않게합니다.
  • doFilterInternal메서드를 오버라이딩해서 JWT를 검증합니다.
  • extractJwt메서드를 통해 Authorization 헤더로 넘어온 토큰을 추출합니다.
    이후 토큰이 유효한지 검증합니다.
  • PreFlightRequest는 포스팅 하단에 설명되어 있습니다.

필터를 빈으로 등록하기

필터의 사용을 위해선 bean으로 등록을 해줘야 합니다. 그래야 인증되지 않은 사용자 등을 검증 할 수 있습니다.

@Configuration
@RequiredArgsConstructor
public class FilterConfig {

	private final JwtProvider jwtProvider;
	private final AuthenticationContext authenticationContext;

	@Bean
	public FilterRegistrationBean<JwtFilter> jwtFilter() {
		FilterRegistrationBean<JwtFilter> jwtFilter = new FilterRegistrationBean<>();
		jwtFilter.setFilter(new JwtFilter(jwtProvider, authenticationContext));
		jwtFilter.addUrlPatterns("/api/*");
		jwtFilter.setOrder(2);
		return jwtFilter;
	}

	@Bean
	public FilterRegistrationBean<AuthExceptionHandlerFilter> authExceptionHandlerFilter() {
		FilterRegistrationBean<AuthExceptionHandlerFilter> authExceptionHandlerFilter = new FilterRegistrationBean<>();
		authExceptionHandlerFilter.setFilter(new AuthExceptionHandlerFilter());
		authExceptionHandlerFilter.addUrlPatterns("/api/*");
		authExceptionHandlerFilter.setOrder(1);
		return authExceptionHandlerFilter;
	}
}
  • FilterRegistrationBean 객체를 생성해 Filter의 정보(본인이 만든 필터)를 입력합니다.
    • 이때 주의할 점은 Filter는 반드시 구현이 되어야 합니다.
    • .setFilter() : 새로운 인스턴스로 만들어둔 filter를 적용합니다.
    • .addUrlPatterns() : 어떤 api를 탈 때 적용 시킬지 지정합니다. 필자의 경우 모든 api가 /api로 시작하기 때문에 위와 같이 작성하였습니다.
    • .setOrder() : 필터의 순서를 지정합니다.

💡 왜 필터의 순서를 지정하나요 ?
필터의 진행 순서는 1->2->3 순서로 진행이 되지만 2번 필터에서 예외 발생 시 3번으로 넘어가지 않고 다시 1번 filter로 예외가 전파됩니다. 그래서 예외를 처리해주는 authExceptionHandlerFilter() 메서드를 1번에 위치시키고 2번에는 작성해둔 filter를 위치 시킵니다.

필터의 예외처리

@RequiredArgsConstructor
public class AuthExceptionHandlerFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		try {
			filterChain.doFilter(request, response);
		} catch (UnAuthorizedException e) {
			setErrorResponse(response);
		}
	}

	private void setErrorResponse(HttpServletResponse response) {
		response.setStatus(HttpStatus.UNAUTHORIZED.value());
	}
}
  • 필터의 생성과 마찬가지로 doFilterInternal 오버라이딩 합니다.
    • try catch 이용해서 에러를 잡아서 처리해줍니다.

PreFlightRequest란?

preflight request는 실제 요청 전에 브라우저에서 보내는 작은 요청이다. 지금 요청을 보내는 프론트 엔드가 백엔드 서버에서 허용한 *origin이 맞는지, 그리고 해당 엔드포인트에서 어떤 HTTP 메서드들을 허용하는지 등을 확인한다.
만약 허용되는 origin 요청이고 메서드도 허용되는 것이라면 실제 요청을 할 수 있게 해준다. 그렇지 않다면, 실제 요청을 보내기도 전에 보내지 못하게 막는것이다.
origin은 프로토콜 + 호스트 + 포트를 합한 것.
아래 그림을 보면 preflight request가 무엇인지 이해하는데 도움이 된다.

끝!

레퍼런스

https://velog.io/@bruni_23yong/JWT-%EC%A0%81%EC%9A%A9%EA%B8%B0
https://bskyvision.com/entry/CORS%EC%99%80-%EA%B4%80%EB%A0%A8-%EC%9E%88%EB%8A%94-preflight-request%EB%9E%80
https://velog.io/@whitebear/%EC%BF%A0%ED%82%A4-%EC%84%B8%EC%85%98-%ED%86%A0%ED%81%B0JWT-%ED%99%95%EC%8B%A4%ED%9E%88-%EC%95%8C%EA%B3%A0-%EA%B0%80%EA%B8%B0

profile
⋆。゚★⋆⁺₊⋆ ゚☾ ゚。⋆ ☁︎。₊⋆

0개의 댓글