Public Access
Changed from JWT token to public/privat key
This commit is contained in:
1 parent
4859e74d45
commit
9114eb096b
5 files changed
+106
-77
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 io.jsonwebtoken.Jwts;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import java.security.KeyFactory;
|
||||||
import static org.mockito.Mockito.when;
|
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.
|
* Tests for JwtService (EdDSA / Ed25519).
|
||||||
*/
|
*/
|
||||||
class JwtServiceTest extends AbstractUnitTest {
|
class JwtServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final String KID = "test-key-1";
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private JwtConfig jwtConfig;
|
private JwtConfig jwtConfig;
|
||||||
|
|
||||||
private JwtService jwtService;
|
private JwtService jwtService;
|
||||||
|
private PublicKey publicKey;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() throws Exception {
|
||||||
when(jwtConfig.getSecret()).thenReturn("verysecretkeythatmustbeatleast32characterslongforhmacsha");
|
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 = new JwtService(jwtConfig);
|
||||||
|
jwtService.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateToken_Success() {
|
void generateToken_Success() {
|
||||||
// Arrange
|
var token = jwtService.generateToken("0815", "test@example.com", null);
|
||||||
var userId = "0815";
|
|
||||||
var email = "test@example.com";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var token = jwtService.generateToken(userId, email, null);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertNotNull(token);
|
assertNotNull(token);
|
||||||
assertFalse(token.isEmpty());
|
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
|
@Test
|
||||||
void extractEmail_Success() {
|
void extractEmail_Success() {
|
||||||
// Arrange
|
|
||||||
var userId = "0815";
|
|
||||||
var email = "test@example.com";
|
var email = "test@example.com";
|
||||||
var token = jwtService.generateToken(userId, email, null);
|
var token = jwtService.generateToken("0815", email, null);
|
||||||
|
|
||||||
// Act
|
assertEquals(email, jwtService.extractEmail(token));
|
||||||
var extractedEmail = jwtService.extractEmail(token);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertEquals(email, extractedEmail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void extractUserId_Success() {
|
void extractUserId_Success() {
|
||||||
// Arrange
|
|
||||||
var userId = "0815";
|
var userId = "0815";
|
||||||
var email = "test@example.com";
|
var token = jwtService.generateToken(userId, "test@example.com", null);
|
||||||
var token = jwtService.generateToken(userId, email, null);
|
|
||||||
|
|
||||||
// Act
|
assertEquals(userId, jwtService.extractUserId(token));
|
||||||
var extractedUserId = jwtService.extractUserId(token);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertEquals(userId, extractedUserId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void extractSteamId_Success() {
|
void extractSteamId_Success() {
|
||||||
// Arrange
|
|
||||||
var userId = "0815";
|
|
||||||
var email = "test@example.com";
|
|
||||||
var steamId = 123456789L;
|
var steamId = 123456789L;
|
||||||
var token = jwtService.generateToken(userId, email, steamId);
|
var token = jwtService.generateToken("0815", "test@example.com", steamId);
|
||||||
|
|
||||||
// Act
|
assertEquals(steamId, jwtService.extractSteamId(token));
|
||||||
var extractedSteamId = jwtService.extractSteamId(token);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertEquals(steamId, extractedSteamId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isTokenValid_Success() {
|
void isTokenValid_Success() {
|
||||||
// Arrange
|
var token = jwtService.generateToken("0815", "test@example.com", null);
|
||||||
var userId = "0815";
|
|
||||||
var email = "test@example.com";
|
|
||||||
var token = jwtService.generateToken(userId, email, null);
|
|
||||||
|
|
||||||
// Act
|
assertTrue(jwtService.isTokenValid(token));
|
||||||
var isValid = jwtService.isTokenValid(token);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertTrue(isValid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isTokenValid_Failure() {
|
void isTokenValid_Failure() {
|
||||||
// Arrange
|
assertFalse(jwtService.isTokenValid("invalid-token"));
|
||||||
var invalidToken = "invalid-token";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var isValid = jwtService.isTokenValid(invalidToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertFalse(isValid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user