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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user