오늘은 JWT 서비스를 직접 구현해 볼 것이다. 😀
JWT에 대한 설명은 아래 포스팅을 참고하길!
⏩ 세션 기반 인증과 토큰 기반 인증, 그리고 JWT
먼저 JWT 서비스 구현을 위해 필요한 의존성을 추가해 준다.
dependencies {
(생략)
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
implementation 'io.jsonwebtoken:jjwt:0.9.1' // 자바 JWT 라이브러리
implementation 'javax.xml.bind:jaxb-api:2.3.1' // XML 문서와 Java 객체 간 매핑 자동화
}
JWT 토큰을 만들기 위해서는 이슈 발급자(issuer), 비밀키(secret_key)
를 필수로 설정해 주어야 한다.
(생략)
jwt:
issuer: ajufresh@gmail.com
secret_key: study-springboot
해당 값들을 변수로 접근하는 데 사용할 JwtProperties
클래스를 만든다.
package me.ansoohyeon.springbootdeveloper.config.jwt;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Getter
@Setter
@Component
@ConfigurationProperties("jwt") // 자바 클래스에 property 값을 가져와 사용하는 어노테이션
public class JwtProperties {
private String issuer;
private String secretKey;
}
토큰을 생성하고 올바른 토큰인지 유효성 검사를 하고, 토큰에서 필요한 정보를 가져오는 클래스를 작성한다.
package me.ansoohyeon.springbootdeveloper.config.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.User;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;
import static com.fasterxml.jackson.databind.type.LogicalType.Collection;
@RequiredArgsConstructor
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generateToken(User user, Duration expiredAt){
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// JWT 토큰 생성 메소드
private String makeToken(Date expiry, User user){
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ : JWT
// 내용 iss : ajufresh@gmail.com(properties 파일에서 설정한 값)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now) // 내용 iat : 현재 시간
.setExpiration(expiry) // 내용 exp : expiry 멤버 변수값
.setSubject(user.getEmail()) // 내용 sub : 유저의 이메일
.claim("id", user.getId()) // 클레임 id : 유저 ID
// 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
// JWT 토큰 유효성 검증 메소드
public boolean validToken(String token){
try{
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 비밀값으로 복호화
.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.parser() // 클레임 조회
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody();
}
}
이제 코드가 제대로 동작하는지 확인을 위해 테스트 코드를 작성해 보자. 빌더 패턴
을 사용해 객체를 만들 때 테스트가 필요한 데이터만 선택한다. 빌더 패턴을 사용하지 않으면 필드 기본값을 사용한다.
package me.ansoohyeon.springbootdeveloper.config.jwt;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Builder;
import lombok.Getter;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import static java.util.Collections.emptyMap;
@Getter
public class JwtFactory {
private String subject = "test@email.com";
private Date issuedAt = new Date();
private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
private Map<String, Object> claims = 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(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
.compact();
}
}
이제 TokenProvider
클래스를 테스트하는 클래스를 만들어 보자.
package me.ansoohyeon.springbootdeveloper.config.jwt;
import io.jsonwebtoken.Jwts;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
// generateToken() 검증 테스트
@DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
@Test
void generateToken() {
// given : 토큰에 유저 정보를 추가하기 위한 테스트 유저를 만든다.
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
// when : 토큰 제공자의 generateToken() 메소드를 호출해 토큰을 만든다.
String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
// then : jjwt 라이브러리를 사용해 토큰을 복호화한다.
// 토큰을 만들 때 클레임으로 넣어둔 id값이 given절에서 만든 유저 ID와 동일한지 확인!
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
// validToken() 검증 테스트
@DisplayName("validToken(): 만료된 토큰인 경우에 유효성 검증에 실패한다.")
@Test
void validToken_invalidToken() {
// given : jjwt 라이브러리를 사용해 토큰을 생성한다.
// 만료 시간을 현재 시간부터 14일 뒤로, 만료되지 않은 토큰으로 생성한다.
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createToken(jwtProperties);
// when : 토큰 제공자의 validToken() 메소드를 호출해 유효한 토큰인지 검증한 뒤 결과값을 반환받는다.
boolean result = tokenProvider.validToken(token);
// then : 반환값이 false(유효한 토큰이 아님)인지 확인한다.
assertThat(result).isFalse();
}
// getAuthentication() 검증 테스트
@DisplayName("getAuthentication(): 토큰 기반으로 인증정보를 가져올 수 있다.")
@Test
void getAuthentication() {
// given : jjwt 라이브러리를 사용해 토큰을 생성한다.
// 이때 토큰의 제목인 subject는 유저 이메일을 사용한다.
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
// when : 토큰 제공자의 getAuthentication() 메소드를 호출해 인증 객체를 반환받는다.
Authentication authentication = tokenProvider.getAuthentication(token);
// then : 반환받은 인증 객체의 유저 이름을 가져와 given절에서 설정한 subject 값과 같은지 확인한다.
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
// getUserId() 검증 테스트
@DisplayName("getUserId() : 토큰으로 유저 ID를 가져올 수 있다.")
@Test
void getUserId(){
//given : jjwt 라이브러리를 사용해 토큰을 생성한다.
// 이때 키는 "id", 값은 1이라는 클레임을 추가한다.
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
//when : 토큰 제공자의 getUserId() 메소드를 호출해 유저 ID를 반환받는다.
Long userIdByToken = tokenProvider.getUserId(token);
//then : 반환받은 유저 ID가 given절에서 설정한 유저 ID(1)와 같은지 확인한다.
assertThat(userIdByToken).isEqualTo(userId);
}
}
테스트가 모두 성공했다!
리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔티티와 리포지토리를 추가해야 한다. 따라서 domain 디렉터리에 RefreshToken.java
을 만들어 준다.
package me.ansoohyeon.springbootdeveloper.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
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;
}
}
이제 리포지토리를 만들어 준다.
package me.ansoohyeon.springbootdeveloper.repository;
import me.ansoohyeon.springbootdeveloper.domain.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
토큰 필터
는 요청이 오면 헤더 값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 컨텍스트 홀더에 인증 정보를 저장한다. 시큐리티 컨텍스트
는 인증 객체가 저장되는 보관소이다. 여기서 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내 사용할 수 있다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더
이다.
액세스 토큰 값이 담긴 Authorization 헤더 값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보를 설정하는 토큰 필터를 구현해 보자.
package me.ansoohyeon.springbootdeveloper.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.config.jwt.TokenProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@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;
}
}
요정 헤더에서 키가 'Authorization'인 필드의 값을 가져온 다음, 토큰의 접두사 'Bearer'을 제외한 값을 얻는다. 만약 값이 null이거나 Bearer로 시작하지 않으면 null을 반환한다. 이어서 가져온 토큰이 유효한지 확인하고 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정한다.
위 코드가 실행되며 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication()
메소드를 사용해 인증 정보를 가져오면 유저 객체가 반환된다.
이제 리프레시 토큰을 전달받아 검증하고, 유효한 리프레시 토큰이라면 새로운 액세스 토큰을 생성하는 토큰 API를 구현해 보자.
아래와 같이 전달받은 유저의 ID로 유저를 검색해서 전달하는 findById()
메소드를 추가해 준다.
(생략)
public User findById(Long userId){
return userRepository.findById(userId)
.orElseThrow(()->new IllegalArgumentException("unexpected user"));
}
그리고 새로 만들어 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색해서 전달하는 findByRefreshToken()
메소드를 구현한다.
package me.ansoohyeon.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.domain.RefreshToken;
import me.ansoohyeon.springbootdeveloper.repository.RefreshTokenRepository;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
}
}
이제 토큰 서비스 클래스를 생성한다. createNewAccessToken()
메소드는 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 리프레시 토큰으로 사용자 ID를 찾는다. 마지막으로 사용자 ID로 사용자를 찾은 뒤 토큰 제공자의 generateToken()
메소드를 호출해서 새로운 액세스 토큰을 생성한다.
package me.ansoohyeon.springbootdeveloper.service;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.config.jwt.TokenProvider;
import me.ansoohyeon.springbootdeveloper.domain.User;
import org.springframework.stereotype.Service;
import java.time.Duration;
@RequiredArgsConstructor
@Service
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserService userService;
public String createNewAccessToken(String refreshToken){
// 토큰 유효성 검사에 실패하면 예외 발생
if(!tokenProvider.validToken(refreshToken)){
throw new IllegalArgumentException("unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
return tokenProvider.generateToken(user, Duration.ofHours(2));
}
}
토큰 생성 요청 및 응답을 담당할 DTO를 만든다.
package me.ansoohyeon.springbootdeveloper.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
package me.ansoohyeon.springbootdeveloper.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
private String accessToken;
}
실제로 요청을 받고 처리할 컨트롤러를 생성한다. '/api/token POST' 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새로운 액세스 토큰을 만들어 주면 된다.
package me.ansoohyeon.springbootdeveloper.controller;
import lombok.RequiredArgsConstructor;
import me.ansoohyeon.springbootdeveloper.dto.CreateAccessTokenRequest;
import me.ansoohyeon.springbootdeveloper.dto.CreateAccessTokenResponse;
import me.ansoohyeon.springbootdeveloper.service.TokenService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(newAccessToken));
}
}
이제 이 과정을 테스트할 코드를 작성해 준다.
package me.ansoohyeon.springbootdeveloper.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import me.ansoohyeon.springbootdeveloper.config.jwt.JwtFactory;
import me.ansoohyeon.springbootdeveloper.config.jwt.JwtProperties;
import me.ansoohyeon.springbootdeveloper.domain.RefreshToken;
import me.ansoohyeon.springbootdeveloper.domain.User;
import me.ansoohyeon.springbootdeveloper.dto.CreateAccessTokenRequest;
import me.ansoohyeon.springbootdeveloper.repository.RefreshTokenRepository;
import me.ansoohyeon.springbootdeveloper.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@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 : 테스트 유저를 생성하고, jjwt 라이브러리를 이용해 리프레시 토큰을 마들어 데이터베이스에 저장한다.
// 토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성한다.
final String url = "/api/token";
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
String refreshToekn = JwtFactory.builder()
.claims(Map.of("id", testUser.getId()))
.build()
.createToken(jwtProperties);
refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToekn));
CreateAccessTokenRequest request = new CreateAccessTokenRequest();
request.setRefreshToken(refreshToekn);
final String requestBody = objectMapper.writeValueAsString(request);
// when : 토큰 추가 API에 요청을 보낸다.
// 이때 요청 타입은 JSON이고, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보낸다.
ResultActions resultActions = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then : 응답 코드가 201 Created인지 확인하고 응답으로 온 액세스 토큰이 비어 있지 않은지 확인한다.
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
}
테스트를 실행하니 성공했다!
요즘은 JWT를 많이 사용한다는 것을 듣고 이렇게 먼 길을 달려 왔는데.. 처음 보는 코드도 많고 과정이 길고 험난해서 중간에 살짝 멘탈이 나갈 뻔했다. 그렇지만 결국 해내고 나니 뿌듯한 마음이 든다. 🙌