mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Merge remote-tracking branch 'origin/V2' into mainToV2
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.configuration;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ServerCertificateInitializer {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.initializeServerCertificate();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize server certificate", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package stirling.software.proprietary.controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** Controller for the audit dashboard. Admin-only access. */
|
||||
@Slf4j
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@RequestMapping("/audit")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class AuditDashboardController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Display the audit dashboard. */
|
||||
@GetMapping
|
||||
public String showDashboard(Model model) {
|
||||
model.addAttribute("auditEnabled", auditConfig.isEnabled());
|
||||
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
|
||||
model.addAttribute("auditLevelInt", auditConfig.getLevel());
|
||||
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
|
||||
|
||||
// Add audit level enum values for display
|
||||
model.addAttribute("auditLevels", AuditLevel.values());
|
||||
|
||||
// Add audit event types for the dropdown
|
||||
model.addAttribute("auditEventTypes", AuditEventType.values());
|
||||
|
||||
return "audit/dashboard";
|
||||
}
|
||||
|
||||
/** Get audit events data for the dashboard tables. */
|
||||
@GetMapping("/data")
|
||||
@ResponseBody
|
||||
public Map<String, Object> getAuditData(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "30") int size,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate,
|
||||
HttpServletRequest request) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending());
|
||||
Page<PersistentAuditEvent> events;
|
||||
|
||||
String mode;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
mode = "principal + type + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
||||
principal, type, start, end, pageable);
|
||||
} else if (type != null && principal != null) {
|
||||
mode = "principal + type";
|
||||
events = auditRepository.findByPrincipalAndType(principal, type, pageable);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
mode = "type + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
mode = "principal + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTimestampBetween(
|
||||
principal, start, end, pageable);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
mode = "startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
||||
} else if (type != null) {
|
||||
mode = "type";
|
||||
events = auditRepository.findByType(type, pageable);
|
||||
} else if (principal != null) {
|
||||
mode = "principal";
|
||||
events = auditRepository.findByPrincipal(principal, pageable);
|
||||
} else {
|
||||
mode = "all";
|
||||
events = auditRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
// Logging
|
||||
List<PersistentAuditEvent> content = events.getContent();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("content", content);
|
||||
response.put("totalPages", events.getTotalPages());
|
||||
response.put("totalElements", events.getTotalElements());
|
||||
response.put("currentPage", events.getNumber());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get statistics for charts. */
|
||||
@GetMapping("/stats")
|
||||
@ResponseBody
|
||||
public Map<String, Object> getAuditStats(
|
||||
@RequestParam(value = "days", defaultValue = "7") int days) {
|
||||
|
||||
// Get events from the last X days
|
||||
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
|
||||
List<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
|
||||
|
||||
// Count events by type
|
||||
Map<String, Long> eventsByType =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getType, Collectors.counting()));
|
||||
|
||||
// Count events by principal
|
||||
Map<String, Long> eventsByPrincipal =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||
|
||||
// Count events by day
|
||||
Map<String, Long> eventsByDay =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
e ->
|
||||
LocalDateTime.ofInstant(
|
||||
e.getTimestamp(),
|
||||
ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
Collectors.counting()));
|
||||
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("eventsByType", eventsByType);
|
||||
stats.put("eventsByPrincipal", eventsByPrincipal);
|
||||
stats.put("eventsByDay", eventsByDay);
|
||||
stats.put("totalEvents", events.size());
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Get all unique event types from the database for filtering. */
|
||||
@GetMapping("/types")
|
||||
@ResponseBody
|
||||
public List<String> getAuditTypes() {
|
||||
// Get distinct event types from the database
|
||||
List<String> dbTypes = auditRepository.findDistinctEventTypes();
|
||||
|
||||
// Include standard enum types in case they're not in the database yet
|
||||
List<String> enumTypes =
|
||||
Arrays.stream(AuditEventType.values())
|
||||
.map(AuditEventType::name)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Combine both sources, remove duplicates, and sort
|
||||
Set<String> combinedTypes = new HashSet<>();
|
||||
combinedTypes.addAll(dbTypes);
|
||||
combinedTypes.addAll(enumTypes);
|
||||
|
||||
return combinedTypes.stream().sorted().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Export audit data as CSV. */
|
||||
@GetMapping("/export")
|
||||
public ResponseEntity<byte[]> exportAuditData(
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
// Get data with same filtering as getAuditData
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
principal, type, start, end);
|
||||
} else if (type != null && principal != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
principal, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (type != null) {
|
||||
events = auditRepository.findByTypeForExport(type);
|
||||
} else if (principal != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(principal);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Convert to CSV
|
||||
StringBuilder csv = new StringBuilder();
|
||||
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
for (PersistentAuditEvent event : events) {
|
||||
csv.append(event.getId()).append(",");
|
||||
csv.append(escapeCSV(event.getPrincipal())).append(",");
|
||||
csv.append(escapeCSV(event.getType())).append(",");
|
||||
csv.append(formatter.format(event.getTimestamp())).append(",");
|
||||
csv.append(escapeCSV(event.getData())).append("\n");
|
||||
}
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes();
|
||||
|
||||
// Set up HTTP headers for download
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
/** Export audit data as JSON. */
|
||||
@GetMapping("/export/json")
|
||||
public ResponseEntity<byte[]> exportAuditDataJson(
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
// Get data with same filtering as getAuditData
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
principal, type, start, end);
|
||||
} else if (type != null && principal != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
principal, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (type != null) {
|
||||
events = auditRepository.findByTypeForExport(type);
|
||||
} else if (principal != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(principal);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
try {
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||
|
||||
// Set up HTTP headers for download
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.json");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(jsonBytes);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Error serializing audit events to JSON", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper method to escape CSV fields. */
|
||||
private String escapeCSV(String field) {
|
||||
if (field == null) {
|
||||
return "";
|
||||
}
|
||||
// Replace double quotes with two double quotes and wrap in quotes
|
||||
return "\"" + field.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static stirling.software.common.util.ProviderUtils.validateProvider;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.SessionEntity;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@EnterpriseEndpoint
|
||||
public class ProprietaryUIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final UserRepository userRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final DatabaseService databaseService;
|
||||
private final boolean runningEE;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
UserRepository userRepository,
|
||||
TeamRepository teamRepository,
|
||||
SessionRepository sessionRepository,
|
||||
DatabaseService databaseService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.userRepository = userRepository;
|
||||
this.teamRepository = teamRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.databaseService = databaseService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get audit dashboard data")
|
||||
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
|
||||
AuditDashboardData data = new AuditDashboardData();
|
||||
data.setAuditEnabled(auditConfig.isEnabled());
|
||||
data.setAuditLevel(auditConfig.getAuditLevel());
|
||||
data.setAuditLevelInt(auditConfig.getLevel());
|
||||
data.setRetentionDays(auditConfig.getRetentionDays());
|
||||
data.setAuditLevels(AuditLevel.values());
|
||||
data.setAuditEventTypes(AuditEventType.values());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
@Operation(summary = "Get login page data")
|
||||
public ResponseEntity<LoginData> getLoginData() {
|
||||
LoginData data = new LoginData();
|
||||
Map<String, String> providerList = new HashMap<>();
|
||||
Security securityProps = applicationProperties.getSecurity();
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
if (oauth.isSettingsValid()) {
|
||||
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
|
||||
String clientName =
|
||||
oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase());
|
||||
providerList.put("/oauth2/authorization/" + oauth.getProvider(), clientName);
|
||||
}
|
||||
|
||||
Client client = oauth.getClient();
|
||||
if (client != null) {
|
||||
GoogleProvider google = client.getGoogle();
|
||||
if (validateProvider(google)) {
|
||||
providerList.put(
|
||||
"/oauth2/authorization/" + google.getName(), google.getClientName());
|
||||
}
|
||||
|
||||
GitHubProvider github = client.getGithub();
|
||||
if (validateProvider(github)) {
|
||||
providerList.put(
|
||||
"/oauth2/authorization/" + github.getName(), github.getClientName());
|
||||
}
|
||||
|
||||
KeycloakProvider keycloak = client.getKeycloak();
|
||||
if (validateProvider(keycloak)) {
|
||||
providerList.put(
|
||||
"/oauth2/authorization/" + keycloak.getName(),
|
||||
keycloak.getClientName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
if (securityProps.isSaml2Active()
|
||||
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
&& applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
|
||||
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove null entries
|
||||
providerList
|
||||
.entrySet()
|
||||
.removeIf(entry -> entry.getKey() == null || entry.getValue() == null);
|
||||
|
||||
data.setProviderList(providerList);
|
||||
data.setLoginMethod(securityProps.getLoginMethod());
|
||||
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/admin-settings")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Operation(summary = "Get admin settings data")
|
||||
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
Iterator<User> iterator = allUsers.iterator();
|
||||
Map<String, String> roleDetails = Role.getAllRoleDetails();
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
boolean shouldRemove = false;
|
||||
|
||||
// Check if user is an INTERNAL_API_USER
|
||||
for (Authority authority : user.getAuthorities()) {
|
||||
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
shouldRemove = true;
|
||||
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is part of the Internal team
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Session status and last request time
|
||||
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(user.getUsername());
|
||||
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
Date lastAccessedTime = sessionEntity.getLastRequest();
|
||||
Instant now = Instant.now();
|
||||
Instant expirationTime =
|
||||
lastAccessedTime
|
||||
.toInstant()
|
||||
.plus(maxInactiveInterval, ChronoUnit.SECONDS);
|
||||
|
||||
if (now.isAfter(expirationTime)) {
|
||||
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
|
||||
} else {
|
||||
hasActiveSession = !sessionEntity.isExpired();
|
||||
}
|
||||
lastRequest = sessionEntity.getLastRequest();
|
||||
} else {
|
||||
lastRequest = new Date(0);
|
||||
}
|
||||
|
||||
userSessions.put(user.getUsername(), hasActiveSession);
|
||||
userLastRequest.put(user.getUsername(), lastRequest);
|
||||
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort users by active status and last request date
|
||||
List<User> sortedUsers =
|
||||
allUsers.stream()
|
||||
.sorted(
|
||||
(u1, u2) -> {
|
||||
boolean u1Active = userSessions.get(u1.getUsername());
|
||||
boolean u2Active = userSessions.get(u2.getUsername());
|
||||
if (u1Active && !u2Active) return -1;
|
||||
if (!u1Active && u2Active) return 1;
|
||||
|
||||
Date u1LastRequest =
|
||||
userLastRequest.getOrDefault(
|
||||
u1.getUsername(), new Date(0));
|
||||
Date u2LastRequest =
|
||||
userLastRequest.getOrDefault(
|
||||
u2.getUsername(), new Date(0));
|
||||
return u2LastRequest.compareTo(u1LastRequest);
|
||||
})
|
||||
.toList();
|
||||
|
||||
List<Team> allTeams =
|
||||
teamRepository.findAll().stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
data.setUsers(sortedUsers);
|
||||
data.setCurrentUsername(authentication.getName());
|
||||
data.setRoleDetails(roleDetails);
|
||||
data.setUserSessions(userSessions);
|
||||
data.setUserLastRequest(userLastRequest);
|
||||
data.setTotalUsers(allUsers.size());
|
||||
data.setActiveUsers(activeUsers);
|
||||
data.setDisabledUsers(disabledUsers);
|
||||
data.setTeams(allTeams);
|
||||
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/account")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@Operation(summary = "Get account page data")
|
||||
public ResponseEntity<AccountData> getAccountData(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
Object principal = authentication.getPrincipal();
|
||||
String username = null;
|
||||
boolean isOAuth2Login = false;
|
||||
boolean isSaml2Login = false;
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
username = detailsUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
username = oAuth2User.getName();
|
||||
isOAuth2Login = true;
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
username = saml2User.name();
|
||||
isSaml2Login = true;
|
||||
}
|
||||
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
Optional<User> user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
|
||||
if (user.isEmpty()) {
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
|
||||
String settingsJson;
|
||||
try {
|
||||
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Error converting settings map", e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
|
||||
AccountData data = new AccountData();
|
||||
data.setUsername(username);
|
||||
data.setRole(user.get().getRolesAsString());
|
||||
data.setSettings(settingsJson);
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin());
|
||||
data.setOAuth2Login(isOAuth2Login);
|
||||
data.setSaml2Login(isSaml2Login);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/teams")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Operation(summary = "Get teams list data")
|
||||
public ResponseEntity<TeamsData> getTeamsData() {
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
|
||||
Map<Long, Date> teamLastRequest = new HashMap<>();
|
||||
for (Object[] result : teamActivities) {
|
||||
Long teamId = (Long) result[0];
|
||||
Date lastActivity = (Date) result[1];
|
||||
teamLastRequest.put(teamId, lastActivity);
|
||||
}
|
||||
|
||||
TeamsData data = new TeamsData();
|
||||
data.setTeamsWithCounts(teamsWithCounts);
|
||||
data.setTeamLastRequest(teamLastRequest);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/teams/{id}")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Operation(summary = "Get team details data")
|
||||
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
List<User> teamUsers = userRepository.findAllByTeamId(id);
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
List<User> availableUsers =
|
||||
allUsers.stream()
|
||||
.filter(
|
||||
user ->
|
||||
(user.getTeam() == null
|
||||
|| !user.getTeam().getId().equals(id))
|
||||
&& (user.getTeam() == null
|
||||
|| !user.getTeam()
|
||||
.getName()
|
||||
.equals(
|
||||
TeamService
|
||||
.INTERNAL_TEAM_NAME)))
|
||||
.toList();
|
||||
|
||||
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
for (Object[] result : userSessions) {
|
||||
String username = (String) result[0];
|
||||
Date lastRequest = (Date) result[1];
|
||||
userLastRequest.put(username, lastRequest);
|
||||
}
|
||||
|
||||
TeamDetailsData data = new TeamDetailsData();
|
||||
data.setTeam(team);
|
||||
data.setTeamUsers(teamUsers);
|
||||
data.setAvailableUsers(availableUsers);
|
||||
data.setUserLastRequest(userLastRequest);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@GetMapping("/database")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Operation(summary = "Get database management data")
|
||||
public ResponseEntity<DatabaseData> getDatabaseData() {
|
||||
List<FileInfo> backupList = databaseService.getBackupList();
|
||||
String dbVersion = databaseService.getH2Version();
|
||||
boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion);
|
||||
|
||||
DatabaseData data = new DatabaseData();
|
||||
data.setBackupFiles(backupList);
|
||||
data.setDatabaseVersion(dbVersion);
|
||||
data.setVersionUnknown(isVersionUnknown);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
// Data classes
|
||||
@Data
|
||||
public static class AuditDashboardData {
|
||||
private boolean auditEnabled;
|
||||
private AuditLevel auditLevel;
|
||||
private int auditLevelInt;
|
||||
private int retentionDays;
|
||||
private AuditLevel[] auditLevels;
|
||||
private AuditEventType[] auditEventTypes;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class LoginData {
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AdminSettingsData {
|
||||
private List<User> users;
|
||||
private String currentUsername;
|
||||
private Map<String, String> roleDetails;
|
||||
private Map<String, Boolean> userSessions;
|
||||
private Map<String, Date> userLastRequest;
|
||||
private int totalUsers;
|
||||
private int activeUsers;
|
||||
private int disabledUsers;
|
||||
private List<Team> teams;
|
||||
private int maxPaidUsers;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AccountData {
|
||||
private String username;
|
||||
private String role;
|
||||
private String settings;
|
||||
private boolean changeCredsFlag;
|
||||
private boolean oAuth2Login;
|
||||
private boolean saml2Login;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TeamsData {
|
||||
private List<TeamWithUserCountDTO> teamsWithCounts;
|
||||
private Map<Long, Date> teamLastRequest;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TeamDetailsData {
|
||||
private Team team;
|
||||
private List<User> teamUsers;
|
||||
private List<User> availableUsers;
|
||||
private Map<String, Date> userLastRequest;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DatabaseData {
|
||||
private List<FileInfo> backupFiles;
|
||||
private String databaseVersion;
|
||||
private boolean versionUnknown;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import java.io.Serializable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.*;
|
||||
@@ -30,6 +32,7 @@ public class Team implements Serializable {
|
||||
private String name;
|
||||
|
||||
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonIgnore
|
||||
private Set<User> users = new HashSet<>();
|
||||
|
||||
public void addUser(User user) {
|
||||
|
||||
@@ -10,13 +10,15 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
<<<<<<< HEAD
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
=======
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
>>>>>>> refs/remotes/origin/V2
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -46,7 +48,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Controller
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@Slf4j
|
||||
@Tag(name = "Account Security", description = "Account Security APIs")
|
||||
public class AccountWebController {
|
||||
@@ -70,7 +72,7 @@ public class AccountWebController {
|
||||
this.teamRepository = teamRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
// @GetMapping("/login")
|
||||
public String login(HttpServletRequest request, Model model, Authentication authentication) {
|
||||
// If the user is already authenticated and it's not a logout scenario, redirect them to the
|
||||
// home page.
|
||||
@@ -201,15 +203,16 @@ public class AccountWebController {
|
||||
return "login";
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@GetMapping("/usage")
|
||||
//@EnterpriseEndpoint
|
||||
// @PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
// @GetMapping("/usage")
|
||||
|
||||
public String showUsage() {
|
||||
return "usage";
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@GetMapping("/adminSettings")
|
||||
// @PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
// @GetMapping("/adminSettings")
|
||||
public String showAddUserForm(
|
||||
HttpServletRequest request, Model model, Authentication authentication) {
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
@@ -369,8 +372,8 @@ public class AccountWebController {
|
||||
return "adminSettings";
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/account")
|
||||
// @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
// @GetMapping("/account")
|
||||
public String account(HttpServletRequest request, Model model, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return "redirect:/";
|
||||
@@ -434,8 +437,8 @@ public class AccountWebController {
|
||||
return "account";
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/change-creds")
|
||||
// @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
// @GetMapping("/change-creds")
|
||||
public String changeCreds(
|
||||
HttpServletRequest request, Model model, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
|
||||
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@@ -55,19 +54,6 @@ public class EEAppConfig {
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Primary
|
||||
public boolean googleDriveEnabled() {
|
||||
return runningProOrHigher()
|
||||
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveConfig")
|
||||
public GoogleDrive googleDriveConfig() {
|
||||
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@SuppressWarnings("deprecation")
|
||||
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
|
||||
|
||||
@@ -12,12 +12,10 @@ import java.util.regex.Pattern;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
@@ -27,13 +25,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.AdminApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -41,9 +39,7 @@ import stirling.software.proprietary.security.model.api.admin.SettingValueRespon
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
||||
|
||||
@Controller
|
||||
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
|
||||
@RequestMapping("/api/v1/admin/settings")
|
||||
@AdminApi
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Slf4j
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@@ -22,20 +21,18 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.DatabaseApi;
|
||||
import stirling.software.proprietary.security.database.H2SQLCondition;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/api/v1/database")
|
||||
@DatabaseApi
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Conditional(H2SQLCondition.class)
|
||||
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseController {
|
||||
|
||||
|
||||
@@ -6,12 +6,8 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mail.MailSendException;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -19,6 +15,8 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.proprietary.security.model.api.Email;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
|
||||
@@ -26,11 +24,9 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
* Controller for handling email-related API requests. This controller exposes an endpoint for
|
||||
* sending emails with attachments.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class EmailController {
|
||||
private final EmailService emailService;
|
||||
@@ -43,7 +39,7 @@ public class EmailController {
|
||||
* attachment.
|
||||
* @return ResponseEntity with success or error message.
|
||||
*/
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email")
|
||||
@Operation(
|
||||
summary = "Send an email with an attachment",
|
||||
description =
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
@Slf4j
|
||||
@Tag(
|
||||
name = "Admin - Server Certificate",
|
||||
description = "Admin APIs for server certificate management")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class ServerCertificateController {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@GetMapping("/info")
|
||||
@Operation(
|
||||
summary = "Get server certificate information",
|
||||
description = "Returns information about the current server certificate")
|
||||
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
|
||||
getServerCertificateInfo() {
|
||||
try {
|
||||
ServerCertificateServiceInterface.ServerCertificateInfo info =
|
||||
serverCertificateService.getServerCertificateInfo();
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate info", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(
|
||||
summary = "Upload server certificate",
|
||||
description =
|
||||
"Upload a new PKCS12 certificate file to be used as the server certificate")
|
||||
public ResponseEntity<String> uploadServerCertificate(
|
||||
@Parameter(description = "PKCS12 certificate file", required = true)
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(description = "Certificate password", required = true)
|
||||
@RequestParam("password")
|
||||
String password) {
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
|
||||
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Only PKCS12 (.p12 or .pfx) files are supported");
|
||||
}
|
||||
|
||||
try {
|
||||
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
|
||||
return ResponseEntity.ok("Server certificate uploaded successfully");
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid certificate upload: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body("Invalid certificate or password.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Delete server certificate",
|
||||
description = "Delete the current server certificate")
|
||||
public ResponseEntity<String> deleteServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate();
|
||||
return ResponseEntity.ok("Server certificate deleted successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
@Operation(
|
||||
summary = "Generate new server certificate",
|
||||
description = "Generate a new self-signed server certificate")
|
||||
public ResponseEntity<String> generateServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate(); // Remove existing if any
|
||||
serverCertificateService.initializeServerCertificate(); // Generate new
|
||||
return ResponseEntity.ok("New server certificate generated successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Failed to generate server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/certificate")
|
||||
@Operation(
|
||||
summary = "Download server certificate",
|
||||
description = "Download the server certificate in DER format for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificate() {
|
||||
try {
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server-cert.cer\"")
|
||||
.contentType(MediaType.valueOf("application/pkix-cert"))
|
||||
.body(certificate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/enabled")
|
||||
@Operation(
|
||||
summary = "Check if server certificate feature is enabled",
|
||||
description =
|
||||
"Returns whether the server certificate feature is enabled in configuration")
|
||||
public ResponseEntity<Boolean> isServerCertificateEnabled() {
|
||||
return ResponseEntity.ok(serverCertificateService.isEnabled());
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,15 @@ package stirling.software.proprietary.security.controller.api;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.TeamApi;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@@ -21,9 +19,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/api/v1/team")
|
||||
@Tag(name = "Team", description = "Team Management APIs")
|
||||
@TeamApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@PremiumEndpoint
|
||||
|
||||
@@ -15,14 +15,11 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.transaction.Transactional;
|
||||
@@ -30,6 +27,7 @@ import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@@ -47,9 +45,7 @@ import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Controller
|
||||
@Tag(name = "User", description = "User APIs")
|
||||
@RequestMapping("/api/v1/user")
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
@@ -4,9 +4,7 @@ import java.util.List;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@@ -17,15 +15,16 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
|
||||
@Controller
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@Tag(name = "Database Management", description = "Database management and security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseWebController {
|
||||
|
||||
private final DatabaseService databaseService;
|
||||
|
||||
@Deprecated
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@GetMapping("/database")
|
||||
// @GetMapping("/database")
|
||||
public String database(HttpServletRequest request, Model model, Authentication authentication) {
|
||||
String error = request.getParameter("error");
|
||||
String confirmed = request.getParameter("infoMessage");
|
||||
|
||||
@@ -6,9 +6,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@@ -25,7 +23,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
|
||||
@Controller
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@RequestMapping("/teams")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@@ -35,7 +33,8 @@ public class TeamWebController {
|
||||
private final SessionRepository sessionRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@Deprecated
|
||||
// @GetMapping
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
public String listTeams(HttpServletRequest request, Model model) {
|
||||
// Get teams with user counts using a DTO projection
|
||||
@@ -86,7 +85,8 @@ public class TeamWebController {
|
||||
return "accounts/teams";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Deprecated
|
||||
// @GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
public String viewTeamDetails(
|
||||
HttpServletRequest request, @PathVariable("id") Long id, Model model) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.io.Serializable;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
@@ -33,6 +35,7 @@ public class Authority implements GrantedAuthority, Serializable {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "user_id")
|
||||
@JsonIgnore
|
||||
private User user;
|
||||
|
||||
public Authority() {}
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -62,6 +64,7 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "team_id")
|
||||
@JsonIgnore
|
||||
private Team team;
|
||||
|
||||
@ElementCollection
|
||||
|
||||
@@ -38,7 +38,7 @@ public class CustomUserDetailsService implements UserDetailsService {
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
|
||||
// Handle legacy users without authenticationType (from versions < 1.3.0)
|
||||
// TODO: Remove for SaaS - Handle legacy users without authenticationType (from versions < 1.3.0)
|
||||
String authTypeStr = user.getAuthenticationType();
|
||||
if (authTypeStr == null || authTypeStr.isEmpty()) {
|
||||
// Migrate legacy users by detecting authentication type based on password presence
|
||||
|
||||
@@ -4,7 +4,6 @@ import java.security.KeyPair;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
@@ -13,6 +12,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
@@ -53,6 +53,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
@@ -93,8 +94,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuedAt(Date.from(Instant.now()))
|
||||
.expiration(Date.from(Instant.now().plusMillis(EXPIRATION)))
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -130,7 +131,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(Date.from(Instant.now()));
|
||||
return extractExpiration(token).before(new Date());
|
||||
}
|
||||
|
||||
private Date extractExpiration(String token) {
|
||||
@@ -154,8 +155,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPair = specificKeyPair.get();
|
||||
} else {
|
||||
log.warn(
|
||||
"Key ID {} not found in keystore, token may have been signed with an"
|
||||
+ " expired key",
|
||||
"Key ID {} not found in keystore, token may have been signed with an expired key",
|
||||
keyId);
|
||||
|
||||
if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -29,6 +30,7 @@ public class KeyPairCleanupService {
|
||||
private final KeyPersistenceService keyPersistenceService;
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
|
||||
@Autowired
|
||||
public KeyPairCleanupService(
|
||||
KeyPersistenceService keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
@@ -40,7 +42,7 @@ public class KeyPairCleanupService {
|
||||
@PostConstruct
|
||||
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
|
||||
public void cleanup() {
|
||||
if (!jwtProperties.isEnabled() || !jwtProperties.isKeyCleanup()) {
|
||||
if (!jwtProperties.isEnableKeyCleanup() || !keyPersistenceService.isKeystoreEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ public class KeyPairCleanupService {
|
||||
}
|
||||
|
||||
private void removePrivateKey(String keyId) throws IOException {
|
||||
if (!jwtProperties.isEnabled()) {
|
||||
if (!keyPersistenceService.isKeystoreEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
@@ -22,6 +20,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -44,52 +43,26 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
public static final String KEY_SUFFIX = ".key";
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
private final CacheManager cacheManager;
|
||||
private final Cache verifyingKeyCache;
|
||||
|
||||
private volatile JwtVerificationKey activeKey;
|
||||
|
||||
@Autowired
|
||||
public KeyPersistenceService(
|
||||
ApplicationProperties applicationProperties, CacheManager cacheManager) {
|
||||
this.jwtProperties = applicationProperties.getSecurity().getJwt();
|
||||
this.cacheManager = cacheManager;
|
||||
this.verifyingKeyCache = cacheManager.getCache("verifyingKeys");
|
||||
}
|
||||
|
||||
/** Move all key files from db/keys to backup/keys */
|
||||
@Deprecated(since = "2.0.0", forRemoval = true)
|
||||
private void moveKeysToBackup() {
|
||||
Path sourceDir =
|
||||
Paths.get(InstallationPathConfig.getConfigPath(), "db", "keys").normalize();
|
||||
|
||||
if (!Files.exists(sourceDir)) {
|
||||
log.info("Source directory does not exist: {}", sourceDir);
|
||||
return;
|
||||
}
|
||||
|
||||
Path targetDir = Paths.get(InstallationPathConfig.getPrivateKeyPath()).normalize();
|
||||
|
||||
try {
|
||||
Files.createDirectories(targetDir);
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(sourceDir)) {
|
||||
for (Path entry : stream) {
|
||||
Files.move(
|
||||
entry,
|
||||
targetDir.resolve(entry.getFileName()),
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error moving key files to backup: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initializeKeystore() {
|
||||
if (!jwtProperties.isEnabled()) {
|
||||
if (!isKeystoreEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
moveKeysToBackup();
|
||||
ensurePrivateKeyDirectoryExists();
|
||||
loadKeyPair();
|
||||
} catch (Exception e) {
|
||||
@@ -132,7 +105,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
|
||||
@Override
|
||||
public Optional<KeyPair> getKeyPair(String keyId) {
|
||||
if (!jwtProperties.isEnabled()) {
|
||||
if (!isKeystoreEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -155,6 +128,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isKeystoreEnabled() {
|
||||
return jwtProperties.isEnableKeystore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JwtVerificationKey refreshActiveKeyPair() {
|
||||
return generateAndStoreKeypair();
|
||||
@@ -181,7 +159,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
nativeCache.asMap().size());
|
||||
|
||||
return nativeCache.asMap().values().stream()
|
||||
.filter(JwtVerificationKey.class::isInstance)
|
||||
.filter(value -> value instanceof JwtVerificationKey)
|
||||
.map(value -> (JwtVerificationKey) value)
|
||||
.filter(
|
||||
key -> {
|
||||
@@ -255,7 +233,6 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKey decodePublicKey(String encodedKey)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(encodedKey);
|
||||
@@ -263,8 +240,4 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return keyFactory.generatePublic(keySpec);
|
||||
}
|
||||
|
||||
public boolean isKeystoreEnabled() {
|
||||
return jwtProperties.isEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public interface KeyPersistenceServiceInterface {
|
||||
|
||||
Optional<KeyPair> getKeyPair(String keyId);
|
||||
|
||||
boolean isKeystoreEnabled();
|
||||
|
||||
JwtVerificationKey refreshActiveKeyPair();
|
||||
|
||||
List<JwtVerificationKey> getKeysEligibleForCleanup(LocalDateTime cutoffDate);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ServerCertificateService implements ServerCertificateServiceInterface {
|
||||
|
||||
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
|
||||
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
|
||||
|
||||
@Value("${system.serverCertificate.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
|
||||
private String organizationName;
|
||||
|
||||
@Value("${system.serverCertificate.validity:365}")
|
||||
private int validityDays;
|
||||
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
private Path getKeystorePath() {
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
return Files.exists(getKeystorePath());
|
||||
}
|
||||
|
||||
public void initializeServerCertificate() {
|
||||
if (!enabled) {
|
||||
log.debug("Server certificate feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
try {
|
||||
generateServerCertificate();
|
||||
log.info("Generated new server certificate at: {}", keystorePath);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
}
|
||||
} else {
|
||||
log.info("Server certificate already exists at: {}", keystorePath);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) {
|
||||
keyStore.load(fis, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
return keyStore;
|
||||
}
|
||||
|
||||
public String getServerCertificatePassword() {
|
||||
return DEFAULT_PASSWORD;
|
||||
}
|
||||
|
||||
public X509Certificate getServerCertificate() throws Exception {
|
||||
KeyStore keyStore = getServerKeyStore();
|
||||
return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS);
|
||||
}
|
||||
|
||||
public byte[] getServerCertificatePublicKey() throws Exception {
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return cert.getEncoded();
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
|
||||
// Find the first private key entry
|
||||
String alias = null;
|
||||
for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) {
|
||||
if (uploadedKeyStore.isKeyEntry(a)) {
|
||||
alias = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alias == null) {
|
||||
throw new IllegalArgumentException("No private key found in uploaded certificate");
|
||||
}
|
||||
|
||||
// Create new keystore with our standard alias and password
|
||||
KeyStore newKeyStore = KeyStore.getInstance("PKCS12");
|
||||
newKeyStore.load(null, null);
|
||||
|
||||
PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray());
|
||||
Certificate[] chain = uploadedKeyStore.getCertificateChain(alias);
|
||||
|
||||
newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain);
|
||||
|
||||
// Save to server keystore location
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
log.info("Server certificate updated from uploaded file");
|
||||
}
|
||||
|
||||
public void deleteServerCertificate() throws Exception {
|
||||
Path keystorePath = getKeystorePath();
|
||||
if (Files.exists(keystorePath)) {
|
||||
Files.delete(keystorePath);
|
||||
log.info("Server certificate deleted");
|
||||
}
|
||||
}
|
||||
|
||||
public ServerCertificateInfo getServerCertificateInfo() throws Exception {
|
||||
if (!hasServerCertificate()) {
|
||||
return new ServerCertificateInfo(false, null, null, null, null);
|
||||
}
|
||||
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return new ServerCertificateInfo(
|
||||
true,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
cert.getNotBefore(),
|
||||
cert.getNotAfter());
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
// Certificate details
|
||||
X500Name subject =
|
||||
new X500Name(
|
||||
"CN=" + organizationName + " Server, O=" + organizationName + ", C=US");
|
||||
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Date notBefore = new Date();
|
||||
Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000));
|
||||
|
||||
// Build certificate
|
||||
JcaX509v3CertificateBuilder certBuilder =
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// 1) End-entity certificate, not a CA (critical)
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// 2) Key usage for PDF digital signatures (critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// 3) Extended key usage for document signing (non-critical, widely accepted)
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||
|
||||
// 4) Subject Key Identifier for chain building (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// 5) Authority Key Identifier for self-signed cert (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Sign certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
.setProvider("BC")
|
||||
.build(keyPair.getPrivate());
|
||||
|
||||
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||
|
||||
// Create keystore
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
DEFAULT_PASSWORD.toCharArray(),
|
||||
new Certificate[] {cert});
|
||||
|
||||
// Save keystore
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
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;
|
||||
@@ -20,6 +21,8 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -55,7 +58,23 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
|
||||
lenient().when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
lenient().when(security.getJwt()).thenReturn(jwtConfig);
|
||||
lenient().when(jwtConfig.isEnabled()).thenReturn(true);
|
||||
lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); // Default value
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void testKeystoreEnabled(boolean keystoreEnabled) {
|
||||
when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled);
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mockedStatic =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mockedStatic
|
||||
.when(InstallationPathConfig::getPrivateKeyPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager);
|
||||
|
||||
assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,7 +177,7 @@ class KeyPersistenceServiceInterfaceTest {
|
||||
|
||||
@Test
|
||||
void testGetKeyPairWhenKeystoreDisabled() {
|
||||
when(jwtConfig.isEnabled()).thenReturn(false);
|
||||
when(jwtConfig.isEnableKeystore()).thenReturn(false);
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mockedStatic =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
|
||||
Reference in New Issue
Block a user