Changed from JWT token to public/privat key

This commit is contained in:
Andreas Dahm committed 2026-05-12 08:56:51 +02:00
1 parent 4859e74d45
commit 9114eb096b
5 files changed
+161 -132

No files matched your search

+1 -1
View File
@@ -87,7 +87,7 @@ Tracking known work that is not yet implemented. Each item is independently acti
### Security ### 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. - **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()`). - **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. - **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.
@@ -11,5 +11,12 @@ import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "security.jwt") @ConfigurationProperties(prefix = "security.jwt")
public class JwtConfig { 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;
} }
@@ -2,35 +2,46 @@ package auth_service.configuration.security;
import auth_service.modules.login.service.TokenGenerator; import auth_service.modules.login.service.TokenGenerator;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.crypto.SecretKey; import java.security.KeyFactory;
import java.nio.charset.StandardCharsets; 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; import java.util.Date;
/** /**
* Service responsible for generating and validating JSON Web Tokens (JWT). * 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 @Service
public class JwtService implements TokenGenerator { public class JwtService implements TokenGenerator {
// Token validity: 24 hours in milliseconds // Token validity: 24 hours in milliseconds
private static final long JWT_EXPIRATION_MS = 86400000; private static final long JWT_EXPIRATION_MS = 86400000;
private static final String ED25519 = "Ed25519";
private final JwtConfig jwtConfig; private final JwtConfig jwtConfig;
private PrivateKey privateKey;
private PublicKey publicKey;
public JwtService(JwtConfig jwtConfig) { public JwtService(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig; this.jwtConfig = jwtConfig;
} }
/** @PostConstruct
* Generates a secure JWT for the authenticated user. void init() {
* this.privateKey = decodePrivateKey(jwtConfig.getPrivateKey());
* @return A signed JWT string this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
*/ }
@Override @Override
public String generateToken(String userId, String email, Long steamId) { public String generateToken(String userId, String email, Long steamId) {
var builder = Jwts.builder() var builder = Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.subject(email) .subject(email)
.claim("userId", userId) .claim("userId", userId)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
@@ -41,13 +52,13 @@ public class JwtService implements TokenGenerator {
} }
return builder return builder
.signWith(getSigningKey()) .signWith(privateKey, Jwts.SIG.EdDSA)
.compact(); .compact();
} }
public String extractEmail(String token) { public String extractEmail(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload() .getPayload()
@@ -56,7 +67,7 @@ public class JwtService implements TokenGenerator {
public String extractUserId(String token) { public String extractUserId(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload() .getPayload()
@@ -65,7 +76,7 @@ public class JwtService implements TokenGenerator {
public Long extractSteamId(String token) { public Long extractSteamId(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload() .getPayload()
@@ -75,7 +86,7 @@ public class JwtService implements TokenGenerator {
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
Jwts.parser() Jwts.parser()
.verifyWith(getSigningKey()) .verifyWith(publicKey)
.build() .build()
.parseSignedClaims(token); .parseSignedClaims(token);
return true; return true;
@@ -84,12 +95,21 @@ public class JwtService implements TokenGenerator {
} }
} }
/** private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
* Retrieves the cryptographic key used for signing the token. try {
*/ byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
private SecretKey getSigningKey() { return KeyFactory.getInstance(ED25519).generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
String secretKey = jwtConfig.getSecret(); } catch (Exception e) {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); throw new IllegalStateException("Failed to decode Ed25519 private key", e);
return Keys.hmacShaKeyFor(keyBytes); }
}
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);
}
} }
} }
+3 -1
View File
@@ -44,7 +44,9 @@ management:
# Security stuff # Security stuff
security: security:
jwt: jwt:
secret: ${JWT_SECRET} private-key: ${JWT_PRIVATE_KEY}
public-key: ${JWT_PUBLIC_KEY}
kid: ${JWT_KID:tbp-auth-1}
# Custom properties for CORS # Custom properties for CORS
app: app:
@@ -1,109 +1,109 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach;
import org.mockito.Mock; import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when; import java.security.KeyFactory;
import java.security.KeyPair;
/** import java.security.KeyPairGenerator;
* Tests for JwtService. import java.security.PublicKey;
*/ import java.security.spec.X509EncodedKeySpec;
class JwtServiceTest extends AbstractUnitTest { import java.util.Base64;
@Mock import static org.junit.jupiter.api.Assertions.assertEquals;
private JwtConfig jwtConfig; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
private JwtService jwtService; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
@BeforeEach
void setUp() { /**
when(jwtConfig.getSecret()).thenReturn("verysecretkeythatmustbeatleast32characterslongforhmacsha"); * Tests for JwtService (EdDSA / Ed25519).
jwtService = new JwtService(jwtConfig); */
} class JwtServiceTest extends AbstractUnitTest {
@Test private static final String KID = "test-key-1";
void generateToken_Success() {
// Arrange @Mock
var userId = "0815"; private JwtConfig jwtConfig;
var email = "test@example.com";
private JwtService jwtService;
// Act private PublicKey publicKey;
var token = jwtService.generateToken(userId, email, null);
@BeforeEach
// Assert void setUp() throws Exception {
assertNotNull(token); KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
assertFalse(token.isEmpty()); String privateB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
} String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
@Test lenient().when(jwtConfig.getPrivateKey()).thenReturn(privateB64);
void extractEmail_Success() { lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
// Arrange lenient().when(jwtConfig.getKid()).thenReturn(KID);
var userId = "0815";
var email = "test@example.com"; publicKey = KeyFactory.getInstance("Ed25519")
var token = jwtService.generateToken(userId, email, null); .generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicB64)));
// Act jwtService = new JwtService(jwtConfig);
var extractedEmail = jwtService.extractEmail(token); jwtService.init();
}
// Assert
assertEquals(email, extractedEmail); @Test
} void generateToken_Success() {
var token = jwtService.generateToken("0815", "test@example.com", null);
@Test
void extractUserId_Success() { assertNotNull(token);
// Arrange assertFalse(token.isEmpty());
var userId = "0815"; }
var email = "test@example.com";
var token = jwtService.generateToken(userId, email, null); @Test
void generateToken_setsKidHeader() {
// Act var token = jwtService.generateToken("0815", "test@example.com", null);
var extractedUserId = jwtService.extractUserId(token);
var header = Jwts.parser()
// Assert .verifyWith(publicKey)
assertEquals(userId, extractedUserId); .build()
} .parseSignedClaims(token)
.getHeader();
@Test
void extractSteamId_Success() { assertEquals(KID, header.getKeyId());
// Arrange assertEquals("EdDSA", header.getAlgorithm());
var userId = "0815"; }
var email = "test@example.com";
var steamId = 123456789L; @Test
var token = jwtService.generateToken(userId, email, steamId); void extractEmail_Success() {
var email = "test@example.com";
// Act var token = jwtService.generateToken("0815", email, null);
var extractedSteamId = jwtService.extractSteamId(token);
assertEquals(email, jwtService.extractEmail(token));
// Assert }
assertEquals(steamId, extractedSteamId);
} @Test
void extractUserId_Success() {
@Test var userId = "0815";
void isTokenValid_Success() { var token = jwtService.generateToken(userId, "test@example.com", null);
// Arrange
var userId = "0815"; assertEquals(userId, jwtService.extractUserId(token));
var email = "test@example.com"; }
var token = jwtService.generateToken(userId, email, null);
@Test
// Act void extractSteamId_Success() {
var isValid = jwtService.isTokenValid(token); var steamId = 123456789L;
var token = jwtService.generateToken("0815", "test@example.com", steamId);
// Assert
assertTrue(isValid); assertEquals(steamId, jwtService.extractSteamId(token));
} }
@Test @Test
void isTokenValid_Failure() { void isTokenValid_Success() {
// Arrange var token = jwtService.generateToken("0815", "test@example.com", null);
var invalidToken = "invalid-token";
assertTrue(jwtService.isTokenValid(token));
// Act }
var isValid = jwtService.isTokenValid(invalidToken);
@Test
// Assert void isTokenValid_Failure() {
assertFalse(isValid); assertFalse(jwtService.isTokenValid("invalid-token"));
} }
} }