feat: add Ed25519 JWT signing and verification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,120 @@
|
||||
package net.siegeln.cameleer.saas.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.Signature;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JwtService(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public String generateToken(UserEntity user) {
|
||||
return "stub-token";
|
||||
try {
|
||||
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
|
||||
Map.of("alg", "EdDSA", "typ", "JWT")
|
||||
));
|
||||
|
||||
Instant now = Instant.now();
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("sub", user.getEmail());
|
||||
payload.put("uid", user.getId().toString());
|
||||
payload.put("name", user.getName());
|
||||
payload.put("roles", user.getRoles().stream()
|
||||
.map(RoleEntity::getName)
|
||||
.toList());
|
||||
payload.put("iat", now.getEpochSecond());
|
||||
payload.put("exp", now.getEpochSecond() + jwtConfig.getExpirationSeconds());
|
||||
|
||||
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
|
||||
|
||||
String signingInput = header + "." + payloadEncoded;
|
||||
Signature sig = Signature.getInstance("Ed25519");
|
||||
sig.initSign(jwtConfig.getPrivateKey());
|
||||
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String signature = base64UrlEncode(sig.sign());
|
||||
|
||||
return signingInput + "." + signature;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate JWT", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String extractEmail(String token) {
|
||||
return null;
|
||||
Map<String, Object> payload = parsePayload(token);
|
||||
return (String) payload.get("sub");
|
||||
}
|
||||
|
||||
public UUID extractUserId(String token) {
|
||||
Map<String, Object> payload = parsePayload(token);
|
||||
return UUID.fromString((String) payload.get("uid"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Set<String> extractRoles(String token) {
|
||||
Map<String, Object> payload = parsePayload(token);
|
||||
List<String> roles = (List<String>) payload.get("roles");
|
||||
return roles.stream().collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public boolean isTokenValid(String token) {
|
||||
return false;
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length != 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String signingInput = parts[0] + "." + parts[1];
|
||||
byte[] signatureBytes = base64UrlDecode(parts[2]);
|
||||
|
||||
Signature sig = Signature.getInstance("Ed25519");
|
||||
sig.initVerify(jwtConfig.getPublicKey());
|
||||
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
|
||||
if (!sig.verify(signatureBytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, Object> payload = parsePayload(token);
|
||||
long exp = ((Number) payload.get("exp")).longValue();
|
||||
return Instant.now().getEpochSecond() < exp;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parsePayload(String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
byte[] payloadBytes = base64UrlDecode(parts[1]);
|
||||
return objectMapper.readValue(payloadBytes, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse JWT payload", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String base64UrlEncode(byte[] data) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
|
||||
}
|
||||
|
||||
private byte[] base64UrlDecode(String data) {
|
||||
return Base64.getUrlDecoder().decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
@Component
|
||||
public class JwtConfig {
|
||||
|
||||
@Value("${cameleer.jwt.expiration:86400}")
|
||||
private long expirationSeconds = 86400;
|
||||
|
||||
private KeyPair keyPair;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws NoSuchAlgorithmException {
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
||||
this.keyPair = keyGen.generateKeyPair();
|
||||
}
|
||||
|
||||
public PrivateKey getPrivateKey() {
|
||||
return keyPair.getPrivate();
|
||||
}
|
||||
|
||||
public PublicKey getPublicKey() {
|
||||
return keyPair.getPublic();
|
||||
}
|
||||
|
||||
public long getExpirationSeconds() {
|
||||
return expirationSeconds;
|
||||
}
|
||||
}
|
||||
104
src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java
Normal file
104
src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package net.siegeln.cameleer.saas.auth;
|
||||
|
||||
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
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;
|
||||
|
||||
class JwtServiceTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
JwtConfig config = new JwtConfig();
|
||||
config.init();
|
||||
jwtService = new JwtService(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateToken_producesValidJwt() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
assertNotNull(token);
|
||||
String[] parts = token.split("\\.");
|
||||
assertEquals(3, parts.length, "JWT should have 3 parts separated by dots");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractEmail_returnsCorrectEmail() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
String email = jwtService.extractEmail(token);
|
||||
|
||||
assertEquals("test@example.com", email);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTokenValid_returnsTrueForValidToken() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
assertTrue(jwtService.isTokenValid(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTokenValid_returnsFalseForTamperedToken() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
// Tamper with the last 5 characters of the signature
|
||||
String tampered = token.substring(0, token.length() - 5) + "XXXXX";
|
||||
|
||||
assertFalse(jwtService.isTokenValid(tampered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRoles_returnsUserRoles() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
var roles = jwtService.extractRoles(token);
|
||||
|
||||
assertNotNull(roles);
|
||||
assertTrue(roles.contains("OWNER"));
|
||||
assertEquals(1, roles.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractUserId_returnsCorrectId() {
|
||||
UserEntity user = createUser("test@example.com", "OWNER");
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
UUID extractedId = jwtService.extractUserId(token);
|
||||
|
||||
assertEquals(user.getId(), extractedId);
|
||||
}
|
||||
|
||||
private UserEntity createUser(String email, String roleName) {
|
||||
var role = new RoleEntity();
|
||||
role.setName(roleName);
|
||||
var user = new UserEntity();
|
||||
user.setEmail(email);
|
||||
user.setName("Test User");
|
||||
user.getRoles().add(role);
|
||||
try {
|
||||
var idField = UserEntity.class.getDeclaredField("id");
|
||||
idField.setAccessible(true);
|
||||
idField.set(user, UUID.randomUUID());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user