이번 프로젝트를 진행하면서 jwt를 이용한 로그인 기능을 구현해보면서 생겼던 의문점이나, 어려웠던 점에 대해 설명을 해보려고 합니다.
jwt를 설명하기 앞서 필요한 지식들을 먼저 배워보고 시작하겠습니다!
HTTP는 인터넷 상에서 데이터를 주고 받기 위한 서버/ 클라이언트 모델을 따르는 프로토콜입니다.
HTTP는 비연결성 및 무상태성이라는 특징을 가지고 있어, 클라이언트가 서버에게 요청(request)을 보내면, 서버는 응답(response)을 보냄으로써 데이터를 교환합니다.
HTTP는 요청을 처리 한 후 연결을 끊어버리기 때문에, 클라이언트의 상태 정보 및 현재 통신 상태가 남아있지 않습니다.
이 비연결성의 장점은 서버의 자원 낭비를 줄일 수 있다는 것입니다.
만약 다수의 클라이언트와 연결을 유지한다면 자원 낭비가 심해질 것입니다.
하지만 비연결성은 클라이언트를 식별할 수 없다는 단점이 존재해, 로그인을 하더라도 다음 요청에서 해당 클라이언트를 기억하지 못합니다.
이러한 단점때문에 사용자는 무한 로그인을 하거나, 심지어 브라우저 새로고침을 누를 때마다 로그인을 해야할 수 도 있는데,
이와 같은 HTTP 프로토콜의 특성이자 약점을 보완하기 위해서 Cookie
와 Session
이라는 기술을 활용합니다.
쿠키(Cookie)란 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다.
클라이언트는 서버에 데이터(ex. user = 김철수)를 담아 로그인 요청을 보내게 되면, 서버는 쿠키를 생성해 HTTP Set-Cookie
헤더에 입력한 데이터(ex.Set-Cookie : user = 김철수)를 쿠키에 포함시켜 전달해 줍니다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 해당 클라이언트가 요청을 보낼 때마다 저장된 쿠키를 전달하여, 서버는 쿠키에 담긴 정보를 바탕으로 해당 요청의 클라이언트가 누군지 식별하게 됩니다.
세션은 쿠키를 기반하고 있지만, 사용자 정보 파일을 브라우저에 저장하는 쿠키와 달리 세션은 서버측에서 관리합니다.
클라이언트는 로그인 요청에 대한 응답을 작성해 서버로 보내면, 서버는 인증 정보를 저장하고, 클라이언트 식별자인 SESSION-ID
를 Set-Cookie
헤더에 담아 보냅니다. 이후 클라이언트는 요청을 보낼 때마다 SESSION-ID
의 유효성을 판별해 클라이언트를 식별합니다.
JWT(JSON Web Token)
는 인증에 필요한 정보들을 암호화시킨 토큰입니다.
JWT는 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별합니다.
JWT는 위와 같이 세가지의 문자열 조합을 가지고 있으며, 구성은 아래와 같습니다.
Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려줍니다.
{
"alg": "HS256",
"typ": "JWT"
}
alg
는 정보를 암호화할 해싱 알고리즘
을, typ
는 토큰의 타입
을 나타냅니다.
위를 봤을때 HS256
알고리즘을 사용했고, JWT
타입 인 것을 알 수 있습니다.
Payload는 실제로 토큰에 담을 정보를 지니고 있으며, Key-Value 형식
으로 이루어진 한 쌍의 정보를 Claim
이라고 합니다.
주로 클라이언트 고유 ID, 유효 기간 등이 포함됩니다.
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516230922
}
Signature는 인코딩된 Header와 Payload를 더한 뒤, 비밀키로 해싱하여 생성합니다.
Header 및 Payload는 단순 인코딩된 값이기 때문에 해커가 복호화하고 조작할 수 있지만, Signature는 서버 측에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없습니다.
따라서 Signature는 토큰의 위변조 여부를 확인하는 데 사용됩니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret_key
)
프로젝트의 구성
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'
@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 }'
라고 치면 비밀키를 만들 수 있습니다.
@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로 설정해 토큰을 생성합니다.
⚡️ 주의 ?
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를 생성합니다.필터의 사용을 위해선 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의 정보(본인이 만든 필터)를 입력합니다..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
오버라이딩 합니다. 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