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:
Anthony Stirling
2025-11-06 17:29:34 +00:00
committed by GitHub
co-authored by James Brunton
parent f5c67a3239
commit ac3e10eb99
64 changed files with 5269 additions and 461 deletions
@@ -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
}
@@ -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;
}
}
@@ -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
@@ -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;
}
}
@@ -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 = "";
}
@@ -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();
@@ -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 {
@@ -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(
@@ -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;
}
@@ -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()));
}
}
}
@@ -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
@@ -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"
};
@@ -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();
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(inviteUrl, inviteUrl, expiresAt);
sendPlainEmail(to, subject, body, true);
}
}
@@ -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);
}
}
}
@@ -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);