이번 이슈 트래커 프로젝트를 진행하며 JWT를 통한 로그인을 구현하게 되었습니다. 이전에는 세션을 통해 로그인을 구현했었는데 둘은 어떤 차이가 있고 Spring과 JWT를 통해 로그인을 구현한 과정을 소개하겠습니다.
먼저 HTTP의 특성을 소개하려고 합니다. HTTP는 무상태 프로토콜로 서버가 클라이언트의 상태를 보관하지 않는다는 특성이 있습니다. HTTP는 서버가 클라이언트의 요청을 처리하면 연결을 끊어 클라이언트에 대한 이전 정보를 가지고 있지 않게 됩니다.
이러한 방식은 서버의 확장성(Scale-out)을 높일 수 있고 불필요한 자원의 낭비를 줄일 수 있다는 장점이 있습니다.
상태를 계속 유지한다면 서버마다 클라이언트의 정보를 가지고 있어야겠죠?
그런데 우리는 로그인을 한 번 하고 나면 로그인 상태를 유지할 수 있습니다. 어떻게 이를 가능케할까요? 바로 쿠키와 세션 기술을 이용해 이를 가능케합니다.
세션에 대해 먼저 설명하기 전에 쿠키에 대해 알아보겠습니다.
MDN에는 쿠키가 다음과 같이 정의되어 있습니다.
HTTP 쿠키(웹 쿠키, 브라우저 쿠키)는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송합니다. 쿠키는 두 요청이 동일한 브라우저에서 들어왔는지 아닌지를 판단할 때 주로 사용합니다. 이를 이용하면 사용자의 로그인 상태를 유지할 수 있습니다. 상태가 없는(stateless) HTTP 프로토콜에서 상태 정보를 기억시켜주기 때문입니다.
클라이언트는 서버에 요청을 보내게 되면 서버는 Set-Cookie
헤더에 쿠키를 담아 전달해 줍니다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 HTTP 요청시 해당 쿠키를 담아 전달하게 됩니다.
주의할 점은 요청시 데이터를 쿠키에 그대로 담아 보내기 때문에 쿠키가 탈취당한다면 그대로 정보가 노출되기 떄문에 민감한 정보는 담지 않는 것이 좋습니다.
세션은 쿠키와 다르게 민감한 정보들은 서버에 저장해두고 서버는 SESSION_ID를 Set-Cookie
헤더에 담아 보내게 됩니다.
HTTP/1.1 200 OK
Set-Cookie: weohfeq2390ghehg42q
이렇게 되면 클라이언트는 SESSION_ID를 가지고 있게 되고 이후 요청마다 SESSION_ID를 Cookie
헤더에 담아 서버에 보내게 됩니다.
이러한 세션방식의 장단점은 다음과 같습니다.
session 을 이용할 때 scale-out시 대처
1. sticky session
2. session clustering
3. 외부 session 저장소 이용 (ex. redis)
JWT(Json Web Token)는 서명된 토큰입니다. 먼저 JWT가 어떻게 생겼는지 알아보겠습니다.
JWT의 구성요소는 아래 3가지와 같은데, 점(.)으로 구분되어 있습니다.
Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려줍니다.
그림에서 보면 HS256
알고리즘을 사용했고 JWT
타입인 것을 알 수 있습니다.
HS256
대칭키 방식의 알고리즘 말고RS256
비대칭키 방식의 알고리즘을 사용하는 방식도 있습니다.
Payload는 토큰에 담을 정보를 저장하고 있습니다. Key-Value 한 쌍의 정보를 Claim 이라고 합니다.
jwt.io 문서에 따르면 Claim은 registered, public, private 이렇게 3종류가 있습니다.
위와같이 표준 스펙으로 정의된 Claim 스펙이 존재합니다. 위에서 말했듯이 필수는 아니기 때문에 상황에 따라 적절히 사용하면됩니다.
Signature는 헤더와 페이로드가 비밀키로 서명되어 저장됩니다.
웹에서 쿠키(cookie)와 세션(session)을 이용한 사용자 인증을 구현하는 방식과 비교해 봤을 때 확장성에 있어 가장 큰 차이를 보입니다. JWT는 이미 사용자의 정보가 저장되어 있고 서버는 이를 검증만 해주면 되기 때문에 세션과 다르게 따로 저장소를 둘 필요가 없습니다.
그렇기 때문에 JWT를 사용할 때는 사용자가 늘어나도 인증을 위한 저장소를 둘 필요가 없으니 인프라 비용을 절감할 수 있습니다.
위를 보면 항상 JWT가 좋아보일 수 있습니다. 하지만 JWT는 몇 가지 한계점이 존재합니다.
토큰에 대한 제어권이 없기 때문에 규모가 큰 서비스에서는 JWT를 사용하기에는 부족한 느낌이 있습니다. 예를 들어 여러 장치에서 로그인을 하는 것을 막고 싶은 경우 JWT를 사용하기보다는 세션을 이용해야하기 때문입니다.
저희 프로젝트는 JWT를 사용하기로 결정했는데요, 규모가 큰 서비스도 아니고 여러 장비를 고려하지 않고 인프라 비용도 절감하기 위해 JWT를 선택했습니다. 또한 빠른 로그인 구현에 초점을 맞추었기 때문에 refreshToken
의 발급은 고려하지 않았습니다.
이제 Spring 환경에서 JWT를 적용해보겠습니다.
프로젝트의 기술 정보는 다음과 같습니다.
- Java11
- Spring Boot 2.7.14
먼저 Gradle에 아래와 같은 의존성을 추가합니다.
// 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'
@Getter
@ConfigurationProperties("jwt")
public class JwtProperties {
private final String secretKey;
private final long expirationMilliseconds;
@ConstructorBinding
public JwtProperties(String secretKey, long expirationMilliseconds) {
this.secretKey = secretKey;
this.expirationMilliseconds = expirationMilliseconds;
}
}
현재 프로젝트에서 비밀키는 코드상에 드러나면 안되기 때문에 application-jwt.yml
파일로 비밀키와 만료시간을 관리하고 있습니다.
yml 파일에서 정보를 읽어오기 위해 @ConfigurationProperties("jwt")
를 통해 설정정보를 읽어옵니다. 이후 JwtProperties
클래스를 읽어 Jwt
빈을 등록해 줍니다.
@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {
private final JwtProperties properties;
public JwtConfig(JwtProperties properties) {
this.properties = properties;
}
@Bean
public Jwt jwtProperties() {
return new Jwt(properties.getSecretKey(), properties.getExpirationMilliseconds());
}
}
저희는 로그인에 성공한 사용자에 대해 토큰을 발급해주는 로직입니다. 그렇기 때문에 JWT를 발급해주는 로직을 작성하겠습니다.
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final long expirationMilliseconds;
public JwtProvider(Jwt jwt) {
this.secretKey = Keys.hmacShaKeyFor(jwt.getSecretKey().getBytes(StandardCharsets.UTF_8));
this.expirationMilliseconds = jwt.getExpirationMilliseconds();
}
public String createToken(String payload) {
Date now = new Date();
return Jwts.builder()
.signWith(secretKey, SignatureAlgorithm.HS256)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expirationMilliseconds))
.setClaims(Map.of("userId", payload))
.compact();
}
}
createToken(String payload)
메서드가 토큰을 발급하는 로직입니다.
HS256
알고리즘을 통해 secretKey
로 서명합니다.sub
로 설정할 수도 있을 것 같습니다.이제 로그인에 성공하면 다음과 같이 토큰을 발급해주는 것을 확인할 수 있습니다.
{
"tokenType": "Bearer",
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIyIn0.bvKP6d_hTx2stQj0k4ROa7LjDaD-ddncZjZ1jmd1VfY"
}
인증되지 않은 사용자는 Spring Context까지 요청이 올 필요가 없다고 생각했기 때문에 토큰의 검증은 Filter단에서 진행하기로 결정했습니다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER = "bearer";
private static final int TOKEN_INDEX = 1;
private static final AntPathMatcher pathMatcher = new AntPathMatcher();
private static final List<String> excludeUrlPatterns = List.of("/api/auth/**");
private final JwtProvider jwtProvider;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return excludeUrlPatterns.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, request.getServletPath()));
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
extractJwt(request).ifPresentOrElse(jwtProvider::validateToken, () -> {
throw new ApplicationException(ErrorCode.EMPTY_JWT);
});
filterChain.doFilter(request, response);
}
private Optional<String> extractJwt(HttpServletRequest request) {
String header = request.getHeader(AUTHORIZATION);
if (!StringUtils.hasText(header)) {
return Optional.empty();
}
if (header.toLowerCase().startsWith(BEARER)) {
return Optional.of(header.split(" ")[TOKEN_INDEX]);
}
return Optional.empty();
}
}
OncePerRequestFilter
를 상속받는 JwtFilter
를 생성합니다. OncePerRequestFilter
를 상속받았습니다.shouldNotFilter
를 오버라이딩해서 회원가입/로그인에 대해서는 인증/인가 로직을 수행하지 않게합니다.doFilterInternal
메서드로 JWT를 검증합니다.extractJwt
메서드를 통해 Authorization
헤더로 넘어온 토큰을 추출합니다.토큰의 검증로직은 다음과 같습니다.
// JwtProvider
public void validateToken(final String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw new ApplicationException(ErrorCode.EXPIRED_JWT);
} catch (JwtException e) {
throw new ApplicationException(ErrorCode.INVALID_JWT);
}
}
이제 JwtFilter
를 빈으로 등록해줍니다.
@Bean
public FilterRegistrationBean<JwtFilter> jwtFilter() {
FilterRegistrationBean<JwtFilter> jwtFilter = new FilterRegistrationBean<>();
jwtFilter.setFilter(new JwtFilter(jwtProvider));
jwtFilter.addUrlPatterns("/api/*");
return jwtFilter;
}
Authorizatoin
헤더에 JWT를 넣어 요청이 상황에서 요청을 날렸을 때 만나는 문제는 다음과 같았습니다.
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. The response had HTTP status code 401. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
CORS 설정을 해주었지만 CORS 키워드가 나와 당황했습니다. 그런데 CORS 문제라면 405 응답이 나타나야 하는데 401응답을 준다는 것이 이상했습니다. 이는 Preflight 처리 중 발생한 문제였습니다.
Preflight Request일 경우 JWT 검증 로직을 수행하지 않도록 했습니다.
if (CorsUtils.isPreFlightRequest(request)) {
filterChain.doFilter(request, response);
return;
}
String token = extractJwt(request).orElseThrow(() -> new ApplicationException(ErrorCode.EMPTY_JWT));
jwtProvider.validateToken(token);
authenticationContext.setPrincipal(jwtProvider.extractUserId(token));
filterChain.doFilter(request, response);
이런 유용한 정보를 나눠주셔서 감사합니다.