mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add audit system, invite links, and usage analytics (#4749)
# Description of Changes New Features Audit System: Complete audit logging with dashboard, event tracking, and export capabilities Invite Links: Secure invite system with email notifications and expiration Usage Analytics: Endpoint usage statistics and visualization License Management: User counting with grandfathering and license enforcement ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f5c67a3239
commit
ac3e10eb99
+3
-5
@@ -1,17 +1,15 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */
|
||||
/** Configuration to enable scheduling for the audit system. */
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository")
|
||||
@EnableScheduling
|
||||
public class AuditJpaConfig {
|
||||
// This configuration enables JPA repositories in the specified package
|
||||
// and enables scheduling for audit cleanup tasks
|
||||
// This configuration enables scheduling for audit cleanup tasks
|
||||
// JPA repositories are now managed by DatabaseConfig to avoid conflicts
|
||||
// No additional beans or methods needed
|
||||
}
|
||||
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
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.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** REST API controller for audit data used by React frontend. */
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class AuditRestController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters. Maps to frontend's getEvents() call.
|
||||
*
|
||||
* @param page Page number (0-indexed)
|
||||
* @param pageSize Number of items per page
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username (principal)
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return Paginated audit events response
|
||||
*/
|
||||
@GetMapping("/audit-events")
|
||||
public ResponseEntity<AuditEventsResponse> getAuditEvents(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
|
||||
Page<PersistentAuditEvent> events;
|
||||
|
||||
// Apply filters based on provided parameters
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
||||
username, eventType, start, end, pageable);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findByPrincipalAndType(username, eventType, pageable);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTimestampBetween(
|
||||
username, start, end, pageable);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
||||
} else if (eventType != null) {
|
||||
events = auditRepository.findByType(eventType, pageable);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findByPrincipal(username, pageable);
|
||||
} else {
|
||||
events = auditRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
// Convert to response format expected by frontend
|
||||
List<AuditEventDto> eventDtos =
|
||||
events.getContent().stream().map(this::convertToDto).collect(Collectors.toList());
|
||||
|
||||
AuditEventsResponse response =
|
||||
AuditEventsResponse.builder()
|
||||
.events(eventDtos)
|
||||
.totalEvents((int) events.getTotalElements())
|
||||
.page(events.getNumber())
|
||||
.pageSize(events.getSize())
|
||||
.totalPages(events.getTotalPages())
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for dashboard. Maps to frontend's getChartsData() call.
|
||||
*
|
||||
* @param period Time period for charts (day/week/month)
|
||||
* @return Chart data for events by type, user, and over time
|
||||
*/
|
||||
@GetMapping("/audit-charts")
|
||||
public ResponseEntity<AuditChartsData> getAuditCharts(
|
||||
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||
|
||||
// Calculate days based on period
|
||||
int days;
|
||||
switch (period.toLowerCase()) {
|
||||
case "day":
|
||||
days = 1;
|
||||
break;
|
||||
case "month":
|
||||
days = 30;
|
||||
break;
|
||||
case "week":
|
||||
default:
|
||||
days = 7;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get events from the specified period
|
||||
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 (user)
|
||||
Map<String, Long> eventsByUser =
|
||||
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()));
|
||||
|
||||
// Convert to ChartData format
|
||||
ChartData eventsByTypeChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(eventsByType.keySet()))
|
||||
.values(
|
||||
eventsByType.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
ChartData eventsByUserChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(eventsByUser.keySet()))
|
||||
.values(
|
||||
eventsByUser.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
// Sort events by day for time series
|
||||
TreeMap<String, Long> sortedEventsByDay = new TreeMap<>(eventsByDay);
|
||||
ChartData eventsOverTimeChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(sortedEventsByDay.keySet()))
|
||||
.values(
|
||||
sortedEventsByDay.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
AuditChartsData chartsData =
|
||||
AuditChartsData.builder()
|
||||
.eventsByType(eventsByTypeChart)
|
||||
.eventsByUser(eventsByUserChart)
|
||||
.eventsOverTime(eventsOverTimeChart)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(chartsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available event types for filtering. Maps to frontend's getEventTypes() call.
|
||||
*
|
||||
* @return List of unique event types
|
||||
*/
|
||||
@GetMapping("/audit-event-types")
|
||||
public ResponseEntity<List<String>> getEventTypes() {
|
||||
// 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);
|
||||
|
||||
List<String> result = combinedTypes.stream().sorted().collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of users for filtering. Maps to frontend's getUsers() call.
|
||||
*
|
||||
* @return List of unique usernames
|
||||
*/
|
||||
@GetMapping("/audit-users")
|
||||
public ResponseEntity<List<String>> getUsers() {
|
||||
// Use the countByPrincipal query to get unique principals
|
||||
List<Object[]> principalCounts = auditRepository.countByPrincipal();
|
||||
|
||||
List<String> users =
|
||||
principalCounts.stream()
|
||||
.map(arr -> (String) arr[0])
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
|
||||
*
|
||||
* @param format Export format (csv or json)
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return File download response
|
||||
*/
|
||||
@GetMapping("/audit-export")
|
||||
public ResponseEntity<byte[]> exportAuditData(
|
||||
@RequestParam(value = "format", defaultValue = "csv") String format,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@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 getAuditEvents
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
username, eventType, start, end);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByTypeAndTimestampBetweenForExport(
|
||||
eventType, start, end);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
username, 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 (eventType != null) {
|
||||
events = auditRepository.findByTypeForExport(eventType);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(username);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Export based on format
|
||||
if ("json".equalsIgnoreCase(format)) {
|
||||
return exportAsJson(events);
|
||||
} else {
|
||||
return exportAsCsv(events);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private AuditEventDto convertToDto(PersistentAuditEvent event) {
|
||||
// Parse the JSON data field if present
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
if (event.getData() != null && !event.getData().isEmpty()) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.getData(), Map.class);
|
||||
details = parsed;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to parse audit event data as JSON: {}", event.getData());
|
||||
details.put("rawData", event.getData());
|
||||
}
|
||||
}
|
||||
|
||||
return AuditEventDto.builder()
|
||||
.id(String.valueOf(event.getId()))
|
||||
.timestamp(event.getTimestamp().toString())
|
||||
.eventType(event.getType())
|
||||
.username(event.getPrincipal())
|
||||
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
|
||||
.details(details)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
|
||||
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();
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
|
||||
try {
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeCSV(String field) {
|
||||
if (field == null) {
|
||||
return "";
|
||||
}
|
||||
// Replace double quotes with two double quotes and wrap in quotes
|
||||
return "\"" + field.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
// DTOs for response formatting
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditEventsResponse {
|
||||
private List<AuditEventDto> events;
|
||||
private int totalEvents;
|
||||
private int page;
|
||||
private int pageSize;
|
||||
private int totalPages;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditEventDto {
|
||||
private String id;
|
||||
private String timestamp;
|
||||
private String eventType;
|
||||
private String username;
|
||||
private String ipAddress;
|
||||
private Map<String, Object> details;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditChartsData {
|
||||
private ChartData eventsByType;
|
||||
private ChartData eventsByUser;
|
||||
private ChartData eventsOverTime;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class ChartData {
|
||||
private List<String> labels;
|
||||
private List<Integer> values;
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -39,6 +39,7 @@ 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.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@@ -50,6 +51,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@@ -64,6 +66,8 @@ public class ProprietaryUIDataController {
|
||||
private final DatabaseService databaseService;
|
||||
private final boolean runningEE;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -74,7 +78,9 @@ public class ProprietaryUIDataController {
|
||||
SessionRepository sessionRepository,
|
||||
DatabaseService databaseService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -84,6 +90,8 @@ public class ProprietaryUIDataController {
|
||||
this.databaseService = databaseService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.runningEE = runningEE;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@@ -262,6 +270,13 @@ public class ProprietaryUIDataController {
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
// Calculate license limits
|
||||
int maxAllowedUsers = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int grandfatheredCount = licenseSettingsService.getDisplayGrandfatheredCount();
|
||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
data.setUsers(sortedUsers);
|
||||
data.setCurrentUsername(authentication.getName());
|
||||
@@ -273,6 +288,11 @@ public class ProprietaryUIDataController {
|
||||
data.setDisabledUsers(disabledUsers);
|
||||
data.setTeams(allTeams);
|
||||
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
|
||||
data.setMaxAllowedUsers(maxAllowedUsers);
|
||||
data.setAvailableSlots(availableSlots);
|
||||
data.setGrandfatheredUserCount(grandfatheredCount);
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -445,6 +465,11 @@ public class ProprietaryUIDataController {
|
||||
private int disabledUsers;
|
||||
private List<Team> teams;
|
||||
private int maxPaidUsers;
|
||||
private int maxAllowedUsers;
|
||||
private long availableSlots;
|
||||
private int grandfatheredUserCount;
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** REST API controller for usage analytics data used by React frontend. */
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class UsageRestController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
|
||||
* events to generate usage statistics.
|
||||
*
|
||||
* @param limit Optional limit on number of endpoints to return
|
||||
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
|
||||
* auth), or "ui" (non-API endpoints)
|
||||
* @return Endpoint statistics response
|
||||
*/
|
||||
@GetMapping("/usage-endpoint-statistics")
|
||||
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
|
||||
|
||||
// Get all HTTP_REQUEST audit events
|
||||
List<PersistentAuditEvent> httpEvents =
|
||||
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
|
||||
|
||||
// Count visits per endpoint
|
||||
Map<String, Long> endpointCounts = new HashMap<>();
|
||||
|
||||
for (PersistentAuditEvent event : httpEvents) {
|
||||
String endpoint = extractEndpointFromAuditData(event.getData());
|
||||
if (endpoint != null) {
|
||||
// Apply data type filter
|
||||
if (!shouldIncludeEndpoint(endpoint, dataType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
endpointCounts.merge(endpoint, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
long totalVisits = endpointCounts.values().stream().mapToLong(Long::longValue).sum();
|
||||
int totalEndpoints = endpointCounts.size();
|
||||
|
||||
// Convert to list and sort by visit count (descending)
|
||||
List<EndpointStatistic> statistics =
|
||||
endpointCounts.entrySet().stream()
|
||||
.map(
|
||||
entry -> {
|
||||
String endpoint = entry.getKey();
|
||||
long visits = entry.getValue();
|
||||
double percentage =
|
||||
totalVisits > 0 ? (visits * 100.0 / totalVisits) : 0.0;
|
||||
|
||||
return EndpointStatistic.builder()
|
||||
.endpoint(endpoint)
|
||||
.visits((int) visits)
|
||||
.percentage(Math.round(percentage * 10.0) / 10.0)
|
||||
.build();
|
||||
})
|
||||
.sorted(Comparator.comparingInt(EndpointStatistic::getVisits).reversed())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit != null && limit > 0 && statistics.size() > limit) {
|
||||
statistics = statistics.subList(0, limit);
|
||||
}
|
||||
|
||||
EndpointStatisticsResponse response =
|
||||
EndpointStatisticsResponse.builder()
|
||||
.endpoints(statistics)
|
||||
.totalEndpoints(totalEndpoints)
|
||||
.totalVisits((int) totalVisits)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the endpoint path from the audit event's data field. The data field contains JSON
|
||||
* with an "endpoint" or "path" key.
|
||||
*
|
||||
* @param dataJson JSON string from audit event
|
||||
* @return Endpoint path or null if not found
|
||||
*/
|
||||
private String extractEndpointFromAuditData(String dataJson) {
|
||||
if (dataJson == null || dataJson.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = objectMapper.readValue(dataJson, Map.class);
|
||||
|
||||
// Try common keys for endpoint path
|
||||
Object endpoint = data.get("endpoint");
|
||||
if (endpoint != null) {
|
||||
return normalizeEndpoint(endpoint.toString());
|
||||
}
|
||||
|
||||
Object path = data.get("path");
|
||||
if (path != null) {
|
||||
return normalizeEndpoint(path.toString());
|
||||
}
|
||||
|
||||
// Fallback: check if there's a request-related key
|
||||
Object requestUri = data.get("requestUri");
|
||||
if (requestUri != null) {
|
||||
return normalizeEndpoint(requestUri.toString());
|
||||
}
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
log.debug("Failed to parse audit data JSON: {}", dataJson, e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize endpoint paths by removing query strings and standardizing format.
|
||||
*
|
||||
* @param endpoint Raw endpoint path
|
||||
* @return Normalized endpoint path
|
||||
*/
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove query string
|
||||
int queryIndex = endpoint.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
endpoint = endpoint.substring(0, queryIndex);
|
||||
}
|
||||
|
||||
// Ensure it starts with /
|
||||
if (!endpoint.startsWith("/")) {
|
||||
endpoint = "/" + endpoint;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an endpoint should be included based on the data type filter.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @param dataType The filter type: "all", "api", or "ui"
|
||||
* @return true if the endpoint should be included, false otherwise
|
||||
*/
|
||||
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
|
||||
if ("all".equalsIgnoreCase(dataType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean isApiEndpoint = isApiEndpoint(endpoint);
|
||||
|
||||
if ("api".equalsIgnoreCase(dataType)) {
|
||||
return isApiEndpoint;
|
||||
} else if ("ui".equalsIgnoreCase(dataType)) {
|
||||
return !isApiEndpoint;
|
||||
}
|
||||
|
||||
// Default to including all if unrecognized type
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
|
||||
* /api/v1/auth/* paths.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
|
||||
*/
|
||||
private boolean isApiEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it starts with /api/v1/
|
||||
if (!endpoint.startsWith("/api/v1/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude auth endpoints
|
||||
if (endpoint.startsWith("/api/v1/auth/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// DTOs for response formatting
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class EndpointStatisticsResponse {
|
||||
private List<EndpointStatistic> endpoints;
|
||||
private int totalEndpoints;
|
||||
private int totalVisits;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class EndpointStatistic {
|
||||
private String endpoint;
|
||||
private int visits;
|
||||
private double percentage;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* Entity to store user license settings in the database. This is a singleton entity (only one row
|
||||
* should exist). Tracks grandfathered user counts and license limits.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_license_settings")
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
public class UserLicenseSettings implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/**
|
||||
* The number of users that existed in the database when grandfathering was initialized. This
|
||||
* value is set once during initial setup and should NEVER be modified afterwards.
|
||||
*/
|
||||
@Column(name = "grandfathered_user_count", nullable = false)
|
||||
private int grandfatheredUserCount = 0;
|
||||
|
||||
/**
|
||||
* Flag to indicate that grandfathering has been initialized and locked. Once true, the
|
||||
* grandfatheredUserCount should never change. This prevents manipulation by deleting/recreating
|
||||
* the table.
|
||||
*/
|
||||
@Column(name = "grandfathering_locked", nullable = false)
|
||||
private boolean grandfatheringLocked = false;
|
||||
|
||||
/**
|
||||
* Maximum number of users allowed by the current license. This is updated when the license key
|
||||
* is validated.
|
||||
*/
|
||||
@Column(name = "license_max_users", nullable = false)
|
||||
private int licenseMaxUsers = 0;
|
||||
|
||||
/**
|
||||
* Random salt used when generating signatures. Makes it harder to recompute the signature when
|
||||
* manually editing the table.
|
||||
*/
|
||||
@Column(name = "integrity_salt", nullable = false, length = 64)
|
||||
private String integritySalt = "";
|
||||
|
||||
/**
|
||||
* Signed representation of {@code grandfatheredUserCount}. Stores the original value alongside
|
||||
* a secret-backed HMAC so we can detect tampering and restore the correct count.
|
||||
*/
|
||||
@Column(name = "grandfathered_user_signature", nullable = false, length = 256)
|
||||
private String grandfatheredUserSignature = "";
|
||||
}
|
||||
+8
@@ -21,6 +21,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -34,6 +35,7 @@ public class InitialSecuritySetup {
|
||||
private final TeamService teamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@@ -50,12 +52,18 @@ public class InitialSecuritySetup {
|
||||
configureJWTSettings();
|
||||
assignUsersToDefaultTeamIfMissing();
|
||||
initializeInternalApiUser();
|
||||
initializeUserLicenseSettings();
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.error("Failed to initialize security setup.", e);
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeUserLicenseSettings() {
|
||||
licenseSettingsService.initializeGrandfatheredCount();
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
private void configureJWTSettings() {
|
||||
ApplicationProperties.Security.Jwt jwtProperties =
|
||||
applicationProperties.getSecurity().getJwt();
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@EnableJpaRepositories(
|
||||
basePackages = {
|
||||
"stirling.software.proprietary.security.database.repository",
|
||||
"stirling.software.proprietary.security.repository"
|
||||
"stirling.software.proprietary.security.repository",
|
||||
"stirling.software.proprietary.repository"
|
||||
})
|
||||
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
|
||||
public class DatabaseConfig {
|
||||
|
||||
-1
@@ -315,7 +315,6 @@ public class SecurityConfiguration {
|
||||
req -> {
|
||||
String uri = req.getRequestURI();
|
||||
String contextPath = req.getContextPath();
|
||||
|
||||
// Check if it's a public auth endpoint or static
|
||||
// resource
|
||||
return RequestUriUtils.isStaticResource(
|
||||
|
||||
+37
-4
@@ -5,13 +5,20 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -23,21 +30,36 @@ public class LicenseKeyChecker {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private License premiumEnabledResult = License.NORMAL;
|
||||
|
||||
public LicenseKeyChecker(
|
||||
KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) {
|
||||
KeygenLicenseVerifier licenseService,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Lazy UserLicenseSettingsService licenseSettingsService) {
|
||||
this.licenseService = licenseService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.checkLicense();
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
evaluateLicense();
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady() {
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds
|
||||
public void checkLicensePeriodically() {
|
||||
checkLicense();
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
private void checkLicense() {
|
||||
private void evaluateLicense() {
|
||||
if (!applicationProperties.getPremium().isEnabled()) {
|
||||
premiumEnabledResult = License.NORMAL;
|
||||
} else {
|
||||
@@ -58,6 +80,10 @@ public class LicenseKeyChecker {
|
||||
}
|
||||
}
|
||||
|
||||
private void synchronizeLicenseSettings() {
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
private String getLicenseKeyContent(String keyOrFilePath) {
|
||||
if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) {
|
||||
log.error("License key is not specified");
|
||||
@@ -85,6 +111,13 @@ public class LicenseKeyChecker {
|
||||
return keyOrFilePath;
|
||||
}
|
||||
|
||||
public void updateLicenseKey(String newKey) throws IOException {
|
||||
applicationProperties.getPremium().setKey(newKey);
|
||||
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
public License getPremiumLicenseEnabledResult() {
|
||||
return premiumEnabledResult;
|
||||
}
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
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.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
public class InviteLinkController {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Optional<EmailService> emailService;
|
||||
|
||||
/**
|
||||
* Generate a new invite link (admin only)
|
||||
*
|
||||
* @param email The email address to invite
|
||||
* @param role The role to assign (default: ROLE_USER)
|
||||
* @param teamId The team to assign (optional, uses default team if not provided)
|
||||
* @param expiryHours Custom expiry hours (optional, uses default from config)
|
||||
* @param sendEmail Whether to send the invite link via email (default: false)
|
||||
* @param principal The authenticated admin user
|
||||
* @param request The HTTP request
|
||||
* @return ResponseEntity with the invite link or error
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/generate")
|
||||
public ResponseEntity<?> generateInviteLink(
|
||||
@RequestParam(name = "email", required = false) String email,
|
||||
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@RequestParam(name = "expiryHours", required = false) Integer expiryHours,
|
||||
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
|
||||
Principal principal,
|
||||
HttpServletRequest request) {
|
||||
|
||||
try {
|
||||
// Check if email invites are enabled
|
||||
if (!applicationProperties.getMail().isEnableInvites()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email invites are not enabled"));
|
||||
}
|
||||
|
||||
// If email is provided, validate and check for conflicts
|
||||
if (email != null && !email.trim().isEmpty()) {
|
||||
// Validate email format
|
||||
if (!email.contains("@")) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
email = email.trim().toLowerCase();
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(email)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
// Check if there's already an active invite for this email
|
||||
Optional<InviteToken> existingInvite = inviteTokenRepository.findByEmail(email);
|
||||
if (existingInvite.isPresent() && existingInvite.get().isValid()) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"An active invite already exists for this email address"));
|
||||
}
|
||||
|
||||
// If sendEmail is requested but no email provided, reject
|
||||
if (sendEmail) {
|
||||
// Email will be sent
|
||||
}
|
||||
} else {
|
||||
// No email provided - this is a general invite link
|
||||
email = null; // Ensure it's null, not empty string
|
||||
|
||||
// Cannot send email if no email address provided
|
||||
if (sendEmail) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot send email without an email address"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check license limits
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now());
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
|
||||
if (currentUserCount + activeInvites >= maxUsers) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"License limit reached ("
|
||||
+ (currentUserCount + activeInvites)
|
||||
+ "/"
|
||||
+ maxUsers
|
||||
+ " users). Contact your administrator to upgrade your license."));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate role
|
||||
try {
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified"));
|
||||
}
|
||||
|
||||
// Determine team
|
||||
Long effectiveTeamId = teamId;
|
||||
if (effectiveTeamId == null) {
|
||||
Team defaultTeam =
|
||||
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
if (defaultTeam != null) {
|
||||
effectiveTeamId = defaultTeam.getId();
|
||||
}
|
||||
} else {
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team"));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token
|
||||
String token = UUID.randomUUID().toString();
|
||||
|
||||
// Determine expiry time
|
||||
int effectiveExpiryHours =
|
||||
(expiryHours != null && expiryHours > 0)
|
||||
? expiryHours
|
||||
: applicationProperties.getMail().getInviteLinkExpiryHours();
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusHours(effectiveExpiryHours);
|
||||
|
||||
// Create invite token
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setToken(token);
|
||||
inviteToken.setEmail(email);
|
||||
inviteToken.setRole(role);
|
||||
inviteToken.setTeamId(effectiveTeamId);
|
||||
inviteToken.setExpiresAt(expiresAt);
|
||||
inviteToken.setCreatedBy(principal.getName());
|
||||
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
|
||||
// Build invite URL
|
||||
// Use configured frontend URL if available, otherwise fall back to backend URL
|
||||
String baseUrl;
|
||||
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
|
||||
// Use configured frontend URL (remove trailing slash if present)
|
||||
baseUrl =
|
||||
configuredFrontendUrl.endsWith("/")
|
||||
? configuredFrontendUrl.substring(
|
||||
0, configuredFrontendUrl.length() - 1)
|
||||
: configuredFrontendUrl;
|
||||
} else {
|
||||
// Fall back to backend URL from request
|
||||
baseUrl =
|
||||
request.getScheme()
|
||||
+ "://"
|
||||
+ request.getServerName()
|
||||
+ (request.getServerPort() != 80 && request.getServerPort() != 443
|
||||
? ":" + request.getServerPort()
|
||||
: "");
|
||||
}
|
||||
String inviteUrl = baseUrl + "/invite?token=" + token;
|
||||
|
||||
log.info("Generated invite link for {} by {}", email, principal.getName());
|
||||
|
||||
// Optionally send email
|
||||
boolean emailSent = false;
|
||||
String emailError = null;
|
||||
if (sendEmail) {
|
||||
if (!emailService.isPresent()) {
|
||||
emailError = "Email service is not configured";
|
||||
log.warn("Cannot send invite email: Email service not configured");
|
||||
} else {
|
||||
try {
|
||||
emailService
|
||||
.get()
|
||||
.sendInviteLinkEmail(email, inviteUrl, expiresAt.toString());
|
||||
emailSent = true;
|
||||
log.info("Sent invite link email to: {}", email);
|
||||
} catch (Exception emailEx) {
|
||||
emailError = emailEx.getMessage();
|
||||
log.error(
|
||||
"Failed to send invite email to {}: {}",
|
||||
email,
|
||||
emailEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("token", token);
|
||||
response.put("inviteUrl", inviteUrl);
|
||||
response.put("email", email);
|
||||
response.put("expiresAt", expiresAt.toString());
|
||||
response.put("expiryHours", effectiveExpiryHours);
|
||||
if (sendEmail) {
|
||||
response.put("emailSent", emailSent);
|
||||
if (emailError != null) {
|
||||
response.put("emailError", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate invite link: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to generate invite link: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active invite links (admin only)
|
||||
*
|
||||
* @return List of active invite tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<?> listInviteLinks() {
|
||||
try {
|
||||
List<InviteToken> activeInvites =
|
||||
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
|
||||
|
||||
List<Map<String, Object>> inviteList =
|
||||
activeInvites.stream()
|
||||
.map(
|
||||
invite -> {
|
||||
Map<String, Object> inviteMap = new HashMap<>();
|
||||
inviteMap.put("id", invite.getId());
|
||||
inviteMap.put("email", invite.getEmail());
|
||||
inviteMap.put("role", invite.getRole());
|
||||
inviteMap.put("teamId", invite.getTeamId());
|
||||
inviteMap.put("createdBy", invite.getCreatedBy());
|
||||
inviteMap.put(
|
||||
"createdAt", invite.getCreatedAt().toString());
|
||||
inviteMap.put(
|
||||
"expiresAt", invite.getExpiresAt().toString());
|
||||
return inviteMap;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(Map.of("invites", inviteList));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list invite links: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to list invite links"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*
|
||||
* @param inviteId The invite token ID to revoke
|
||||
* @return Success or error response
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@DeleteMapping("/revoke/{inviteId}")
|
||||
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
|
||||
try {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findById(inviteId);
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invite not found"));
|
||||
}
|
||||
|
||||
inviteTokenRepository.deleteById(inviteId);
|
||||
log.info("Revoked invite link ID: {}", inviteId);
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully"));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to revoke invite link: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to revoke invite link"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invite tokens (admin only)
|
||||
*
|
||||
* @return Number of deleted tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/cleanup")
|
||||
public ResponseEntity<?> cleanupExpiredInvites() {
|
||||
try {
|
||||
List<InviteToken> expiredInvites =
|
||||
inviteTokenRepository.findAll().stream()
|
||||
.filter(invite -> !invite.isValid())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int count = expiredInvites.size();
|
||||
inviteTokenRepository.deleteAll(expiredInvites);
|
||||
|
||||
log.info("Cleaned up {} expired invite tokens", count);
|
||||
|
||||
return ResponseEntity.ok(Map.of("deletedCount", count));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to cleanup expired invites: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to cleanup expired invites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invite token (public endpoint)
|
||||
*
|
||||
* @param token The invite token to validate
|
||||
* @return Invite details if valid, error otherwise
|
||||
*/
|
||||
@GetMapping("/validate/{token}")
|
||||
public ResponseEntity<?> validateInviteToken(@PathVariable String token) {
|
||||
try {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
}
|
||||
|
||||
// Check if user already exists (only if email is pre-set)
|
||||
if (invite.getEmail() != null
|
||||
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("email", invite.getEmail());
|
||||
response.put("role", invite.getRole());
|
||||
response.put("expiresAt", invite.getExpiresAt().toString());
|
||||
response.put("emailRequired", invite.getEmail() == null);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to validate invite token: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to validate invite link"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invite and create user account (public endpoint)
|
||||
*
|
||||
* @param token The invite token
|
||||
* @param email The email address (required if not pre-set in invite)
|
||||
* @param password The password to set for the new account
|
||||
* @return Success or error response
|
||||
*/
|
||||
@PostMapping("/accept/{token}")
|
||||
public ResponseEntity<?> acceptInvite(
|
||||
@PathVariable String token,
|
||||
@RequestParam(name = "email", required = false) String email,
|
||||
@RequestParam(name = "password") String password) {
|
||||
try {
|
||||
// Validate password
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
}
|
||||
|
||||
// Determine the email to use
|
||||
String effectiveEmail = invite.getEmail();
|
||||
if (effectiveEmail == null) {
|
||||
// Email not pre-set, must be provided by user
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email address is required"));
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!email.contains("@")) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
effectiveEmail = email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
userService.saveUser(
|
||||
effectiveEmail,
|
||||
password,
|
||||
invite.getTeamId(),
|
||||
invite.getRole(),
|
||||
false); // Don't force password change
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
invite.setUsedAt(LocalDateTime.now());
|
||||
inviteTokenRepository.save(invite);
|
||||
|
||||
log.info(
|
||||
"User account created via invite link: {} with role: {}",
|
||||
effectiveEmail,
|
||||
invite.getRole());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "Account created successfully", "username", effectiveEmail));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-21
@@ -43,6 +43,7 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@@ -56,6 +57,7 @@ public class UserController {
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final Optional<EmailService> emailService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
@@ -80,10 +82,9 @@ public class UserController {
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null
|
||||
|| usernameAndPass.getPassword().length() < 6) {
|
||||
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters"));
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
@@ -316,11 +317,17 @@ public class UserController {
|
||||
"error",
|
||||
"Invalid username format. Username must be 3-50 characters."));
|
||||
}
|
||||
if (applicationProperties.getPremium().isEnabled()
|
||||
&& applicationProperties.getPremium().getMaxUsers()
|
||||
<= userService.getTotalUsersCount()) {
|
||||
if (licenseSettingsService.wouldExceedLimit(1)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Maximum number of users reached for your license."));
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Maximum number of users reached. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
@@ -413,20 +420,19 @@ public class UserController {
|
||||
}
|
||||
|
||||
// Check license limits
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
long availableSlots = maxUsers - currentUserCount;
|
||||
if (availableSlots < emailArray.length) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Not enough user slots available. Available: "
|
||||
+ availableSlots
|
||||
+ ", Requested: "
|
||||
+ emailArray.length));
|
||||
}
|
||||
if (licenseSettingsService.wouldExceedLimit(emailArray.length)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Not enough user slots available. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available: "
|
||||
+ availableSlots
|
||||
+ ", Requested: "
|
||||
+ emailArray.length));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
|
||||
+3
@@ -251,6 +251,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String[] permitAllPatterns = {
|
||||
contextPath + "/login",
|
||||
contextPath + "/register",
|
||||
contextPath + "/invite",
|
||||
contextPath + "/error",
|
||||
contextPath + "/images/",
|
||||
contextPath + "/public/",
|
||||
@@ -263,6 +264,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/api/v1/invite/validate",
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/site.webmanifest"
|
||||
};
|
||||
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "invite_tokens")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class InviteToken implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "token", unique = true, nullable = false, length = 100)
|
||||
private String token;
|
||||
|
||||
@Column(name = "email", nullable = true, length = 255)
|
||||
private String email; // Optional - if not set, user can provide their own email
|
||||
|
||||
@Column(name = "role", nullable = false, length = 50)
|
||||
private String role;
|
||||
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "used", nullable = false)
|
||||
private boolean used = false;
|
||||
|
||||
@Column(name = "created_by", nullable = false, length = 255)
|
||||
private String createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private LocalDateTime usedAt;
|
||||
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return !used && !isExpired();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
|
||||
@Repository
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, Long> {
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
Optional<InviteToken> findByEmail(String email);
|
||||
|
||||
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
|
||||
|
||||
List<InviteToken> findByCreatedBy(String createdBy);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now")
|
||||
void deleteExpiredTokens(@Param("now") LocalDateTime now);
|
||||
|
||||
@Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now")
|
||||
long countActiveInvites(@Param("now") LocalDateTime now);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
|
||||
@Repository
|
||||
public interface UserLicenseSettingsRepository extends JpaRepository<UserLicenseSettings, Long> {
|
||||
|
||||
/**
|
||||
* Finds the singleton UserLicenseSettings record.
|
||||
*
|
||||
* @return Optional containing the settings if they exist
|
||||
*/
|
||||
default Optional<UserLicenseSettings> findSettings() {
|
||||
return findById(UserLicenseSettings.SINGLETON_ID);
|
||||
}
|
||||
}
|
||||
+54
@@ -159,4 +159,58 @@ public class EmailService {
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation link email to a new user.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param inviteUrl The full URL for accepting the invite
|
||||
* @param expiresAt The expiration timestamp
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt)
|
||||
throws MessagingException {
|
||||
String subject = "You've been invited to Stirling PDF";
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:</p>
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Accept Invitation</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
|
||||
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
|
||||
%s
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠️ Important:</strong> This invitation link will expire on %s. Please complete your registration before then.</p>
|
||||
</div>
|
||||
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(inviteUrl, inviteUrl, expiresAt);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Service for managing user license settings and grandfathering logic.
|
||||
*
|
||||
* <p>User limit calculation:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Default limit: 5 users
|
||||
* <li>Grandfathered limit: max(5, existing user count at initialization)
|
||||
* <li>With pro license: grandfathered limit + license maxUsers
|
||||
* <li>Without pro license: grandfathered limit
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserLicenseSettingsService {
|
||||
|
||||
private static final int DEFAULT_USER_LIMIT = 5;
|
||||
private static final String SIGNATURE_SEPARATOR = ":";
|
||||
private static final String DEFAULT_INTEGRITY_SECRET = "stirling-pdf-user-license-guard";
|
||||
|
||||
private final UserLicenseSettingsRepository settingsRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Gets the current user license settings, creating them if they don't exist.
|
||||
*
|
||||
* @return The current settings
|
||||
*/
|
||||
@Transactional
|
||||
public UserLicenseSettings getOrCreateSettings() {
|
||||
return settingsRepository
|
||||
.findSettings()
|
||||
.orElseGet(
|
||||
() -> {
|
||||
log.info("Initializing user license settings");
|
||||
UserLicenseSettings settings = new UserLicenseSettings();
|
||||
settings.setId(UserLicenseSettings.SINGLETON_ID);
|
||||
settings.setGrandfatheredUserCount(0);
|
||||
settings.setLicenseMaxUsers(0);
|
||||
settings.setGrandfatheringLocked(false);
|
||||
settings.setIntegritySalt(UUID.randomUUID().toString());
|
||||
settings.setGrandfatheredUserSignature("");
|
||||
return settingsRepository.save(settings);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the grandfathered user count if not already set. This should be called on
|
||||
* application startup.
|
||||
*
|
||||
* <p>IMPORTANT: Once grandfathering is locked, this value can NEVER be changed. This prevents
|
||||
* manipulation by deleting the settings table.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfatheringLocked is true: Skip initialization (already set permanently)
|
||||
* <li>If users exist in database: Set to max(5, current user count) - this is an existing
|
||||
* installation
|
||||
* <li>If no users exist: Set to 5 (default) - this is a fresh installation
|
||||
* <li>Lock grandfathering immediately after setting
|
||||
* </ul>
|
||||
*/
|
||||
@Transactional
|
||||
public void initializeGrandfatheredCount() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
boolean changed = ensureIntegritySalt(settings);
|
||||
|
||||
// CRITICAL: Never change grandfathering once it's locked
|
||||
if (settings.isGrandfatheringLocked()) {
|
||||
if (settings.getGrandfatheredUserSignature() == null
|
||||
|| settings.getGrandfatheredUserSignature().isBlank()) {
|
||||
settings.setGrandfatheredUserSignature(
|
||||
generateSignature(settings.getGrandfatheredUserCount(), settings));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
settingsRepository.save(settings);
|
||||
}
|
||||
log.debug(
|
||||
"Grandfathering is locked. Current grandfathered count: {}",
|
||||
settings.getGrandfatheredUserCount());
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is an existing installation or fresh install
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
boolean isExistingInstallation = currentUserCount > 0;
|
||||
|
||||
int grandfatheredCount;
|
||||
if (isExistingInstallation) {
|
||||
// Existing installation (v2.0+ or has users) - grandfather current user count
|
||||
grandfatheredCount = Math.max(DEFAULT_USER_LIMIT, (int) currentUserCount);
|
||||
log.info(
|
||||
"Existing installation detected. Grandfathering {} users (current: {}, minimum:"
|
||||
+ " {})",
|
||||
grandfatheredCount,
|
||||
currentUserCount,
|
||||
DEFAULT_USER_LIMIT);
|
||||
} else {
|
||||
// Fresh installation - set to default
|
||||
grandfatheredCount = DEFAULT_USER_LIMIT;
|
||||
log.info(
|
||||
"Fresh installation detected. Setting default grandfathered limit: {}",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
// Set and LOCK the grandfathering permanently
|
||||
settings.setGrandfatheredUserCount(grandfatheredCount);
|
||||
settings.setGrandfatheringLocked(true);
|
||||
settings.setGrandfatheredUserSignature(generateSignature(grandfatheredCount, settings));
|
||||
settingsRepository.save(settings);
|
||||
|
||||
log.warn(
|
||||
"GRANDFATHERING LOCKED: {} users. This value can never be changed.",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the license max users from the application properties. This should be called when the
|
||||
* license is validated.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateLicenseMaxUsers() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int licenseMaxUsers = 0;
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
}
|
||||
|
||||
if (settings.getLicenseMaxUsers() != licenseMaxUsers) {
|
||||
settings.setLicenseMaxUsers(licenseMaxUsers);
|
||||
settingsRepository.save(settings);
|
||||
log.info("Updated license max users to: {}", licenseMaxUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and enforces the integrity of license settings. This ensures that even if someone
|
||||
* manually modifies the database, the grandfathering rules are still enforced.
|
||||
*/
|
||||
@Transactional
|
||||
public void validateSettingsIntegrity() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
boolean changed = ensureIntegritySalt(settings);
|
||||
|
||||
Optional<Integer> signedCountOpt = extractSignedCount(settings);
|
||||
boolean signatureValid =
|
||||
signedCountOpt.isPresent()
|
||||
&& signatureMatches(
|
||||
signedCountOpt.get(),
|
||||
settings.getGrandfatheredUserSignature(),
|
||||
settings);
|
||||
|
||||
int targetCount = settings.getGrandfatheredUserCount();
|
||||
String targetSignature = settings.getGrandfatheredUserSignature();
|
||||
|
||||
if (!signatureValid) {
|
||||
int restoredCount =
|
||||
signedCountOpt.orElseGet(
|
||||
() ->
|
||||
Math.max(
|
||||
DEFAULT_USER_LIMIT,
|
||||
(int) userService.getTotalUsersCount()));
|
||||
log.error(
|
||||
"Grandfathered user signature invalid or missing. Restoring locked count to {}.",
|
||||
restoredCount);
|
||||
targetCount = restoredCount;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
} else {
|
||||
int signedCount = signedCountOpt.get();
|
||||
if (targetCount != signedCount) {
|
||||
log.error(
|
||||
"Grandfathered user count ({}) was modified without signature update. Restoring to {}.",
|
||||
targetCount,
|
||||
signedCount);
|
||||
targetCount = signedCount;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetCount < DEFAULT_USER_LIMIT) {
|
||||
if (targetCount != DEFAULT_USER_LIMIT) {
|
||||
log.warn(
|
||||
"Grandfathered count ({}) is below minimum ({}). Enforcing minimum.",
|
||||
targetCount,
|
||||
DEFAULT_USER_LIMIT);
|
||||
}
|
||||
targetCount = DEFAULT_USER_LIMIT;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (targetSignature == null || targetSignature.isBlank()) {
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed
|
||||
|| settings.getGrandfatheredUserCount() != targetCount
|
||||
|| (targetSignature != null
|
||||
&& !targetSignature.equals(settings.getGrandfatheredUserSignature()))) {
|
||||
settings.setGrandfatheredUserCount(targetCount);
|
||||
settings.setGrandfatheredUserSignature(targetSignature);
|
||||
settingsRepository.save(settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum allowed users based on grandfathering rules.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Grandfathered limit = max(5, existing user count at initialization)
|
||||
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
|
||||
* <li>If premium disabled: total limit = grandfathered limit
|
||||
* </ul>
|
||||
*
|
||||
* @return Maximum number of users allowed
|
||||
*/
|
||||
public int calculateMaxAllowedUsers() {
|
||||
validateSettingsIntegrity();
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int grandfatheredLimit = settings.getGrandfatheredUserCount();
|
||||
if (grandfatheredLimit == 0) {
|
||||
// Fallback if not initialized yet - should not happen with validation
|
||||
log.warn("Grandfathered limit is 0, using default: {}", DEFAULT_USER_LIMIT);
|
||||
grandfatheredLimit = DEFAULT_USER_LIMIT;
|
||||
}
|
||||
|
||||
int totalLimit = grandfatheredLimit;
|
||||
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
|
||||
totalLimit,
|
||||
grandfatheredLimit,
|
||||
settings.getLicenseMaxUsers(),
|
||||
applicationProperties.getPremium().isEnabled());
|
||||
|
||||
return totalLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if adding new users would exceed the limit.
|
||||
*
|
||||
* @param newUsersCount Number of new users to add
|
||||
* @return true if the addition would exceed the limit
|
||||
*/
|
||||
public boolean wouldExceedLimit(int newUsersCount) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxAllowed = calculateMaxAllowedUsers();
|
||||
return (currentUserCount + newUsersCount) > maxAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of available user slots.
|
||||
*
|
||||
* @return Number of users that can still be added
|
||||
*/
|
||||
public long getAvailableUserSlots() {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxAllowed = calculateMaxAllowedUsers();
|
||||
return Math.max(0, maxAllowed - currentUserCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the grandfathered user count for display purposes. Returns only the excess users beyond
|
||||
* the base limit (5).
|
||||
*
|
||||
* <p>Examples:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfathered = 5: returns 0 (base amount, nothing special)
|
||||
* <li>If grandfathered = 10: returns 5 (5 extra users)
|
||||
* <li>If grandfathered = 15: returns 10 (10 extra users)
|
||||
* </ul>
|
||||
*
|
||||
* @return Number of grandfathered users beyond the base limit
|
||||
*/
|
||||
public int getDisplayGrandfatheredCount() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
int totalGrandfathered = settings.getGrandfatheredUserCount();
|
||||
return Math.max(0, totalGrandfathered - DEFAULT_USER_LIMIT);
|
||||
}
|
||||
|
||||
/** Gets the current settings. */
|
||||
public UserLicenseSettings getSettings() {
|
||||
return getOrCreateSettings();
|
||||
}
|
||||
|
||||
private boolean ensureIntegritySalt(UserLicenseSettings settings) {
|
||||
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
|
||||
settings.setIntegritySalt(UUID.randomUUID().toString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Optional<Integer> extractSignedCount(UserLicenseSettings settings) {
|
||||
String signature = settings.getGrandfatheredUserSignature();
|
||||
if (signature == null || signature.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String[] parts = signature.split(SIGNATURE_SEPARATOR, 2);
|
||||
if (parts.length != 2) {
|
||||
log.warn("Invalid grandfathered user signature format detected");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(Integer.parseInt(parts[0]));
|
||||
} catch (NumberFormatException ex) {
|
||||
log.warn("Unable to parse grandfathered user signature count", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean signatureMatches(int count, String signature, UserLicenseSettings settings) {
|
||||
if (signature == null || signature.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return generateSignature(count, settings).equals(signature);
|
||||
}
|
||||
|
||||
private String generateSignature(int count, UserLicenseSettings settings) {
|
||||
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
|
||||
throw new IllegalStateException("Integrity salt must be initialized before signing.");
|
||||
}
|
||||
String payload = buildSignaturePayload(count, settings.getIntegritySalt());
|
||||
String secret = deriveIntegritySecret();
|
||||
String digest = computeHmac(payload, secret);
|
||||
return count + SIGNATURE_SEPARATOR + digest;
|
||||
}
|
||||
|
||||
private String buildSignaturePayload(int count, String salt) {
|
||||
return count + SIGNATURE_SEPARATOR + salt;
|
||||
}
|
||||
|
||||
private String deriveIntegritySecret() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getKey());
|
||||
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID());
|
||||
appendIfPresent(builder, applicationProperties.getPremium().getKey());
|
||||
|
||||
if (builder.length() == 0) {
|
||||
builder.append(DEFAULT_INTEGRITY_SECRET);
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void appendIfPresent(StringBuilder builder, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(SIGNATURE_SEPARATOR);
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String computeHmac(String payload, String secret) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec =
|
||||
new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
mac.init(keySpec);
|
||||
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Failed to compute grandfathered user signature", e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -17,11 +17,13 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LicenseKeyCheckerTest {
|
||||
|
||||
@Mock private KeygenLicenseVerifier verifier;
|
||||
@Mock private UserLicenseSettingsService userLicenseSettingsService;
|
||||
|
||||
@Test
|
||||
void premiumDisabled_skipsVerification() {
|
||||
@@ -29,7 +31,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setEnabled(false);
|
||||
props.getPremium().setKey("dummy");
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
|
||||
verifyNoInteractions(verifier);
|
||||
@@ -42,7 +46,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setKey("abc");
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("abc");
|
||||
@@ -58,7 +64,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setKey("file:" + file.toString());
|
||||
when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE);
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.ENTERPRISE, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("filekey");
|
||||
@@ -71,7 +79,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setEnabled(true);
|
||||
props.getPremium().setKey("file:" + file.toString());
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
|
||||
verifyNoInteractions(verifier);
|
||||
|
||||
Reference in New Issue
Block a user