앞서 OAuth 로 서비스 이용하는 유저들을 외부 소셜을 통해서 로그인을 진행했다. 이후, 우리 서비스를 이용하기에 (API 호출이 가능한 유저인지) 적합한 유저인지를 판별하기 위해 프론트와 협의 후 JWT를 도입하게 됐다.
JWT에 대한 기본적인 개념부터 짚고 넘어가자.
Json Web Token 의 약자로 인증에 필요한 정보들을 암호화시킨 JSON 토큰이다.
Base64 URL-safe Encode 를 통해 인코딩하여 직렬화한 것이며, 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 들어있다.
웹 표준을 따르기 때문에 대부분의 언어가 이를 지원한다.
위 사진이 JWT의 형태이며 하나씩 뜯어보자.
{
alg : HS256
typ : JWT
}
헤더에는 위 2가지 내용이 들어간다.
서명 암호화 알고리즘이란 Base64로 암호화 시킨 알고리즘 형태를 뜻한다.
Claim(JWT를 통해 식별 가능한 데이터)이 담기며, 서버와 클라이언트가 주고 받는 데이터가 담기는 공간이다.
시그니처에서 사용하는 알고리즘은 헤더에서 정의한 알고리즘 방식을 활용
기본적인 JWT 관련 구성은 위와 같다.
아래 공식사이트에서 JWT 토큰을 인코딩 하거나 디코딩 할수 있다.
jwt.io
JWT 는 아래 2가지로 나뉜다.
2가지로 나누는건 알겠는데 왜 나누는가?
build.gradle
implementation 'io.jsonwebtoken:jjwt:0.9.1'
jjwt 라이브러리를 활용해 구현했다.
서버 입장에선, 로그인 시 Access Token을 같이 응답해주고 클라이언트가 사용자 브라우저의 로컬 스토리지에 담아두는 형태로 진행해두었다.
JwtService.java
public JwtResponse createToken(Long userId) {
String refreshToken;
Optional<RefreshToken> optional = jwtRepository.findById(userId);
if (optional.isEmpty()) {
refreshToken = jwtFactory.createRefreshToken();
jwtRepository.save(RefreshToken.create(userId, refreshToken));
} else {
refreshToken = optional.get().getRefreshToken();
}
return JwtResponse.create(jwtFactory.createAccessToken(userId), refreshToken);
}
JwtFactory.java
public String createAccessToken(Long userId){
Claims claims = Jwts.claims().setSubject(userId.toString());
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessExpiration))
.signWith(SignatureAlgorithm.HS256, encodeAccessKey)
.compact();
}
public String createRefreshToken(){
Date now = new Date();
return Jwts.builder()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshExpiration))
.signWith(SignatureAlgorithm.HS256, encodeRefreshKey)
.compact();
}
앞선 포스팅에서 OAuth를 통해 로그인 한 유저의 정보를 가공해 DB에 저장시킨다고 끝맺었다. 우린 DB의 해당 유저 ID를 통해 JWT를 생성 및 응답 해주기로 했다.
Access Token 에는 유저의 ID를 Payload에 같이 넣어주었고 Refresh Token 에는 어떠한 Claim도 넣지 않았다.
위 사항은 조금 더 조사가 필요하지만, Refresh Token 에는 어떠한 Claim도 넣지 않는다라는 정책이 존재했다.
사진 처럼 정상적인 응답이 오는걸 확인할 수 있다.
위에선 로그인 한 유저 정보를 바탕으로 JWT 생성 및 응답하는 과정을 설명했다.
서버 입장에선 응답해준 JWT로 인증하는 과정이 필요하다.
개인적인 생각으로 인증, 인가 과정에서 가장 중요하다 생각하는 과정이다.
전체적인 서버의 요청 흐름이며 위 과정을 코드를 통해 설명하겠다.
SecurityConfig.java
http
.addFilterBefore(new AuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtFailureFilter(), AuthenticationFilter.class);
서버에선 Spring Security를 사용했으며, Security Filter 단에서 JWT 인증 필터를 중간에 끼워두었다.
AuthenticationFilter.java
public class AuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final JwtProvider jwtProvider;
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
if(authorizationHeader == null){
chain.doFilter(request, response);
return;
}
setAuthPrincipal(getToken(authorizationHeader));
chain.doFilter(request, response);
}
private String getToken(String authorizationHeader){
return authorizationHeader.substring(BEARER_PREFIX.length());
}
private void setAuthPrincipal(String token) {
AuthUser authUser = AuthUser.create(jwtProvider.getUserId(token));
Authentication authentication =
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
클라이언트에서 요청 헤더에 실어 보낸 Access Token을 꺼내는 작업을 하는 Filter이다.
요청 헤더의 Authorization
에 담겨있는 Access Token을 꺼내고 JwtProvider
에게 JWT 디코딩 작업 및 Claim에 존재하는 사용자 ID 값을 추출하는 작업을 진행한다.
사용자 ID가 존재하는 JWT일 경우, AuthUser 객체를 생성 후Security의 인증 유저 설정하고 이후 Filter를 타도록 설정해둔 코드이다.
JwtProvider.java
public Long getUserId(String accessToken) {
return Long.valueOf(getClaims(accessKey, accessToken, false).getSubject());
}
private Claims getClaims(String key, String token, boolean isRefresh) {
try {
return Jwts.parser()
.setSigningKey(key.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token)
.getBody();
} catch (SignatureException | MalformedJwtException | MissingClaimException ex) {
if (isRefresh) {
throw new CustomException(MODULATION_REFRESH);
}
throw new CustomException(MODULATION_ACCESS);
} catch (ExpiredJwtException ex) {
throw new CustomException(EXPIRATION_ACCESS);
}
}
}
jjwt 라이브러리에서 제공되는 Jwts.parser()
를 통해 서버 입장에선 쉽게 디코딩 작업과 Claim을 얻어내는 작업이 가능하다.
2-2 포스팅에서 JWT 예외 처리 및 Refresh Token 활용에 대한 포스팅