diff --git a/spec/README.md b/spec/README.md index 083ccfa..f071a71 100644 --- a/spec/README.md +++ b/spec/README.md @@ -447,4 +447,4 @@ No additional parameters required `GET /api/tags` -No authentication required, returns a [List of Tags](#list-of-tags) \ No newline at end of file +# No authentication required, returns a [List of Tags](#list-of-tags) \ No newline at end of file diff --git a/src/main/java/io/github/raeperd/realworld/domain/User.java b/src/main/java/io/github/raeperd/realworld/domain/User.java index fc82006..209d799 100644 --- a/src/main/java/io/github/raeperd/realworld/domain/User.java +++ b/src/main/java/io/github/raeperd/realworld/domain/User.java @@ -28,6 +28,10 @@ private User(String username, String email, String password) { protected User() { } + public long getId() { + return id; + } + public String getEmail() { return email; } diff --git a/src/main/java/io/github/raeperd/realworld/domain/jwt/Base64URL.java b/src/main/java/io/github/raeperd/realworld/domain/jwt/Base64URL.java new file mode 100644 index 0000000..7d01d2e --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/jwt/Base64URL.java @@ -0,0 +1,19 @@ +package io.github.raeperd.realworld.domain.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +class Base64URL { + + private Base64URL() { + } + + public static String encodeFromString(String rawString) { + return encodeFromBytes(rawString.getBytes(StandardCharsets.UTF_8)); + } + + public static String encodeFromBytes(byte[] rawBytes) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(rawBytes); + } +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/jwt/HS256JWTService.java b/src/main/java/io/github/raeperd/realworld/domain/jwt/HS256JWTService.java new file mode 100644 index 0000000..b8d00b0 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/jwt/HS256JWTService.java @@ -0,0 +1,33 @@ +package io.github.raeperd.realworld.domain.jwt; + +import io.github.raeperd.realworld.domain.User; + +import java.nio.charset.StandardCharsets; + +import static java.time.Instant.now; + +class HS256JWTService implements JWTService { + + private static final String BASE64URL_ENCODED_HEADER = Base64URL.encodeFromString("{\"alg\":\"HS256\",\"type\":\"JWT\"}"); + + private final byte[] secret; + private final long durationSeconds; + + HS256JWTService(String secret, long durationSeconds) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.durationSeconds = durationSeconds; + } + + @Override + public String generateTokenFromUser(User user) { + final var messageToSign = BASE64URL_ENCODED_HEADER + "." + base64EncodedPayLoadFromUser(user); + final var signature = HmacSHA256.sign(secret, messageToSign); + return messageToSign + "." + Base64URL.encodeFromBytes(signature); + } + + private String base64EncodedPayLoadFromUser(User user) { + return Base64URL.encodeFromString( + JWTPayload.fromUser(user, now().getEpochSecond() + durationSeconds).toString()); + } + +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256.java b/src/main/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256.java new file mode 100644 index 0000000..85c96b7 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256.java @@ -0,0 +1,31 @@ +package io.github.raeperd.realworld.domain.jwt; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +class HmacSHA256 { + + private static final String ALGORITHM = "HmacSHA256"; + + private HmacSHA256() { + } + + public static byte[] sign(byte[] secret, String message) { + try { + final var hmacSHA256 = Mac.getInstance(ALGORITHM); + hmacSHA256.init(new SecretKeySpec(secret, ALGORITHM)); + return hmacSHA256.doFinal(message.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException | IllegalArgumentException | InvalidKeyException exception) { + throw new HmacSHA256SignFailedException(exception); + } + } + + private static class HmacSHA256SignFailedException extends RuntimeException { + public HmacSHA256SignFailedException(Throwable cause) { + super(cause); + } + } +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTPayload.java b/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTPayload.java new file mode 100644 index 0000000..90aca24 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTPayload.java @@ -0,0 +1,27 @@ +package io.github.raeperd.realworld.domain.jwt; + +import io.github.raeperd.realworld.domain.User; + +import static java.lang.String.format; + +class JWTPayload { + + private final long sub; + private final String name; + private final long iat; + + static JWTPayload fromUser(User user, long expireEpochSecond) { + return new JWTPayload(user.getId(), user.getUsername(), expireEpochSecond); + } + + JWTPayload(long sub, String name, long iat) { + this.sub = sub; + this.name = name; + this.iat = iat; + } + + @Override + public String toString() { + return format("{\"sub\":%d,\"name\":\"%s\",\"iat\":%d}", sub, name, iat); + } +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTService.java b/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTService.java new file mode 100644 index 0000000..772a010 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/jwt/JWTService.java @@ -0,0 +1,9 @@ +package io.github.raeperd.realworld.domain.jwt; + +import io.github.raeperd.realworld.domain.User; + +public interface JWTService { + + String generateTokenFromUser(User user); + +} diff --git a/src/test/java/io/github/raeperd/realworld/domain/jwt/Base64URLTest.java b/src/test/java/io/github/raeperd/realworld/domain/jwt/Base64URLTest.java new file mode 100644 index 0000000..d26b851 --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/domain/jwt/Base64URLTest.java @@ -0,0 +1,14 @@ +package io.github.raeperd.realworld.domain.jwt; + +import org.junit.jupiter.api.Test; + +import static io.github.raeperd.realworld.domain.jwt.Base64URL.encodeFromString; +import static org.assertj.core.api.Assertions.assertThat; + +class Base64URLTest { + + @Test + void when_encode_return_expected_string() { + assertThat(encodeFromString("something")).isEqualTo("c29tZXRoaW5n"); + } +} \ No newline at end of file diff --git a/src/test/java/io/github/raeperd/realworld/domain/jwt/HS256JWTServiceTest.java b/src/test/java/io/github/raeperd/realworld/domain/jwt/HS256JWTServiceTest.java new file mode 100644 index 0000000..9dd1bd6 --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/domain/jwt/HS256JWTServiceTest.java @@ -0,0 +1,37 @@ +package io.github.raeperd.realworld.domain.jwt; + +import io.github.raeperd.realworld.domain.User; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static io.github.raeperd.realworld.domain.User.createNewUser; +import static org.assertj.core.api.Assertions.assertThat; + +class HS256JWTServiceTest { + + private static final String SECRET = "SOME_SECRET"; + + private final JWTService jwtService = new HS256JWTService(SECRET, 1000); + + private final User user = createNewUser("user", "user@email.com", "password"); + + @Test + void when_generateToken_expect_result_startsWith_encodedHeader() { + final var token = jwtService.generateTokenFromUser(user); + + assertThat(token).startsWith(Base64URL.encodeFromString("{\"alg\":\"HS256\",\"type\":\"JWT\"}")); + } + + @Test + void when_generateToken_return_value_can_be_verified() { + final var token = jwtService.generateTokenFromUser(user); + final var indexOfSignature = token.lastIndexOf('.') + 1; + + final var message = token.substring(0, indexOfSignature - 1); + final var signature = token.substring(indexOfSignature); + + assertThat(Base64URL.encodeFromBytes(HmacSHA256.sign(SECRET.getBytes(StandardCharsets.UTF_8), message))) + .isEqualTo(signature); + } +} \ No newline at end of file diff --git a/src/test/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256Test.java b/src/test/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256Test.java new file mode 100644 index 0000000..b3bfbed --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/domain/jwt/HmacSHA256Test.java @@ -0,0 +1,25 @@ +package io.github.raeperd.realworld.domain.jwt; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HmacSHA256Test { + + @Test + void when_invalid_secret_expect_throw_exception() { + assertThatThrownBy( + () -> HmacSHA256.sign(null, "test") + ).isInstanceOf(RuntimeException.class); + } + + @Test + void when_sign_expect_matched_return() { + assertThat(HmacSHA256.sign("secret".getBytes(StandardCharsets.UTF_8), "plain")) + .asHexString() + .isEqualTo("A237566E044B73E6A1E54BD59974547487FA5F8143025CE0D04D82E7EE4C5E34"); + } +} \ No newline at end of file