본 프로젝트는 “스프링부트 3 백엔드 개발자 되기” 서적을 참고하여 진행하였음
JWT 서비스 구현 - 테스트 코드
•
테스트 코드 작성
◦
generateToken() - 토큰을 생성하는 메서드를 테스트하는 메서드
@DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
@Test
void generateToken() {
// given
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
// when
String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
// then
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretkey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
Java
복사
given | 토큰에 유저 정보를 추가하기 위한 테스트 유저를 만든다. |
when | 토큰 제공자의 generateToken() 메서드를 호출해 토큰을 만든다. |
then | jjwt 라이브러리를 사용해 토큰을 복호화한다. 토큰을 만들 때 클레임으로 넣어둔 id값이 given절에서 만든 유저ID와 동일한지 확인한다. |
◦
validToken_invalidToken()
// validToken() 검증 테스트
@DisplayName("validToken(): 만료된 토큰인 때에 유효성 검증에 실패한다.")
@Test
void vaildToken_invalidToken() {
// given
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createToken(jwtProperties);
// when
boolean result = tokenProvider.vaildToken(token);
// then
assertThat(result).isFalse();
}
Java
복사
validToken_invalidToken() 메서드는 토큰이 유효한 토큰인지 검증하는 메서드인 validToken() 메서드를 테스트하는 메서드이다. 검증 실패를 확인하는 validToken_invalidToken() 메서드와 검증 성공을 확인하는 validToken_validToken() 메서드로 구성하였다.
given | jjwt 라이브러리를 사용하여 토큰을 생성한다. 이때 만료 시간은 1970년 1월 1일부터 현재 시간을 밀리초 단위로 치환한 값(new Date().getTime())에 1000을 빼, 이미 만료된 토큰으로 생성한다. |
when | 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결과값을 반환받는다. |
then | 반환값이 false(유효한 토큰이 아님)인 것을 확인한다. |
◦
valid_validToken()
@DisplayName("validToken(): 유효한 토큰인 때에 유효성 검증에 성공한다.")
@Test
void validToken_validToken() {
// given
String token = JwtFactory.withDefaultValues()
.createToken(jwtProperties);
// when
boolean result = tokenProvider.vaildToken(token);
// then
assertThat(result).isTrue();
}
Java
복사
given | jjwt 라이브러리를 사용하여 토큰을 생성한다. 만료 시간은 현재 시간으로부터 14일 뒤로, 만료되지 않은 토큰으로 생성한다. |
when | 토큰 제공자의 validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결과값을 반환받는다. |
then | 반환값이 true(유효한 토큰임)인 것을 확인한다. |
◦
getAuthentication() - 토큰을 전달받아 인증 정보를 담은 객체 Authentication을 반환하는 메서드인 getAuthentication()을 테스트한다.
// getAuthentication() 검증 테스트
@DisplayName("getAuthentication(): 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication() {
// given
String userEmail = "user@email.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
// when
Authentication authentication = tokenProvider.getAuthentication(token);
// then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
Java
복사
given | jjwt 라이브러리를 사용하여 토큰을 생성한다. 이때 토큰의 제목인 subject는 “user@email.com”라는 값을 사용한다. |
when | 토큰 제공자의 getAuthentication() 메서드를 호출해 인증 객체를 반환받는다. |
then | 반환받은 인증 객체의 유저 이름을 가져와 given절에서 설정한 subject값인 “user@email.com”과 같은지 확인한다. |
◦
getUserId() 메서드는 토큰 기반으로 유저 ID를 가져오는 메서드를 테스트하는 메서드이다. 토큰을 프로퍼티즈 파일에 저장한 비밀값으로 복호화한 뒤 클레임을 가져오는 private 메서드인 getClaims()를 호출해서 클레임 정보를 반환받아 클레임에서 id 키로 저장된 값을 가져와 반환한다.
// getUserId() 검증 테스트
@DisplayName("getUserId(): 토큰으로 유저 ID를 가져올 수 있다.")
@Test
void getUserId() {
// given
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
// when
Long userIdByToken = tokenProvider.getUserId(token);
// then
assertThat(userIdByToken).isEqualTo(userId);
}
Java
복사
given | jjwt 라이브러리를 사용해 토큰을 생성한다. 이때 클레임을 추가한다. 키는 “id”, 값은 1이라는 유저 ID 이다. |
when | 토큰 제공자의 getUserId() 메서드를 호출해 유저 ID를 반환받는다. |
then | 반환받은 유저 ID가 given 절에서 설정한 유저 ID값인 1과 같은지 확인한다. |
◦
테스트 결과 확인
리프레시 토큰 도메인 구현하기
•
리프레시 토큰 도메인
◦
리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔티티와 리포지터리를 추가해야 한다. 만들 엔티티와 매핑되는 테이블 구조는 다음과 같다.
Table
Search
package com.example.msblog.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;
}
}
Java
복사
•
리포지토리 추가
package com.example.msblog.repository;
import com.example.msblog.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);
}
Java
복사
토큰 필터 구현
토큰 필터는 실제로 각종 요청을 처리하기 위한 로직으로 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다. 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 콘텍스트 홀더(security context holder)에 인증 정보를 저장한다.
시큐리티 컨텍스트(security context)는 인증 객체가 저장되는 보관소이며 여기서 인증 정보가 필요할 때 언제든지 인증 객체를 사용할 수 있다. 이 클래스는 스레드마다 공간을 할당하는 즉, 스레드 로컬(thread local)에 저장되므로 코드의 아무 곳에서나 참조할 수 있고, 다른 스레드와 공유하지 않으므로 독립적으로 사용할 수 있다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더(security context holder)이다.
•
config > TokenAuthenticationFilter 생성
◦
이 필터는 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤 액세스 토큰이 유효하다면 인증 정보를 설정한다.
◦
요청 헤더에서 키가 ‘Authorization’인 필드의 값을 가져온 다음 토큰의 접두사 Bearer를 제외한 값을 얻는다. 만약 값이 null이거나 Bearer로 시작하지 않으면 null을 반환한다. 이어서 가져온 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정한다. 코드가 실행되면서 인증 정보가 설정된 이후에 컨텍스트 홀더에서 getAuthentication() 메서드를 사용해 인증 정보를 가져오면 유저 객체가 반환된다. 유저 객체에는 유저 이름(username)과 권한 목록(authorities)과 같은 인증 정보가 포함된다.
package com.example.msblog.config;
import com.example.msblog.config.jwt.TokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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;
}
}
Java
복사
토큰 API 구현하기
리프레시 토큰을 전달받아 검증하고, 유효한 리프레시 토큰이면 새로운 액세스 토큰을 생성하는 토큰 API를 구현한다.
•
토큰 서비스 추가 - 리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스를 생성
// UserService.java에 추가
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
Java
복사
•
새로 만들어 전달받은 리프레시 토큰으로 리프레시 토큰 객체를 검색하여 전달하는 findByRefreshToken() 메서드를 구현
package com.example.msblog.service;
import com.example.msblog.domain.RefreshToken;
import com.example.msblog.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
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"));
}
}
Java
복사
•
토큰 서비스 클래스 생성
◦
createNewAccessToken() 메서드는 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰인 때 리프레시 토큰으로 사용자 ID를 찾는다. 마지막으로는 사용자 ID로 사용자를 찾은 후에 토큰 제공자의 generateToken() 메서드를 호출해서 새로운 액세스 토큰을 생성한다.
package com.example.msblog.service;
import com.example.msblog.config.jwt.TokenProvider;
import com.example.msblog.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.parameters.P;
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));
}
}
Java
복사
•
컨트롤러 추가하기 - 토큰을 발급받는 API 작성
package com.example.msblog.controller;
import com.example.msblog.dto.CreateAccessTokenRequest;
import com.example.msblog.dto.CreateAccessTokenResponse;
import com.example.msblog.service.TokenService;
import lombok.RequiredArgsConstructor;
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));
}
}
Java
복사
•
테스트 코드 작성
given | 테스트 유저를 생성하고, jjwt 라이브러리를 이용해 리프레시 토큰을 만들어 데이터베이스에 저장한다. 토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성한다. |
when | 토큰 추가 API에 요청을 보낸다. 이때 요청 타입은 JSON이며, given절에서 미리 만들어둔 객체를 요청 본문으로 함께 보낸다. |
then | 응답 코드가 201 Created인지 확인하고 응답으로 온 액세스 토큰이 비어 있는지 여부를 확인한다. |
package com.example.msblog.controller;
import com.example.msblog.config.jwt.JwtFactory;
import com.example.msblog.config.jwt.JwtProperties;
import com.example.msblog.domain.RefreshToken;
import com.example.msblog.domain.User;
import com.example.msblog.dto.CreateAccessTokenRequest;
import com.example.msblog.repository.RefreshTokenRepository;
import com.example.msblog.repository.UserRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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
protected 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@gmail.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());
}
}
Java
복사
•
트러블 슈팅
"msg" : "Base64-encoded key bytes may only be specified for HMAC signatures. If using RSA or Elliptic Curve, user the signWith(SignatureAlgorithm, Key) method instead." .
Java
복사
비밀 키를 사용하고 있으므로 "HS" 가 있는 HMAC 알고리즘을 사용해야 하기에 HS256, HS523, HS384를 사용해야 한다.
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()). // ES256 -> HS256 으로 수정
.compact();
}
// JWT 토큰 생성 메서드
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 typ : JWT
// 내용 iss : codesche@gmail.com(yml 파일에서 설정한 값)
.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()) // ES256 -> HS256 으로 수정
.compact();
}
Java
복사
JWT 토큰 내용 정리
•
토큰 기반 인증 은 인증에 토큰을 사용하는 방식이다. 토큰은 클라이언트를 구분하는 데 사용하는 유일한 값으로서 서버에서 생성하여 클라이언트에게 제공한 뒤, 클라이언트는 서버에 요청할 때마다 요청 내용과 함께 토큰을 전송한다. 서버에서는 토큰으로 유효한 사용자인지 검증한다.
•
JWT는 토큰 기반 인증에서 주로 사용하는 토큰이다. JSON 형식으로 사용자(클라이언트)의 정보를 저장한다. JWT는 헤더, 내용, 서명 구조로 이루어져 있다. 헤더는 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 포함하고, 정보에는 토큰에 담을 정보가 들어간다. 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 서명을 사용한다.
•
리프레시 토큰은 액세스 토큰과 별개의 토큰이다. 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급받는 용도로 사용한다.
•
필터는 실제로 요청이 전달되기 전과 후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다.
•
시큐리티 콘텍스트는 인증 객체가 저장되는 보관소로, 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내어 사용하도록 제공되는 클래스이다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더이다.