Spring Boot 3.0.2 version 기준입니다.
(Spring Boot 3.1.0까지 실행 확인)
이전 글
아래 포스팅과 이어지는 글입니다.
JWT?
JWT는 JSON Web Token의 줄임말이다. JWT는 토큰 기반 인증을 지원한다. 토큰 기반 인증은 말 그대로 토큰을 사용하는 방법인데, 토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값이다. 서버에서 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 해당 토큰을 가지고 있다가 여러 요청을 이 토큰을 함께 보내면 서버에서 토큰에 대한 유효성 검사 후에 유효한 토큰이면 클라이언트의 요청을 성공적으로 처리해준다.
토큰은 크기가 작고, URL 안전으로 설계되어 있으며 특히 웹 브라우저 통합 인증(SSO) 컨텍스트에 유용하다.
JWT 장점
- 크기
JSON으로 생성된 토큰은 용량이 작기 때문에 매우 빠르게 전달될 수 있다. - 안전
HMAC 알고리즘 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있다. 토큰에 대한 무결성이 보장된다. - 무상태성(Stateless)
세션 기반 인증은 상태를 갖는다. 즉, Stateful 서버이다. 세션 기반 인증은 클라이언트의 (인증)상태를 관리해야 한다. 사용자가 로그인에 성공하면 세션을 발행하는데, 문제는 그 세션을 여기저기 이곳 저곳에 저장한다는 것이다(브라우저, 서버 메모리 등). 이렇게 저장해 놓은 세션을 클라이언트 측에서 서버쪽에 요청을 보낼 때 같이 포함시켜 보내며, 서버는 클라이언트에서 받은 세션 값에 대한 유효성 검사를 실시한다. 이러한 세션 관리는 사용자가 많으면 많아질 수록 서버쪽에 부하가 올 수 있다.
반대로 Stateless 서버는 이러한 상태를 유지하지 않는다는 것이다. 토큰을 서버가 아닌 클라이언트 측에서 관리한다. 브라우저의 로컬 스토리지 혹은 쿠키에 저장되고, 이 토큰을 HTTP 요청과 함께 서버측에 보내면 서버는 이 토큰에 대한 위변조 검사 및 만료 여부 등의 유효성 검사를 실시한다.
이렇게 토큰 기반 인증 방식은 서버 측에서 클라이언트의 상태를 직접적으로 관리하지 않으므로 높은 확장성을 가질 수 있다.
JWT 구조
간단한 형태의 JSON 웹 토큰은 점(.)으로 구분된 헤더(header), 내용(payload), 서명(signature) 세 부분으로 이루어져있다.
xxxxx.yyyyy.zzzzz
- 헤더(Header)
헤더는 일반적으로 토큰의 유형과 서명에 대한 해싱 알고리즘(HMAC SHA256 또는 RSA)을 지정하는 정보를 담는다.
이 JSON 데이터는 Base64Url로 인코딩되어 JWT의 첫 번째 부분을 구성한다.
{
"alg": "HS256",
"typ": "JWT"
}
- 내용(Payload)
클레임을 포함한다. 클레임은 키값의 한 쌍으로 이루어져있으며 등록된 클레임, 공개 클레임, 비공개 클레임이 있다.
이 JSON 데이터는 Base64Url로 인코딩되어 JWT의 두 번째 부분을 구성한다. 따로 암호화 된 것이 아니라 중간에 누군자 탈취하여 디코딩하면 데이터를 볼 수 있으므로 JWE로 암호화 하거나 중요 데이터를 담으면 안 된다.
- 등록된 클레임
등록된 클레임은 토큰에 대한 정보를 담는데 사용하며, 필수는 아니지만 유용하고 상호 운용 가능한 클레임을 제공하기 위해 권장되는 미리 정의된 클레임 집합이다.
iss(토큰 발급자), sub(토큰 제목), aud(토큰 대상자), exp(토큰 만료 시간) 등
더 많은 등록된 클레임을 보려면 클릭 - 공개 클레임
공개되어도 상관 없는 클레임이다. 충돌을 방지하는 이름을 사용해야 하며, 충돌을 방지하기 위해 보통 URI로 정의한다. - 비공개 클레임
외부로 공개되면 안되는 클레임이다. 사용에 동의한 당사자 간에 정보를 공유하기 위해 생성된 사용자 정의 클레임이며 주로 클라이언트와 서버 간의 통신에 사용된다.
- 등록된 클레임
{
"iss": "test@test.com", // 등록된 클레임
"exp": 1422779638, // 등록된 클레임
"https://test.com/test_admin": true, // 공개 클레임
"email": "secret@test.com" // 비공개 클레임
}
- 서명(signature)
메세지의 위변조 여부를 확인하는 용도로 사용되며, 인코딩 된 헤더, 인코딩 된 내용, 비밀 키를 이용해 헤더에 지정했던 알고리즘 방식으로 해시값을 생성한다.
HMACSHA256(
base64UrlEncode(header) + . +
base64UrlEncode(payload),
secret)
최종적으로는 아래와 같은 JWT가 생성된다. (JWT 디버거로 생성 → https://jwt.io/#debugger-io)
토큰 기반 인증의 문제점
토큰 기반 인증의 장점이었던 Stateless 서버는 한 가지 문제점이 있다. 바로 세션 기반 인증과 같이 서버 쪽에서 관리가 되고 있지 않기 때문에 올바른 사용자인지, 아니면 누군가 토큰을 탈취해 악의적인 의도로 접근해 온 사용자인지 알지 못한다는 것이다. 때문에 액세스 토큰의 유효 기간을 아예 짧게 설정하는 것으로 나름 보안을 강화할 수 있지만 그러면 사용하는 입장에선 불편함을 느낄 수 밖에 없다. 이런 문제점을 해결하기 위해 나타난 것이 'Refresh Token'이다.
리프레시 토큰은 액세스 토큰이 만료되었을 때 액세스 토큰을 새로 발급하기 위해 필요한 토큰이다. 액세스 토큰과 다른 점은 리프레시 토큰은 액세스 토큰보다 유효 기간이 길고, 데이터베이스에 저장한다는 점이다.
리프레시 토큰 인증 과정
- 클라이언트 측에서 인증 요청을 보낸다.
- 서버측에서 액세스 토큰과 리프레시 토큰 두 개를 클라이언트 측에 전송한다.
- 클라이언트는 전송받은 토큰을 저장한다.
- 서버에서는 리프레시 토큰을 데이터베이스에 저장한다.
- 클라이언트 측에서 인증이 필요한 API 요청을 서버측에 보낼 때마다 액세스 토큰을 함께 보낸다.
- 서버 측에서는 전달받은 액세스 토큰에 대한 유효성 검사 후 유효하다면 클라이언트 요청을 처리한다.
만약 액세스 토큰이 만료되었다면 토큰이 만료되었다는 에러를 클라이언트에게 전달한다. - 에러를 전달받은 클라이언트는 리프레시 토큰과 함께 새 액세스 토큰을 서버 측에 요청한다.
- 서버에서는 리프레시 토큰 유효성 검사를 위해 데이터베이스에서 리프레시 토큰을 조회 후 비교한다.
유효하다면 새 액세스 토큰을 발급하여 클라이언트에게 전달한다. - 클라이언트는 새 액세스 토큰을 가지고 원래 요청하려던 API를 다시 요청한다.
그런데 이 리프레시 토큰을 DB에서 저장하고 관리하면 토큰 기반 인증의 장점인 Stateless 장점이 옅어지는 듯하다. 나는 아직 아는 게 많이 없어서 정확히 얘기는 못하겠고, 아래 게시글을 참고하면 될 것 같다.
스프링부트 3.X에 JWT 적용하기
의존성 추가하기
build.gradle의 dependencies에 의존성을 추가한다.
// 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'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
이슈 발급자, 비밀키 설정
JWT 토큰을 생성하기 위해서는 이슈 발급자(issuer), 비밀키(secret_key)를 필수로 설정해야 한다.
관련 설정은 application.yml에 추가한다. 참고로 secret_key 값은 너무 짧게 만들면 길게 만들라고 에러를 뱉어내니 충분히 길게 만들어줘야한다.
# JWT
jwt:
issuer: test@test.com
secret_key: testProjectTestJwtProjectTest@Test.com/TestProjectTestJwtProjectTestTestTest
짧게 만들면 아래 에러 메세지가 반겨 줄 것이다.
The signing key's size is 64 bits which is not secure enough for the HS256 algorithm. The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS256 MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size). |
JwtProperties
@Getter
@Setter
@Component
public class JwtProperties implements InitializingBean {
@Value("${jwt.issuer}")
private String issuer; // application.yml의 issuer
@Value("${jwt.secret_key}")
private String secretKey; // application.yml의 secret_key
private Key key;
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
InitializingBean 인터페이스의 afterPropertiesSet()을 구현하여 String 타입의 secretKey로 Key 타입의 key 값을 구해 세팅한다.
TokenProvider
토큰 생성 및 유효성 검사 등을 수행하는 클래스 작성
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generatedToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// Token 생성 메서드
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // (헤더) typ : JWT
.setIssuer(jwtProperties.getIssuer()) // (내용) iss : test@test.com (application.yml에 작성한 값)
.setIssuedAt(now) // (내용) iat : 현재 시간
.setExpiration(expiry) // (내용) exp : expiry 멤버 변숫값
.setSubject(user.getEmail()) // (내용) sub : user 이메일
.claim("id", user.getId()) // (클레임) id : userId
// 서명
.signWith(jwtProperties.getKey(), SignatureAlgorithm.HS256) // 암호화된 비밀키 값 + 해시을 HS256 방식으로 암호화
.compact();
}
// JWT 토큰 유효성 검사
public boolean validToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(jwtProperties.getKey()) // 비밀 값으로 복호화
.build()
.parseClaimsJws(token);
return true;
}catch (Exception e) {
return false;
}
}
// 토큰 기반으로 인증 정보를 가져오는 메서드
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(
claims.getSubject(), "", authorities
), token, authorities);
}
// 토큰 기반으로 유저 ID를 가져오는 메서드
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
// 클레임 조회 메서드
private Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(jwtProperties.getKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
Refresh Token doamin
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
public RefreshToken(Long userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public RefreshToken update(String newRefreshToken) {
this.refreshToken = newRefreshToken;
return this;
}
}
RefreshTokenRepository
userId, refreshToken 값으로 RefreshToken 데이터를 찾아 반환하는 메서드 생성
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
토큰 필터 생성
토큰 필터는 각종 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다. 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인 후, 유효 토큰이라면 Security Context Holder에 인증 정보를 저장한다.
Security Context Holder는 인증 객체가 저장되는 보관소이다. 인증 정보가 필요할 때는 언제든지 인증 객체를 꺼내 사용할 수 있으며 이 클래스는 thread 마다 공간을 할당하는 thread local에 저장되므로 코드의 아무곳에서나 참조할 수 있고, 다른 thread와 공유하지 않으므로 독립적으로 사용할 수 있다.
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더의 Authorization 키 값 조회
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
// 가져온 값에서 접두사 제거
String token = getAccessToken(authorizationHeader);
// 가져온 토큰이 유효한지 확인, 유효하면 인증 정보 설정
if(tokenProvider.validToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorizationHeader) {
if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
}
}
RefreshTokenRepository
클라이언트의 request로 전달받은 리프레시 토큰으로 리프레시 객체를 검색해서 반환하는 메서드 작성
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
}
}
TokenService
전달받은 리프레시 토큰 유효성 검사 후, 유효한 토큰이면 사용자를 찾고 generatedToken()메서드로 새로운 액세스 토큰을 생성한다.
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserRepository userRepository;
public String createNewAccessToken(String refreshToken) {
// 토큰 유효성 검사에 실패하면 예외 발생
if(!tokenProvider.validToken(refreshToken)) {
throw new IllegalArgumentException("Unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
return tokenProvider.generatedToken(user, Duration.ofHours(2));
}
}
Controller 작성을 위한 DTO 생성
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
// ==========================================
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
private String accessToken;
}
TokenApiController
/api/token으로 POST 요청이 오면 refreshToken으로 accessToken을 발급해준다.
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createAccessToken(
@RequestBody CreateAccessTokenRequest request) {
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(newAccessToken));
}
}
테스트 코드 작성
JwtFactory
@Getter
public class JwtFactory {
private String subject = "test@subject.com";
private Date issuedAt = new Date();
private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
private Map<String, Object> claims = Collections.emptyMap();
// 빌더 패턴을 사용해 설정이 필요한 데이터만 선택 설정
@Builder
public JwtFactory(String subject, Date issuedAt, Date expiration
,Map<String, Object> claims) {
this.subject = subject != null ? subject : this.subject;
this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
this.expiration = expiration != null ? expiration : this.expiration;
this.claims = claims != null ? claims : this.claims;
}
public static JwtFactory withDefaultValues() {return JwtFactory.builder().build();}
// jjwt 라이브러리를 사용해 JWT 토큰 생성
public String createToken(JwtProperties jwtProperties) {
return Jwts.builder()
.setSubject(subject)
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.addClaims(claims)
.signWith(jwtProperties.getKey(), SignatureAlgorithm.HS256)
.compact();
}
}
TokenApiControllerTest
@SpringBootTest
@AutoConfigureMockMvc
class TokenApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
@Autowired
JwtProperties jwtProperties;
@Autowired
UserRepository userRepository;
@Autowired
RefreshTokenRepository refreshTokenRepository;
@BeforeEach
public void mockMvcSetUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
userRepository.deleteAll();
}
@DisplayName("CreateNewAccessToken")
@Test
public void createNewAccessToken() throws Exception {
// given
final String url = "/api/token";
User testUser = userRepository.save(User.builder()
.email("user@test.com")
.password("test")
.build());
String refreshToken = JwtFactory.builder()
.claims(Map.of("id", testUser.getId()))
.build()
.createToken(jwtProperties);
refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));
CreateAccessTokenRequest request = new CreateAccessTokenRequest();
request.setRefreshToken(refreshToken);
final String requestBody = objectMapper.writeValueAsString(request);
// when
ResultActions resultActions = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
}
테스트 코드 결과
작성 후 실행
에러를 보니까 jwtProperties 쪽에 에러가 발생한 걸 확인했다. '@'가 표시가 있는 걸 보니 application.yml에 넣어준 secret_key 값에 @가 들어가서 제대로 디코드 처리가 안된 것 같아 '@'를 삭제하고 다시 실행
이번엔 '.' ....;; 또 수정 후 재실행
정상적으로 테스트가 완료되었다.
- reference
- 스프링부트3 백엔드 개발자 되기(자바편) 및 스택 오버플로우 및 여러 블로그
- JWT에서 Refresh Token은 왜 필요한가?
도움이 되셨다면 공감 부탁드립니다!