feat: add MFA enrollment, removal, and settings endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 13:53:44 +02:00
parent 94de4c2a5b
commit 8b8909e488
3 changed files with 245 additions and 1 deletions

View File

@@ -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<TenantPortalService.MfaStatusData> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
}
@PostMapping("/mfa/totp/setup")
public ResponseEntity<TenantPortalService.MfaSetupData> 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<TenantPortalService.BackupCodesData> generateBackupCodes(
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
}
@DeleteMapping("/mfa/totp")
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
portalService.removeTotp(jwt.getSubject());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/users/{userId}/mfa")
public ResponseEntity<Void> resetTeamMemberMfa(@PathVariable String userId) {
try {
portalService.resetTeamMemberMfa(userId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PatchMapping("/settings")
public ResponseEntity<Void> updateSettings(@RequestBody Map<String, Object> updates) {
portalService.updateTenantSettings(updates);
return ResponseEntity.ok().build();
}
@GetMapping("/{slug}/mfa-policy")
public ResponseEntity<Map<String, Object>> getMfaPolicy(@PathVariable String slug) {
var tenantOpt = tenantService.getBySlug(slug);
if (tenantOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
var tenant = tenantOpt.get();
Map<String, Object> 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(

View File

@@ -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<String> 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<String> codes = List.of();
if (response.containsKey("codes")) {
var rawCodes = response.get("codes");
if (rawCodes instanceof List) {
codes = ((List<Object>) 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<String, Object> updates) {
TenantEntity tenant = resolveTenant();
Map<String, Object> 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;
}
}

View File

@@ -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"));