diff --git a/domain/src/main/java/sopt/makers/authentication/user/Activity.java b/domain/src/main/java/sopt/makers/authentication/user/Activity.java index 5632e02..ce5d514 100644 --- a/domain/src/main/java/sopt/makers/authentication/user/Activity.java +++ b/domain/src/main/java/sopt/makers/authentication/user/Activity.java @@ -2,17 +2,17 @@ public record Activity(int generation, Team team, Part part, Role role) { - public Activity(int generation, final Team team, final Part part) { - this(generation, team, part, Role.MEMBER); - } + public Activity(final int generation, final Team team, final Part part) { + this(generation, team, part, Role.MEMBER); + } - public void validateActivityContentsEmpty() { - if (this.role == null) { - throw new IllegalArgumentException("활동 정보가 비어있습니다."); - } + public void validateActivityContentsEmpty() { + if (this.role == null) { + throw new IllegalArgumentException("활동 정보가 비어있습니다."); + } - if (this.role.isPartRequired() && this.part == null) { - throw new IllegalArgumentException("해당 Role은 part 필드가 필수입니다."); - } - } + if (this.role.isPartRequired() && this.part == null) { + throw new IllegalArgumentException("해당 Role은 part 필드가 필수입니다."); + } + } } diff --git a/support/build.gradle.kts b/support/build.gradle.kts index 996663b..00b21be 100644 --- a/support/build.gradle.kts +++ b/support/build.gradle.kts @@ -12,6 +12,9 @@ dependencies { implementation ("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") + //JWT & Nimbus + implementation ("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + //test testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/support/src/main/java/sopt/makers/authentication/support/util/code/failure/TokenFailure.java b/support/src/main/java/sopt/makers/authentication/support/util/code/failure/TokenFailure.java index a450e9f..1b00cb1 100644 --- a/support/src/main/java/sopt/makers/authentication/support/util/code/failure/TokenFailure.java +++ b/support/src/main/java/sopt/makers/authentication/support/util/code/failure/TokenFailure.java @@ -1,16 +1,21 @@ package sopt.makers.authentication.support.util.code.failure; +import sopt.makers.authentication.support.common.code.Failure; + +import org.springframework.http.HttpStatus; + import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import sopt.makers.authentication.support.common.code.Failure; @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public enum TokenFailure implements Failure { - - ; - private final HttpStatus status; - private final String message; + TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "토큰이 만료되었습니다."), + UNSUPPORTED_ISSUER(HttpStatus.BAD_REQUEST, "신뢰할 수 없는 발급자입니다."), + INVALID_SUBJECT(HttpStatus.BAD_REQUEST, "주체 정보가 잘못되었습니다."), + INVALID_PREFIX(HttpStatus.BAD_REQUEST, "토큰 접두사가 잘못되었습니다."), + ; + private final HttpStatus status; + private final String message; } diff --git a/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtKeyProperties.java b/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtKeyProperties.java new file mode 100644 index 0000000..d9e858c --- /dev/null +++ b/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtKeyProperties.java @@ -0,0 +1,9 @@ +package sopt.makers.authentication.support.util.jwt; + +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "rsa") +public record JwtKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {} diff --git a/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtProvider.java b/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtProvider.java index 81efeb2..7917736 100644 --- a/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtProvider.java +++ b/support/src/main/java/sopt/makers/authentication/support/util/jwt/JwtProvider.java @@ -2,9 +2,10 @@ import java.io.IOException; -public interface JwtProvider { - String generate(final T value); +public interface JwtProvider { + String generateAccessToken(final T value); - T parse(final String token) throws IOException; + String generateRefreshToken(final T value); + T parse(final String token) throws IOException; } diff --git a/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/AuthenticationJwtProvider.java b/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/AuthenticationJwtProvider.java deleted file mode 100644 index 57b9fbc..0000000 --- a/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/AuthenticationJwtProvider.java +++ /dev/null @@ -1,22 +0,0 @@ -package sopt.makers.authentication.support.util.jwt.provider; - -import org.springframework.stereotype.Component; -import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication; -import sopt.makers.authentication.support.util.jwt.JwtProvider; - -import java.io.IOException; - -@Component -public class AuthenticationJwtProvider implements JwtProvider { - - @Override - public String generate(final CustomAuthentication value) { - return null; - } - - @Override - public CustomAuthentication parse(final String token) throws IOException { - return null; - } - -} diff --git a/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/JwtTokenProvider.java b/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/JwtTokenProvider.java new file mode 100644 index 0000000..6d827e6 --- /dev/null +++ b/support/src/main/java/sopt/makers/authentication/support/util/jwt/provider/JwtTokenProvider.java @@ -0,0 +1,136 @@ +package sopt.makers.authentication.support.util.jwt.provider; + +import static sopt.makers.authentication.support.util.code.failure.TokenFailure.TOKEN_EXPIRED; +import static sopt.makers.authentication.support.util.code.failure.TokenFailure.UNSUPPORTED_ISSUER; + +import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication; +import sopt.makers.authentication.support.util.code.failure.TokenFailure; +import sopt.makers.authentication.support.util.exception.TokenException; +import sopt.makers.authentication.support.util.jwt.JwtProvider; +import sopt.makers.authentication.support.value.JwtConstant; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements JwtProvider { + + private final JwtDecoder jwtDecoder; + private final JwtEncoder jwtEncoder; + + @Override + public String generateAccessToken(final CustomAuthentication value) { + String subject = value.getPrincipal().toString(); + String issuer = JwtConstant.ISSUER; + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(JwtConstant.ACCESS_TOKEN_EXPIRATION); + List roles = + value.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toUnmodifiableList()); + JwtClaimsSet claimsSet = generateClaimSet(subject, issuer, now, expiration); + return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet)).getTokenValue(); + } + + @Override + public String generateRefreshToken(CustomAuthentication value) { + String subject = value.getPrincipal().toString(); + String issuer = JwtConstant.ISSUER; + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(JwtConstant.REFRESH_TOKEN_EXPIRATION); + List roles = + value.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toUnmodifiableList()); + JwtClaimsSet claimsSet = generateClaimSet(subject, issuer, now, expiration); + return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet)).getTokenValue(); + } + + @Override + public CustomAuthentication parse(final String token) throws IOException { + validatePrefix(token); + String seperatedToken = separatePrefix(token); + Jwt jwt = jwtDecoder.decode(seperatedToken); + validateJwt(jwt); + return resolveAuthentication(jwt); + } + + private String separatePrefix(String token) { + return token.substring(JwtConstant.TOKEN_HEADER.length()); + } + + private void validatePrefix(String token) { + if (!token.startsWith(JwtConstant.TOKEN_HEADER)) { + throw new TokenException(TokenFailure.INVALID_PREFIX); + } + } + + public void validateJwt(final Jwt jwt) { + validateExpiration(jwt); + validateIssuer(jwt); + validateSubject(jwt); + } + + private JwtClaimsSet generateClaimSet( + String subject, String issuer, Instant issuedAt, Instant expiresAt) { + return JwtClaimsSet.builder() + .subject(subject) + .issuer(issuer) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .build(); + } + + private void validateExpiration(final Jwt jwt) { + Date expiration = Date.from(jwt.getExpiresAt()); + if (expiration == null || expiration.before(new Date())) { + throw new TokenException(TOKEN_EXPIRED); + } + } + + private void validateIssuer(final Jwt jwt) { + String issuer = jwt.getClaim(JwtClaimNames.ISS); + + Optional.ofNullable(issuer) + .filter(i -> Arrays.asList(JwtConstant.ISSUERS).contains(i)) + .orElseThrow(() -> new TokenException(UNSUPPORTED_ISSUER)); + } + + private void validateSubject(final Jwt jwt) { + // DB연결 필요 ? (논의) + } + + private CustomAuthentication resolveAuthentication(final Jwt jwt) { + String subject = jwt.getSubject(); + Long userId = Long.parseLong(subject); + List authorities = extractAuthorities(jwt); + return new CustomAuthentication(subject, authorities); + } + + private List extractAuthorities(final Jwt jwt) { + List roles = jwt.getClaim("roles"); + return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toUnmodifiableList()); + } + + private String appendTokenHeader(final String IncompleteToken) { + return JwtConstant.TOKEN_HEADER.concat(IncompleteToken); + } +} diff --git a/support/src/main/java/sopt/makers/authentication/support/value/JwtConstant.java b/support/src/main/java/sopt/makers/authentication/support/value/JwtConstant.java new file mode 100644 index 0000000..542256d --- /dev/null +++ b/support/src/main/java/sopt/makers/authentication/support/value/JwtConstant.java @@ -0,0 +1,11 @@ +package sopt.makers.authentication.support.value; + +public final class JwtConstant { + + public static final String[] ISSUERS = {"operation", "playground"}; + public static final String ISSUER = "operation"; + + public static final long ACCESS_TOKEN_EXPIRATION = 1000L * 60 * 10; + public static final long REFRESH_TOKEN_EXPIRATION = 1000L * 60 * 60 * 24 * 7; + public static final String TOKEN_HEADER = "Bearer "; +} diff --git a/support/src/test/java/sopt/makers/authentication/support/jwt/JwtDecodeTest.java b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtDecodeTest.java new file mode 100644 index 0000000..465d030 --- /dev/null +++ b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtDecodeTest.java @@ -0,0 +1,38 @@ +package sopt.makers.authentication.support.jwt; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import com.nimbusds.jwt.SignedJWT; + +@ExtendWith(MockitoExtension.class) +public class JwtDecodeTest { + + @Mock private JwtDecoder jwtDecoder; + + @Test + @DisplayName("JWT 디코딩 호출을 확인한다") + public void test() { + // Given + String testToken = "testToken"; + SignedJWT signedJWT = mock(SignedJWT.class); + Jwt jwt = JwtTokenHelper.generateJwt(); + + // When + when(jwtDecoder.decode(testToken)).thenReturn(jwt); + Jwt decoderJwt = jwtDecoder.decode(testToken); + + // Then + verify(jwtDecoder, times(1)).decode(testToken); + } +} diff --git a/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenHelper.java b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenHelper.java new file mode 100644 index 0000000..b5ea24c --- /dev/null +++ b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenHelper.java @@ -0,0 +1,83 @@ +package sopt.makers.authentication.support.jwt; + +import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication; +import sopt.makers.authentication.support.value.JwtConstant; + +import java.time.Instant; +import java.util.Date; + +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; + +public class JwtTokenHelper { + + public static Jwt generateJwt() { + + try { + RSAKey rsaKey = new RSAKeyGenerator(2048).generate(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + + Jwt jwt = + Jwt.withTokenValue("test") + .expiresAt(new Date(new Date().getTime() + 60 * 1000).toInstant()) // 1분 후 만료 + .claim("userId", "1") + .subject("testToken") + .header("TestHeader", "headerValue") + .build(); + return jwt; + } + + public static CustomAuthentication createFakeAuthentication(String userId) { + return new CustomAuthentication(userId, "password"); + } + + public static Jwt createFakeAccessTokenJwt(JwtClaimsSet claimsSet) { + String tokenValue = "mockAccessToken"; + String subject = claimsSet.getSubject(); + String issuer = JwtConstant.ISSUER; + Instant issuedAt = claimsSet.getIssuedAt(); + Instant expiresAt = claimsSet.getExpiresAt(); + Object roles = claimsSet.getClaim("roles"); + + // Jwt 객체 생성 + Jwt jwt = + Jwt.withTokenValue(tokenValue) + .header("TestHeader", "headerValue") // 헤더 설정 + .subject(subject) + .issuer(issuer) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .claim("roles", roles) + .build(); + + return jwt; + } + + public static Jwt createFakeRefreshTokenJwt(JwtClaimsSet claimsSet) { + String tokenValue = "mockRefreshToken"; // 실제 JWT 토큰 값 + String subject = claimsSet.getSubject(); // subject 추출 + String issuer = JwtConstant.ISSUER; // issuer 추출 + Instant issuedAt = claimsSet.getIssuedAt(); // issuedAt 추출 + Instant expiresAt = claimsSet.getExpiresAt(); // expiresAt 추출 + Object roles = claimsSet.getClaim("roles"); // roles 클레임 추출 + + // Jwt 객체 생성 + Jwt jwt = + Jwt.withTokenValue(tokenValue) + .header("TestHeader", "headerValue") // 헤더 설정 + .subject(subject) + .issuer(issuer) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .claim("roles", roles) + .build(); + + return jwt; + } +} diff --git a/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenProviderTest.java b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenProviderTest.java new file mode 100644 index 0000000..8945aee --- /dev/null +++ b/support/src/test/java/sopt/makers/authentication/support/jwt/JwtTokenProviderTest.java @@ -0,0 +1,148 @@ +package sopt.makers.authentication.support.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication; +import sopt.makers.authentication.support.util.exception.TokenException; +import sopt.makers.authentication.support.util.jwt.provider.JwtTokenProvider; +import sopt.makers.authentication.support.value.JwtConstant; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; + +@ExtendWith(MockitoExtension.class) +public class JwtTokenProviderTest { + + @Mock private JwtDecoder decoder; + + @Mock private JwtEncoder encoder; + + @InjectMocks private JwtTokenProvider jwtTokenProvider; + + @Nested + @DisplayName("토큰 생성 테스트") + class GenerateTokenTest { + + @Test + @DisplayName("ATK 토큰 생성 테스트") + public void generate1() { + // Given + String userId = "1"; + CustomAuthentication fakeAuthentication = JwtTokenHelper.createFakeAuthentication(userId); + + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(JwtConstant.ACCESS_TOKEN_EXPIRATION); + JwtClaimsSet claimsSet = + JwtClaimsSet.builder() + .subject(userId) + .issuer(JwtConstant.ISSUER) + .issuedAt(now) + .expiresAt(expiration) + .claim("roles", List.of("ROLE_USER")) + .build(); + + Jwt givenJwt = JwtTokenHelper.createFakeAccessTokenJwt(claimsSet); + JwtEncoderParameters encoderParameters = JwtEncoderParameters.from(claimsSet); + + // When + when(encoder.encode(any(JwtEncoderParameters.class))).thenReturn(givenJwt); + String expectedAccessToken = jwtTokenProvider.generateAccessToken(fakeAuthentication); + + // Then + assertNotNull(expectedAccessToken); + assertThat(expectedAccessToken).isEqualTo("mockAccessToken"); + } + + @Test + @DisplayName("RTK 토큰 생성 테스트") + public void test2() { + // Given + String userId = "1"; + CustomAuthentication fakeAuthentication = JwtTokenHelper.createFakeAuthentication(userId); + + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(JwtConstant.REFRESH_TOKEN_EXPIRATION); + JwtClaimsSet claimsSet = + JwtClaimsSet.builder() + .subject(userId) + .issuer(JwtConstant.ISSUER) + .issuedAt(now) + .expiresAt(expiration) + .claim("roles", List.of("ROLE_USER")) + .build(); + + Jwt givenJwt = JwtTokenHelper.createFakeRefreshTokenJwt(claimsSet); + JwtEncoderParameters encoderParameters = JwtEncoderParameters.from(claimsSet); + + // When + when(encoder.encode(any(JwtEncoderParameters.class))).thenReturn(givenJwt); + String expectedRefreshToken = jwtTokenProvider.generateRefreshToken(fakeAuthentication); + + // Then + assertNotNull(expectedRefreshToken); + assertThat(expectedRefreshToken).isEqualTo("mockRefreshToken"); + } + } + + @Nested + @DisplayName("토큰 검증 테스트") + public class ValidateTokenTest { + + @Test + @DisplayName("토큰은 Bearer로 시작해야 한다.") + public void test1() { + // Given + String userId = "1"; + String token = "Bearer mockAccessToken"; + String invalidToken = "mockAccessToken"; + CustomAuthentication fakeAuthentication = JwtTokenHelper.createFakeAuthentication(userId); + + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(JwtConstant.REFRESH_TOKEN_EXPIRATION); + + JwtClaimsSet claimsSet = + JwtClaimsSet.builder() + .subject(userId) + .issuer(JwtConstant.ISSUER) + .issuedAt(now) + .expiresAt(expiration) + .claim("roles", List.of("ROLE_USER")) + .build(); + + Jwt jwt = JwtTokenHelper.createFakeAccessTokenJwt(claimsSet); + + // When + // Then + jwtTokenProvider.validateJwt(jwt); + assertThat(token).startsWith("Bearer"); + assertThatThrownBy(() -> jwtTokenProvider.parse(invalidToken)) + .isInstanceOf(TokenException.class); + } + + // @Test + // @DisplayName("생성된 토큰은 유효해야 한다.") + + // @Test + // @DisplayName("토큰을 파싱할 수 있어야 한다.") + + // @Test + // @DisplayName("파싱된 토큰 안의 Claims에는 User 정보가 담겨 있어야 한다.") + } +}