diff --git a/.gitignore b/.gitignore index c2065bc2..8daee5e0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### yml file ### +/src/main/resources/application-jwt.yml diff --git a/build.gradle b/build.gradle index 8b098edb..e19852eb 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,10 @@ dependencies { //thymeleaf implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + //jjwt + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' } tasks.named('test') { diff --git a/src/main/java/com/tasksprints/auction/common/config/ClockConfig.java b/src/main/java/com/tasksprints/auction/common/config/ClockConfig.java new file mode 100644 index 00000000..1757f93a --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/config/ClockConfig.java @@ -0,0 +1,14 @@ +package com.tasksprints.auction.common.config; + +import java.time.Clock; +import java.time.ZoneId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/seoul")); + } +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java b/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java new file mode 100644 index 00000000..e87ce1d1 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/JwtProperties.java @@ -0,0 +1,27 @@ +package com.tasksprints.auction.common.jwt; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Getter +public class JwtProperties { + @Value("${jwt.header}") + private String header; + + @Value("${jwt.prefix}") + private String prefix; + + @Value("${jwt.expire-ms}") + private Long expireMs; + + @Value("${jwt.expire-ms}") + private Long refreshExpireMs; + + @Value("${jwt.issuer}") + private String issuer; + + @Value("${jwt.secret}") + private String secretKey; +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java b/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java new file mode 100644 index 00000000..033e8ead --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java @@ -0,0 +1,59 @@ +package com.tasksprints.auction.common.jwt; + +import static com.tasksprints.auction.common.util.TimeUtil.*; + +import com.tasksprints.auction.common.jwt.dto.response.JwtResponse; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Clock; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtProperties jwtProperties; + private final Clock clock; + + public JwtResponse generateToken(Long userId, String userRole) { + return JwtResponse.of(createAccessToken(userId, userRole), createRefreshToken()); + } + + public String createAccessToken(Long userId, String userRole) { + + Date now = localDateTimeToDate(LocalDateTime.now(clock)); + + return Jwts.builder().setIssuer(jwtProperties.getIssuer()).claim("userId", userId).claim("userRole", userRole) + .setIssuedAt(now).setExpiration(new Date(now.getTime() + jwtProperties.getExpireMs())) + .signWith(SignatureAlgorithm.HS256, JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())).compact(); + } + + public String createRefreshToken() { + + Date now = localDateTimeToDate(LocalDateTime.now(clock)); + + return Jwts.builder().setIssuer(jwtProperties.getIssuer()).setIssuedAt(now) + .setExpiration(new Date(now.getTime() + jwtProperties.getRefreshExpireMs())) + .signWith(SignatureAlgorithm.HS256, JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())).compact(); + } + + public boolean verifyToken(String token) { + + Date now = localDateTimeToDate(LocalDateTime.now(clock)); + + Claims claims = getClaims(token); + + return !claims.getExpiration().before(now); + } + + public Claims getClaims(String token) { + return Jwts.parser().setSigningKey(JwtUtil.encodeSecretKey(jwtProperties.getSecretKey())) + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/JwtUtil.java b/src/main/java/com/tasksprints/auction/common/jwt/JwtUtil.java new file mode 100644 index 00000000..4704d489 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/JwtUtil.java @@ -0,0 +1,13 @@ +package com.tasksprints.auction.common.jwt; + +import java.nio.charset.StandardCharsets; + +public class JwtUtil { + + /** + * secret-key 를 인코딩 합니다. + * */ + public static byte[] encodeSecretKey(String secretKey) { + return secretKey.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java b/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java new file mode 100644 index 00000000..1d209eca --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/jwt/dto/response/JwtResponse.java @@ -0,0 +1,19 @@ +package com.tasksprints.auction.common.jwt.dto.response; + +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JwtResponse { + private String refreshToken; + private String accessToken; + + public static JwtResponse of(String accessToken, String refreshToken) { + return JwtResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/tasksprints/auction/common/util/TimeUtil.java b/src/main/java/com/tasksprints/auction/common/util/TimeUtil.java new file mode 100644 index 00000000..6da05171 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/common/util/TimeUtil.java @@ -0,0 +1,13 @@ +package com.tasksprints.auction.common.util; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public class TimeUtil { + private static final String zoneId = "Asia/Seoul"; + + public static Date localDateTimeToDate(LocalDateTime localDateTime) { + return Date.from(localDateTime.atZone(ZoneId.of(zoneId)).toInstant()); + } +} diff --git a/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java b/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java new file mode 100644 index 00000000..13d8e420 --- /dev/null +++ b/src/main/java/com/tasksprints/auction/domain/user/model/UserRole.java @@ -0,0 +1,13 @@ +package com.tasksprints.auction.domain.user.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserRole { + ADMIN("admin"), + USER("user"); + + private final String userRole; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a6bb2474..99934123 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,9 @@ spring: mode: HTML encoding: UTF-8 cache: false + profiles: + include: + -jwt springdoc: api-docs: diff --git a/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java b/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java new file mode 100644 index 00000000..ea1bafc5 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java @@ -0,0 +1,114 @@ +package com.tasksprints.auction.common.jwt; + +import com.tasksprints.auction.common.jwt.dto.response.JwtResponse; +import io.jsonwebtoken.ExpiredJwtException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtProviderTest { + @Mock + private JwtProperties jwtProperties; + @Mock + private Clock clock; + @InjectMocks + private JwtProvider jwtProvider; + + private final Long VALID_EXPIRE_MS = 36000000L; + private final Long REFRESH_EXPIRE_MS = 72000000L; + private final Long EXPIRED_EXPIRE_MS = 0L; + private final String ISSUER = "testIssuer"; + private final String SECRET_KEY = "testSecretKey"; + private final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + @BeforeEach + public void setUp() { + when(clock.instant()).thenReturn(Instant.now()); + when(clock.getZone()).thenReturn(ZONE_ID); + when(jwtProperties.getIssuer()).thenReturn(ISSUER); + when(jwtProperties.getSecretKey()).thenReturn(SECRET_KEY); + } + + private void stubAccessTokenExpiration(Long expireMs) { + when(jwtProperties.getExpireMs()).thenReturn(expireMs); + } + + private void stubRefreshTokenExpiration(Long expireMs) { + when(jwtProperties.getRefreshExpireMs()).thenReturn(expireMs); + } + + @Test + @DisplayName("token generator 을 통한 access token, refresh token 발급 테스트") + void generateToken() { + JwtResponse jwtResponse = jwtProvider.generateToken(1L, "admin"); + + assertNotNull(jwtResponse.getAccessToken(), "access token 이 발급되어야 합니다."); + assertNotNull(jwtResponse.getRefreshToken(), "refresh token 이 발급되어야 합니다."); + } + + @Test + @DisplayName("access token 발급 테스트") + void createAccessToken() { + stubAccessTokenExpiration(VALID_EXPIRE_MS); + + String token = jwtProvider.createAccessToken(1L, "admin"); + + assertNotNull(token, "access token 이 발급되어야 합니다."); + } + + @Test + @DisplayName("refresh token 발급 테스트") + void createRefreshToken() { + stubRefreshTokenExpiration(REFRESH_EXPIRE_MS); + String token = jwtProvider.createRefreshToken(); + assertNotNull(token, "refresh token 이 발급되어야 합니다."); + } + + @Test + @DisplayName("유효한 토큰 테스트") + void verifyToken_valid() { + stubAccessTokenExpiration(VALID_EXPIRE_MS); + + String token = jwtProvider.createAccessToken(1L, "admin"); + + Assertions.assertTrue(jwtProvider.verifyToken(token)); + } + + @Test + @DisplayName("만료된 토큰 테스트") + void verifyToken_expired() { + stubAccessTokenExpiration(EXPIRED_EXPIRE_MS); + + String token = jwtProvider.createAccessToken(1L, "admin"); + + Assertions.assertThrows(ExpiredJwtException.class, () -> { + jwtProvider.verifyToken(token); + }, "토큰이 즉시 만료되어야 합니다."); + } + + @Test + @DisplayName("디코딩 된 페이로드 정확성 테스트") + void getClaims() { + stubAccessTokenExpiration(VALID_EXPIRE_MS); + + String token = jwtProvider.createAccessToken(1L, "admin"); + Long decodedUserId = jwtProvider.getClaims(token).get("userId", Long.class); + String decodedUserRole = jwtProvider.getClaims(token).get("userRole", String.class); + + assertThat(decodedUserId).isEqualTo(1L); + assertThat(decodedUserRole).isEqualTo("admin"); + } +} diff --git a/src/test/java/com/tasksprints/auction/common/util/TimeUtilTest.java b/src/test/java/com/tasksprints/auction/common/util/TimeUtilTest.java new file mode 100644 index 00000000..990213b6 --- /dev/null +++ b/src/test/java/com/tasksprints/auction/common/util/TimeUtilTest.java @@ -0,0 +1,43 @@ +package com.tasksprints.auction.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TimeUtilTest { + private final ZoneId zoneId = ZoneId.of("Asia/Seoul"); + private final Instant fixedInstant = Instant.parse("2024-10-22T10:00:00Z"); + @Mock + private Clock clock; + + @BeforeEach + void setUp() { + when(clock.instant()).thenReturn(fixedInstant); + when(clock.getZone()).thenReturn(zoneId); + } + + @Test + @DisplayName("LocalDateTime -> Date 변환 테스트") + void localDateTimeToDate() { + // given + LocalDateTime localDateTime = LocalDateTime.now(clock); + + // when + Date date = TimeUtil.localDateTimeToDate(localDateTime); + + // then + assertEquals(localDateTime.atZone(zoneId).toInstant(), date.toInstant()); + } +}