diff --git a/readme.md b/readme.md index 00811d9..e26a431 100644 --- a/readme.md +++ b/readme.md @@ -87,7 +87,7 @@ Tracking known work that is not yet implemented. Each item is independently acti ### Security -- **Switch JWT signing from HS256 → EdDSA (Ed25519).** Today Auth and Core share a symmetric `JWT_SECRET`, which means a Core compromise lets an attacker forge tokens. Move to an asymmetric keypair: Auth holds the private key and signs, Core only holds the public key and verifies. `jjwt 0.13` supports this via `Jwts.SIG.EdDSA`; JDK 25 has native `Ed25519` `KeyFactory`. Touches `JwtService`, `JwtConfig`, `application.yml`, `JwtServiceTest`, and the deployment env vars (`JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` replace `JWT_SECRET`). Add a `kid` header so future rotation is possible. +- ~~**Switch JWT signing from HS256 → EdDSA (Ed25519).**~~ Done. Auth signs with an Ed25519 private key (`JWT_PRIVATE_KEY`), Core verifies with the matching public key (`JWT_PUBLIC_KEY`). `kid` header (`JWT_KID`) is emitted to support future rotation. See `JwtService`, `JwtConfig`, `application.yml`, `JwtServiceTest`, and `ToBePlayed-Deployment/README.md` for the rollout / rotation procedure. - **Refresh-token rotation.** Current access tokens last 24h with no revocation path. Split into a short-lived (5–15 min) access token and a long-lived (7–30 day) refresh token, rotated on every use, persisted server-side so logout / password change / "log out everywhere" actually revoke. Add a `token_version` column on `users` (or a `jti` blacklist table) checked on each refresh. - **Move JWT delivery to an httpOnly `Secure` cookie.** Tokens are currently returned in the response body and stored by the frontend in `localStorage`, exposing them to any XSS payload. Set the cookie server-side, scope it to `.tbp.andreas-dahm.eu`, and add CSRF protection (Spring Security's `CookieCsrfTokenRepository.withHttpOnlyFalse()`). - **Tighten CORS for cookie auth.** Restrict allowed origins to `https://tbp.andreas-dahm.eu` (plus the local dev origin) and set `allowCredentials=true`. Required before switching to cookie-based JWT. diff --git a/src/main/java/auth_service/configuration/security/JwtConfig.java b/src/main/java/auth_service/configuration/security/JwtConfig.java index bc06879..cbadfe9 100644 --- a/src/main/java/auth_service/configuration/security/JwtConfig.java +++ b/src/main/java/auth_service/configuration/security/JwtConfig.java @@ -11,5 +11,12 @@ import org.springframework.context.annotation.Configuration; @ConfigurationProperties(prefix = "security.jwt") public class JwtConfig { - private String secret; + /** Base64-encoded PKCS8 DER Ed25519 private key. Used to sign JWTs. */ + private String privateKey; + + /** Base64-encoded X.509 DER Ed25519 public key. Embedded so JWKS can publish it. */ + private String publicKey; + + /** Key identifier set in the JWS `kid` header. Enables future rotation. */ + private String kid; } diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 246213e..17a146c 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -2,35 +2,46 @@ package auth_service.configuration.security; import auth_service.modules.login.service.TokenGenerator; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Service; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; import java.util.Date; /** * Service responsible for generating and validating JSON Web Tokens (JWT). + * Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies. */ @Service public class JwtService implements TokenGenerator { // Token validity: 24 hours in milliseconds private static final long JWT_EXPIRATION_MS = 86400000; + private static final String ED25519 = "Ed25519"; + private final JwtConfig jwtConfig; + private PrivateKey privateKey; + private PublicKey publicKey; public JwtService(JwtConfig jwtConfig) { this.jwtConfig = jwtConfig; } - /** - * Generates a secure JWT for the authenticated user. - * - * @return A signed JWT string - */ + @PostConstruct + void init() { + this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey()); + this.publicKey = decodePublicKey(jwtConfig.getPublicKey()); + } + @Override public String generateToken(String userId, String email, Long steamId) { var builder = Jwts.builder() + .header().keyId(jwtConfig.getKid()).and() .subject(email) .claim("userId", userId) .issuedAt(new Date(System.currentTimeMillis())) @@ -41,13 +52,13 @@ public class JwtService implements TokenGenerator { } return builder - .signWith(getSigningKey()) + .signWith(privateKey, Jwts.SIG.EdDSA) .compact(); } public String extractEmail(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -56,7 +67,7 @@ public class JwtService implements TokenGenerator { public String extractUserId(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -65,7 +76,7 @@ public class JwtService implements TokenGenerator { public Long extractSteamId(String token) { return Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token) .getPayload() @@ -75,7 +86,7 @@ public class JwtService implements TokenGenerator { public boolean isTokenValid(String token) { try { Jwts.parser() - .verifyWith(getSigningKey()) + .verifyWith(publicKey) .build() .parseSignedClaims(token); return true; @@ -84,12 +95,21 @@ public class JwtService implements TokenGenerator { } } - /** - * Retrieves the cryptographic key used for signing the token. - */ - private SecretKey getSigningKey() { - String secretKey = jwtConfig.getSecret(); - byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); + private static PrivateKey decodePrivateKey(String base64Pkcs8Der) { + try { + byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der); + return KeyFactory.getInstance(ED25519).generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to decode Ed25519 private key", e); + } + } + + private static PublicKey decodePublicKey(String base64X509Der) { + try { + byte[] keyBytes = Base64.getDecoder().decode(base64X509Der); + return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to decode Ed25519 public key", e); + } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e104ca2..c62e9a5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,7 +44,9 @@ management: # Security stuff security: jwt: - secret: ${JWT_SECRET} + private-key: ${JWT_PRIVATE_KEY} + public-key: ${JWT_PUBLIC_KEY} + kid: ${JWT_KID:tbp-auth-1} # Custom properties for CORS app: diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index b7c44c9..7a01a43 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -1,109 +1,109 @@ -package auth_service.configuration.security; - -import auth_service.shared.test.AbstractUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.when; - -/** - * Tests for JwtService. - */ -class JwtServiceTest extends AbstractUnitTest { - - @Mock - private JwtConfig jwtConfig; - - private JwtService jwtService; - - @BeforeEach - void setUp() { - when(jwtConfig.getSecret()).thenReturn("verysecretkeythatmustbeatleast32characterslongforhmacsha"); - jwtService = new JwtService(jwtConfig); - } - - @Test - void generateToken_Success() { - // Arrange - var userId = "0815"; - var email = "test@example.com"; - - // Act - var token = jwtService.generateToken(userId, email, null); - - // Assert - assertNotNull(token); - assertFalse(token.isEmpty()); - } - - @Test - void extractEmail_Success() { - // Arrange - var userId = "0815"; - var email = "test@example.com"; - var token = jwtService.generateToken(userId, email, null); - - // Act - var extractedEmail = jwtService.extractEmail(token); - - // Assert - assertEquals(email, extractedEmail); - } - - @Test - void extractUserId_Success() { - // Arrange - var userId = "0815"; - var email = "test@example.com"; - var token = jwtService.generateToken(userId, email, null); - - // Act - var extractedUserId = jwtService.extractUserId(token); - - // Assert - assertEquals(userId, extractedUserId); - } - - @Test - void extractSteamId_Success() { - // Arrange - var userId = "0815"; - var email = "test@example.com"; - var steamId = 123456789L; - var token = jwtService.generateToken(userId, email, steamId); - - // Act - var extractedSteamId = jwtService.extractSteamId(token); - - // Assert - assertEquals(steamId, extractedSteamId); - } - - @Test - void isTokenValid_Success() { - // Arrange - var userId = "0815"; - var email = "test@example.com"; - var token = jwtService.generateToken(userId, email, null); - - // Act - var isValid = jwtService.isTokenValid(token); - - // Assert - assertTrue(isValid); - } - - @Test - void isTokenValid_Failure() { - // Arrange - var invalidToken = "invalid-token"; - - // Act - var isValid = jwtService.isTokenValid(invalidToken); - - // Assert - assertFalse(isValid); - } -} +package auth_service.configuration.security; + +import auth_service.shared.test.AbstractUnitTest; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; + +/** + * Tests for JwtService (EdDSA / Ed25519). + */ +class JwtServiceTest extends AbstractUnitTest { + + private static final String KID = "test-key-1"; + + @Mock + private JwtConfig jwtConfig; + + private JwtService jwtService; + private PublicKey publicKey; + + @BeforeEach + void setUp() throws Exception { + KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String privateB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + + lenient().when(jwtConfig.getPrivateKey()).thenReturn(privateB64); + lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64); + lenient().when(jwtConfig.getKid()).thenReturn(KID); + + publicKey = KeyFactory.getInstance("Ed25519") + .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64))); + + jwtService = new JwtService(jwtConfig); + jwtService.init(); + } + + @Test + void generateToken_Success() { + var token = jwtService.generateToken("0815", "test@example.com", null); + + assertNotNull(token); + assertFalse(token.isEmpty()); + } + + @Test + void generateToken_setsKidHeader() { + var token = jwtService.generateToken("0815", "test@example.com", null); + + var header = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getHeader(); + + assertEquals(KID, header.getKeyId()); + assertEquals("EdDSA", header.getAlgorithm()); + } + + @Test + void extractEmail_Success() { + var email = "test@example.com"; + var token = jwtService.generateToken("0815", email, null); + + assertEquals(email, jwtService.extractEmail(token)); + } + + @Test + void extractUserId_Success() { + var userId = "0815"; + var token = jwtService.generateToken(userId, "test@example.com", null); + + assertEquals(userId, jwtService.extractUserId(token)); + } + + @Test + void extractSteamId_Success() { + var steamId = 123456789L; + var token = jwtService.generateToken("0815", "test@example.com", steamId); + + assertEquals(steamId, jwtService.extractSteamId(token)); + } + + @Test + void isTokenValid_Success() { + var token = jwtService.generateToken("0815", "test@example.com", null); + + assertTrue(jwtService.isTokenValid(token)); + } + + @Test + void isTokenValid_Failure() { + assertFalse(jwtService.isTokenValid("invalid-token")); + } +}