From 8b8909e488659cf4cf9ad5ce9a63483dcafd5184 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:53:44 +0200 Subject: [PATCH] feat: add MFA enrollment, removal, and settings endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/portal/TenantPortalController.java | 71 +++++++- .../saas/portal/TenantPortalService.java | 171 ++++++++++++++++++ .../cameleer/saas/tenant/TenantService.java | 4 + 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index da8f22b..4fe98ea 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.portal; import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity; import net.siegeln.cameleer.saas.certificate.TenantCaCertService; import net.siegeln.cameleer.saas.config.TenantContext; +import net.siegeln.cameleer.saas.tenant.TenantService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; @@ -29,10 +30,14 @@ public class TenantPortalController { private final TenantPortalService portalService; private final TenantCaCertService caCertService; + private final TenantService tenantService; - public TenantPortalController(TenantPortalService portalService, TenantCaCertService caCertService) { + public TenantPortalController(TenantPortalService portalService, + TenantCaCertService caCertService, + TenantService tenantService) { this.portalService = portalService; this.caCertService = caCertService; + this.tenantService = tenantService; } // --- Request bodies --- @@ -43,6 +48,8 @@ public class TenantPortalController { public record PasswordChangeRequest(String password) {} + public record TotpVerifyRequest(String secret, String code) {} + // --- Endpoints --- @GetMapping("/dashboard") @@ -134,6 +141,68 @@ public class TenantPortalController { return ResponseEntity.ok(portalService.getSettings()); } + // --- MFA endpoints --- + + @GetMapping("/mfa/status") + public ResponseEntity getMfaStatus(@AuthenticationPrincipal Jwt jwt) { + return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject())); + } + + @PostMapping("/mfa/totp/setup") + public ResponseEntity setupTotp(@AuthenticationPrincipal Jwt jwt) { + return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject())); + } + + @PostMapping("/mfa/totp/verify") + public ResponseEntity verifyTotp(@AuthenticationPrincipal Jwt jwt, + @RequestBody TotpVerifyRequest request) { + boolean valid = portalService.verifyTotpCode(request.secret(), request.code()); + if (!valid) { + return ResponseEntity.unprocessableEntity().body(Map.of("verified", false)); + } + return ResponseEntity.ok(Map.of("verified", true)); + } + + @PostMapping("/mfa/backup-codes") + public ResponseEntity generateBackupCodes( + @AuthenticationPrincipal Jwt jwt) { + return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject())); + } + + @DeleteMapping("/mfa/totp") + public ResponseEntity removeTotp(@AuthenticationPrincipal Jwt jwt) { + portalService.removeTotp(jwt.getSubject()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/users/{userId}/mfa") + public ResponseEntity resetTeamMemberMfa(@PathVariable String userId) { + try { + portalService.resetTeamMemberMfa(userId); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PatchMapping("/settings") + public ResponseEntity updateSettings(@RequestBody Map updates) { + portalService.updateTenantSettings(updates); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{slug}/mfa-policy") + public ResponseEntity> getMfaPolicy(@PathVariable String slug) { + var tenantOpt = tenantService.getBySlug(slug); + if (tenantOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + var tenant = tenantOpt.get(); + Map settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of(); + boolean mfaRequired = Boolean.TRUE.equals(settings.get("mfaRequired")); + return ResponseEntity.ok(Map.of("mfaRequired", mfaRequired)); + } + // --- CA Certificate management --- public record CaCertResponse( diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index 935e6d3..d31996f 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -15,8 +15,13 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -70,6 +75,12 @@ public class TenantPortalService { String serverEndpoint, Instant createdAt ) {} + public record MfaStatusData(boolean enrolled, boolean hasBackupCodes) {} + + public record MfaSetupData(String secret, String secretQrCode) {} + + public record BackupCodesData(List codes) {} + // --- Helpers --- private TenantEntity resolveTenant() { @@ -257,4 +268,164 @@ public class TenantPortalService { vendorTenantService.provisionAsync( tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null); } + + // --- MFA methods --- + + public MfaStatusData getMfaStatus(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + boolean enrolled = verifications.stream() + .anyMatch(v -> "Totp".equals(String.valueOf(v.get("type")))); + boolean hasBackupCodes = verifications.stream() + .anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type")))); + return new MfaStatusData(enrolled, hasBackupCodes); + } + + @SuppressWarnings("unchecked") + public MfaSetupData setupTotp(String userId) { + byte[] secretBytes = new byte[20]; + new SecureRandom().nextBytes(secretBytes); + String secret = base32Encode(secretBytes); + + var response = logtoClient.createTotpVerification(userId, secret); + String qrCode = ""; + if (response.containsKey("secretQrCode")) { + qrCode = String.valueOf(response.get("secretQrCode")); + } else if (response.containsKey("qrCode")) { + qrCode = String.valueOf(response.get("qrCode")); + } + return new MfaSetupData(secret, qrCode); + } + + public boolean verifyTotpCode(String secret, String code) { + if (secret == null || code == null || code.length() != 6) { + return false; + } + long currentTimeStep = System.currentTimeMillis() / 30000; + // Allow +-1 step drift + for (long step = currentTimeStep - 1; step <= currentTimeStep + 1; step++) { + String computed = computeTotp(secret, step); + if (computed.equals(code)) { + return true; + } + } + return false; + } + + @SuppressWarnings("unchecked") + public BackupCodesData generateBackupCodes(String userId) { + var response = logtoClient.createBackupCodes(userId); + List codes = List.of(); + if (response.containsKey("codes")) { + var rawCodes = response.get("codes"); + if (rawCodes instanceof List) { + codes = ((List) rawCodes).stream() + .map(String::valueOf) + .toList(); + } + } + return new BackupCodesData(codes); + } + + public void removeTotp(String userId) { + var verifications = logtoClient.getUserMfaVerifications(userId); + for (var v : verifications) { + String id = String.valueOf(v.get("id")); + logtoClient.deleteMfaVerification(userId, id); + } + } + + public void resetTeamMemberMfa(String userId) { + TenantEntity tenant = resolveTenant(); + String orgId = tenant.getLogtoOrgId(); + if (orgId == null || orgId.isBlank()) { + throw new IllegalStateException("Tenant has no Logto organization configured"); + } + // Verify the target user belongs to this tenant's org + var members = logtoClient.listOrganizationMembers(orgId); + boolean isMember = members.stream() + .anyMatch(m -> userId.equals(String.valueOf(m.get("id")))); + if (!isMember) { + throw new IllegalArgumentException("User is not a member of this organization"); + } + logtoClient.deleteAllMfaVerifications(userId); + } + + public void updateTenantSettings(Map updates) { + TenantEntity tenant = resolveTenant(); + Map settings = new HashMap<>( + tenant.getSettings() != null ? tenant.getSettings() : Map.of()); + // Only allow known keys + if (updates.containsKey("mfaRequired")) { + settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired"))); + } + tenant.setSettings(settings); + tenantService.save(tenant); + } + + // --- TOTP helpers --- + + private String computeTotp(String base32Secret, long timeStep) { + try { + byte[] key = base32Decode(base32Secret); + byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array(); + + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec(key, "HmacSHA1")); + byte[] hash = mac.doFinal(data); + + int offset = hash[hash.length - 1] & 0x0F; + int binary = ((hash[offset] & 0x7F) << 24) + | ((hash[offset + 1] & 0xFF) << 16) + | ((hash[offset + 2] & 0xFF) << 8) + | (hash[offset + 3] & 0xFF); + int otp = binary % 1_000_000; + return String.format("%06d", otp); + } catch (Exception e) { + log.error("TOTP computation failed", e); + return ""; + } + } + + private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + private String base32Encode(byte[] data) { + StringBuilder result = new StringBuilder(); + int buffer = 0; + int bitsLeft = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xFF); + bitsLeft += 8; + while (bitsLeft >= 5) { + result.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F)); + bitsLeft -= 5; + } + } + if (bitsLeft > 0) { + result.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F)); + } + return result.toString(); + } + + private byte[] base32Decode(String encoded) { + String upper = encoded.toUpperCase().replaceAll("[=\\s]", ""); + int[] output = new int[upper.length() * 5 / 8]; + int buffer = 0; + int bitsLeft = 0; + int index = 0; + for (char c : upper.toCharArray()) { + int val = BASE32_ALPHABET.indexOf(c); + if (val < 0) continue; + buffer = (buffer << 5) | val; + bitsLeft += 5; + if (bitsLeft >= 8) { + output[index++] = (buffer >> (bitsLeft - 8)) & 0xFF; + bitsLeft -= 8; + } + } + byte[] result = new byte[index]; + for (int i = 0; i < index; i++) { + result[i] = (byte) output[i]; + } + return result; + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java index 8d304cb..e31440e 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -84,6 +84,10 @@ public class TenantService { return saved; } + public TenantEntity save(TenantEntity entity) { + return tenantRepository.save(entity); + } + public TenantEntity suspend(UUID tenantId, UUID actorId) { var entity = tenantRepository.findById(tenantId) .orElseThrow(() -> new IllegalArgumentException("Tenant not found"));