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;
|
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 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
|
@Service
|
||||||
public class JwtService {
|
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) {
|
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) {
|
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) {
|
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