Backend
home
🖋️

14일차

생성일
2024/08/05 02:12
태그
본 프로젝트는 “스프링부트 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과 같은지 확인한다.
테스트 결과 확인

리프레시 토큰 도메인 구현하기

리프레시 토큰 도메인
리프레시 토큰은 데이터베이스에 저장하는 정보이므로 엔티티와 리포지터리를 추가해야 한다. 만들 엔티티와 매핑되는 테이블 구조는 다음과 같다.
Search
RefreshToken
이름
자료형
null 허용
설명
BIGINT
N
유저 ID
VARCHAR(255)
N
토큰값
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 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다.
시큐리티 콘텍스트는 인증 객체가 저장되는 보관소로, 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내어 사용하도록 제공되는 클래스이다. 이러한 시큐리티 컨텍스트 객체를 저장하는 객체가 시큐리티 컨텍스트 홀더이다.