Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] : JWT 토큰 생성 및 검증 #61

Merged
merged 18 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/

### yml file ###
/src/main/resources/application-jwt.yml
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
59 changes: 59 additions & 0 deletions src/main/java/com/tasksprints/auction/common/jwt/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/tasksprints/auction/common/jwt/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/tasksprints/auction/common/util/TimeUtil.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ spring:
mode: HTML
encoding: UTF-8
cache: false
profiles:
include:
-jwt

springdoc:
api-docs:
Expand Down
114 changes: 114 additions & 0 deletions src/test/java/com/tasksprints/auction/common/jwt/JwtProviderTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}