mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
settingsPage Init selfhost (#4734)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## 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
d2b38ef4b8
commit
d0c5d74471
+1
-1
@@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@EnterpriseEndpoint
|
||||
public class ProprietaryUIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -89,6 +88,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@Operation(summary = "Get audit dashboard data")
|
||||
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
|
||||
AuditDashboardData data = new AuditDashboardData();
|
||||
|
||||
+1
-6
@@ -39,7 +39,6 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
|
||||
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
@@ -74,7 +73,6 @@ public class SecurityConfiguration {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final FirstLoginFilter firstLoginFilter;
|
||||
private final SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
@@ -93,7 +91,6 @@ public class SecurityConfiguration {
|
||||
JwtServiceInterface jwtService,
|
||||
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
LoginAttemptService loginAttemptService,
|
||||
FirstLoginFilter firstLoginFilter,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
@@ -110,7 +107,6 @@ public class SecurityConfiguration {
|
||||
this.jwtService = jwtService;
|
||||
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.firstLoginFilter = firstLoginFilter;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
@@ -135,8 +131,7 @@ public class SecurityConfiguration {
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class);
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
||||
|
||||
+187
-4
@@ -1,19 +1,27 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -32,7 +40,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.AdminApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.AppArgsCapture;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.JarPathUtil;
|
||||
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
||||
@@ -45,6 +55,7 @@ public class AdminSettingsController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
// Track settings that have been modified but not yet applied (require restart)
|
||||
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
||||
@@ -195,7 +206,8 @@ public class AdminSettingsController {
|
||||
@Operation(
|
||||
summary = "Get specific settings section",
|
||||
description =
|
||||
"Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.")
|
||||
"Retrieve settings for a specific section (e.g., security, system, ui). "
|
||||
+ "By default includes pending changes with awaitingRestart flags. Admin access required.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
@@ -206,7 +218,9 @@ public class AdminSettingsController {
|
||||
responseCode = "403",
|
||||
description = "Access denied - Admin role required")
|
||||
})
|
||||
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
|
||||
public ResponseEntity<?> getSettingsSection(
|
||||
@PathVariable String sectionName,
|
||||
@RequestParam(defaultValue = "true") boolean includePending) {
|
||||
try {
|
||||
Object sectionData = getSectionData(sectionName);
|
||||
if (sectionData == null) {
|
||||
@@ -217,8 +231,24 @@ public class AdminSettingsController {
|
||||
+ ". Valid sections: "
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
log.debug("Admin requested settings section: {}", sectionName);
|
||||
return ResponseEntity.ok(sectionData);
|
||||
|
||||
// Convert to Map for manipulation
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> sectionMap = objectMapper.convertValue(sectionData, Map.class);
|
||||
|
||||
if (includePending && !pendingChanges.isEmpty()) {
|
||||
// Add pending changes block for this section
|
||||
Map<String, Object> sectionPending = extractPendingForSection(sectionName);
|
||||
if (!sectionPending.isEmpty()) {
|
||||
sectionMap.put("_pending", sectionPending);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Admin requested settings section: {} (includePending={})",
|
||||
sectionName,
|
||||
includePending);
|
||||
return ResponseEntity.ok(sectionMap);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
@@ -388,6 +418,101 @@ public class AdminSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/restart")
|
||||
@Operation(
|
||||
summary = "Restart the application",
|
||||
description =
|
||||
"Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(responseCode = "200", description = "Restart initiated successfully"),
|
||||
@ApiResponse(
|
||||
responseCode = "403",
|
||||
description = "Access denied - Admin role required"),
|
||||
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
|
||||
})
|
||||
public ResponseEntity<String> restartApplication() {
|
||||
try {
|
||||
log.warn("Admin initiated application restart");
|
||||
|
||||
// Get paths to current JAR and restart helper
|
||||
Path appJar = JarPathUtil.currentJar();
|
||||
Path helperJar = JarPathUtil.restartHelperJar();
|
||||
|
||||
if (appJar == null) {
|
||||
log.error("Cannot restart: not running from JAR (likely development mode)");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
"Restart not available in development mode. Please restart the application manually.");
|
||||
}
|
||||
|
||||
if (helperJar == null || !Files.isRegularFile(helperJar)) {
|
||||
log.error("Cannot restart: restart-helper.jar not found at expected location");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body("Restart helper not found. Please restart the application manually.");
|
||||
}
|
||||
|
||||
// Get current application arguments
|
||||
List<String> appArgs = AppArgsCapture.APP_ARGS.get();
|
||||
|
||||
// Write args to temp file to avoid command-line quoting issues
|
||||
Path argsFile = Files.createTempFile("stirling-app-args-", ".txt");
|
||||
Files.write(argsFile, appArgs, StandardCharsets.UTF_8);
|
||||
|
||||
// Get current process PID and java executable
|
||||
long pid = ProcessHandle.current().pid();
|
||||
String javaBin = JarPathUtil.javaExecutable();
|
||||
|
||||
// Build command to launch restart helper
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin);
|
||||
cmd.add("-jar");
|
||||
cmd.add(helperJar.toString());
|
||||
cmd.add("--pid");
|
||||
cmd.add(Long.toString(pid));
|
||||
cmd.add("--app");
|
||||
cmd.add(appJar.toString());
|
||||
cmd.add("--argsFile");
|
||||
cmd.add(argsFile.toString());
|
||||
cmd.add("--backoffMs");
|
||||
cmd.add("1000");
|
||||
|
||||
log.info("Launching restart helper: {}", String.join(" ", cmd));
|
||||
|
||||
// Launch restart helper process
|
||||
new ProcessBuilder(cmd)
|
||||
.directory(appJar.getParent().toFile())
|
||||
.inheritIO() // Forward logs
|
||||
.start();
|
||||
|
||||
// Clear pending changes since we're restarting
|
||||
pendingChanges.clear();
|
||||
|
||||
// Give the HTTP response time to complete, then exit
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
log.info("Shutting down for restart...");
|
||||
SpringApplication.exit(applicationContext, () -> 0);
|
||||
System.exit(0);
|
||||
} catch (InterruptedException e) {
|
||||
log.error("Restart interrupted: {}", e.getMessage(), e);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
})
|
||||
.start();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Application restart initiated. The server will be back online shortly.");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initiate restart: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to initiate application restart: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Object getSectionData(String sectionName) {
|
||||
if (sectionName == null || sectionName.trim().isEmpty()) {
|
||||
return null;
|
||||
@@ -626,4 +751,62 @@ public class AdminSettingsController {
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pending changes for a specific section
|
||||
*
|
||||
* @param sectionName The section name (e.g., "security", "system")
|
||||
* @return Map of pending changes with nested structure for this section
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> extractPendingForSection(String sectionName) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String sectionPrefix = sectionName.toLowerCase() + ".";
|
||||
|
||||
// Find all pending changes for this section
|
||||
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
|
||||
String pendingKey = entry.getKey();
|
||||
|
||||
if (pendingKey.toLowerCase().startsWith(sectionPrefix)) {
|
||||
// Extract the path within the section (e.g., "security.enableLogin" ->
|
||||
// "enableLogin")
|
||||
String pathInSection = pendingKey.substring(sectionPrefix.length());
|
||||
Object pendingValue = entry.getValue();
|
||||
|
||||
// Build nested structure from dot notation
|
||||
setNestedValue(result, pathInSection, pendingValue);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in a nested map using dot notation
|
||||
*
|
||||
* @param map The root map
|
||||
* @param dotPath The dot notation path (e.g., "oauth2.clientSecret")
|
||||
* @param value The value to set
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void setNestedValue(Map<String, Object> map, String dotPath, Object value) {
|
||||
String[] parts = dotPath.split("\\.");
|
||||
Map<String, Object> current = map;
|
||||
|
||||
// Navigate/create nested maps for all parts except the last
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
String part = parts[i];
|
||||
Object nested = current.get(part);
|
||||
|
||||
if (!(nested instanceof Map)) {
|
||||
nested = new HashMap<String, Object>();
|
||||
current.put(part, nested);
|
||||
}
|
||||
|
||||
current = (Map<String, Object>) nested;
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current.put(parts[parts.length - 1], value);
|
||||
}
|
||||
}
|
||||
|
||||
+96
-30
@@ -2,21 +2,19 @@ package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -42,15 +40,19 @@ public class DatabaseController {
|
||||
summary = "Import a database backup file",
|
||||
description = "Uploads and imports a database backup SQL file.")
|
||||
@PostMapping(consumes = "multipart/form-data", value = "import-database")
|
||||
public String importDatabase(
|
||||
public ResponseEntity<?> importDatabase(
|
||||
@Parameter(description = "SQL file to import", required = true)
|
||||
@RequestParam("fileInput")
|
||||
MultipartFile file,
|
||||
RedirectAttributes redirectAttributes)
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
|
||||
return "redirect:/database";
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNullOrEmpty",
|
||||
"message",
|
||||
"File is null or empty"));
|
||||
}
|
||||
log.info("Received file: {}", file.getOriginalFilename());
|
||||
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
|
||||
@@ -58,15 +60,31 @@ public class DatabaseController {
|
||||
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath);
|
||||
if (importSuccess) {
|
||||
redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed");
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"importIntoDatabaseSuccessed",
|
||||
"description",
|
||||
"Database imported successfully"));
|
||||
} else {
|
||||
redirectAttributes.addAttribute("error", "failedImportFile");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database file"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing database: {}", e.getMessage());
|
||||
redirectAttributes.addAttribute("error", "failedImportFile");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/database";
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -74,11 +92,17 @@ public class DatabaseController {
|
||||
summary = "Import database backup by filename",
|
||||
description = "Imports a database backup file from the server using its file name.")
|
||||
@GetMapping("/import-database-file/{fileName}")
|
||||
public String importDatabaseFromBackupUI(
|
||||
public ResponseEntity<?> importDatabaseFromBackupUI(
|
||||
@Parameter(description = "Name of the file to import", required = true) @PathVariable
|
||||
String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "redirect:/database?error=fileNullOrEmpty";
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNullOrEmpty",
|
||||
"message",
|
||||
"File name is null or empty"));
|
||||
}
|
||||
// Check if the file exists in the backup list
|
||||
boolean fileExists =
|
||||
@@ -86,14 +110,31 @@ public class DatabaseController {
|
||||
.anyMatch(backup -> backup.getFileName().equals(fileName));
|
||||
if (!fileExists) {
|
||||
log.error("File {} not found in backup list", fileName);
|
||||
return "redirect:/database?error=fileNotFound";
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNotFound",
|
||||
"message",
|
||||
"File not found in backup list"));
|
||||
}
|
||||
log.info("Received file: {}", fileName);
|
||||
if (databaseService.importDatabaseFromUI(fileName)) {
|
||||
log.info("File {} imported to database", fileName);
|
||||
return "redirect:/database?infoMessage=importIntoDatabaseSuccessed";
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"importIntoDatabaseSuccessed",
|
||||
"description",
|
||||
"Database backup imported successfully"));
|
||||
}
|
||||
return "redirect:/database?error=failedImportFile";
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database file"));
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -101,24 +142,42 @@ public class DatabaseController {
|
||||
summary = "Delete a database backup file",
|
||||
description = "Deletes a specified database backup file from the server.")
|
||||
@GetMapping("/delete/{fileName}")
|
||||
public String deleteFile(
|
||||
public ResponseEntity<?> deleteFile(
|
||||
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
|
||||
String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
throw new IllegalArgumentException("File must not be null or empty");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"invalidFileName",
|
||||
"message",
|
||||
"File must not be null or empty"));
|
||||
}
|
||||
try {
|
||||
if (databaseService.deleteBackupFile(fileName)) {
|
||||
log.info("Deleted file: {}", fileName);
|
||||
return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully"));
|
||||
} else {
|
||||
log.error("Failed to delete file: {}", fileName);
|
||||
return "redirect:/database?error=failedToDeleteFile";
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedToDeleteFile",
|
||||
"message",
|
||||
"Failed to delete backup file"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file: {}", e.getMessage());
|
||||
return "redirect:/database?error=" + e.getMessage();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"deleteError",
|
||||
"message",
|
||||
"Error deleting file: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/database";
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -142,22 +201,29 @@ public class DatabaseController {
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
log.error("Error downloading file: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.SEE_OTHER_303)
|
||||
.location(URI.create("/database?error=downloadFailed"))
|
||||
.build();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"downloadFailed",
|
||||
"message",
|
||||
"Failed to download file: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Create a database backup",
|
||||
description =
|
||||
"This endpoint triggers the creation of a database backup and redirects to the"
|
||||
+ " database management page.")
|
||||
description = "This endpoint triggers the creation of a database backup.")
|
||||
@GetMapping("/createDatabaseBackup")
|
||||
public String createDatabaseBackup() {
|
||||
public ResponseEntity<?> createDatabaseBackup() {
|
||||
log.info("Starting database backup creation...");
|
||||
databaseService.exportDatabase();
|
||||
log.info("Database backup successfully created.");
|
||||
return "redirect:/database?infoMessage=backupCreated";
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"backupCreated",
|
||||
"description",
|
||||
"Database backup created successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+44
-27
@@ -1,10 +1,12 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@@ -30,98 +32,113 @@ public class TeamController {
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/create")
|
||||
public RedirectView createTeam(@RequestParam("name") String name) {
|
||||
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
|
||||
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||
return new RedirectView("/teams?messageType=teamExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = new Team();
|
||||
team.setName(name);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamCreated");
|
||||
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/rename")
|
||||
public RedirectView renameTeam(
|
||||
public ResponseEntity<?> renameTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
|
||||
Optional<Team> existing = teamRepository.findById(teamId);
|
||||
if (existing.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
if (teamRepository.existsByNameIgnoreCase(newName)) {
|
||||
return new RedirectView("/teams?messageType=teamNameExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = existing.get();
|
||||
|
||||
// Prevent renaming the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot rename Internal team."));
|
||||
}
|
||||
|
||||
team.setName(newName);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamRenamed");
|
||||
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/delete")
|
||||
@Transactional
|
||||
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent deleting the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot delete Internal team."));
|
||||
}
|
||||
|
||||
long memberCount = userRepository.countByTeam(team);
|
||||
if (memberCount > 0) {
|
||||
return new RedirectView("/teams?messageType=teamHasUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Team must be empty before deletion. Please remove all members first."));
|
||||
}
|
||||
|
||||
teamRepository.delete(team);
|
||||
return new RedirectView("/teams?messageType=teamDeleted");
|
||||
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/addUser")
|
||||
@Transactional
|
||||
public RedirectView addUserToTeam(
|
||||
public ResponseEntity<?> addUserToTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
|
||||
|
||||
// Find the team
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent adding users to the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?error=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot add users to Internal team."));
|
||||
}
|
||||
|
||||
// Find the user
|
||||
User user =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
Optional<User> userOpt = userRepository.findById(userId);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
// Check if user is in the Internal team - prevent moving them
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot move users from Internal team."));
|
||||
}
|
||||
|
||||
// Assign user to team
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
|
||||
// Redirect back to team details page
|
||||
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
|
||||
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+302
-71
@@ -17,8 +17,6 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -38,6 +36,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
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;
|
||||
@@ -53,6 +52,7 @@ public class UserController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final Optional<EmailService> emailService;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
@@ -137,100 +137,130 @@ public class UserController {
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-username")
|
||||
public RedirectView changeUsername(
|
||||
public ResponseEntity<?> changeUsername(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPasswordChangeUsername") String currentPassword,
|
||||
@RequestParam(name = "newUsername") String newUsername,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws IOException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(newUsername)) {
|
||||
return new RedirectView("/account?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "invalidUsername", "message", "Invalid username format"));
|
||||
}
|
||||
if (principal == null) {
|
||||
return new RedirectView("/account?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
// The username MUST be unique when renaming
|
||||
Optional<User> userOpt = userService.findByUsername(principal.getName());
|
||||
if (userOpt == null || userOpt.isEmpty()) {
|
||||
return new RedirectView("/account?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (user.getUsername().equals(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "usernameExists", "message", "Username already in use"));
|
||||
}
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
|
||||
}
|
||||
if (newUsername != null && newUsername.length() > 0) {
|
||||
try {
|
||||
userService.changeUsername(user, newUsername);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new RedirectView("/account?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"invalidUsername",
|
||||
"message",
|
||||
"Invalid username format"));
|
||||
}
|
||||
}
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Username changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-password-on-login")
|
||||
public RedirectView changePasswordOnLogin(
|
||||
public ResponseEntity<?> changePasswordOnLogin(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (principal == null) {
|
||||
return new RedirectView("/change-creds?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/change-creds?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
userService.changePassword(user, newPassword);
|
||||
userService.changeFirstUse(user, false);
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Password changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-password")
|
||||
public RedirectView changePassword(
|
||||
public ResponseEntity<?> changePassword(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (principal == null) {
|
||||
return new RedirectView("/account?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/account?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
userService.changePassword(user, newPassword);
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Password changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@@ -248,23 +278,23 @@ public class UserController {
|
||||
* </ul>
|
||||
* Keys not listed above will be ignored.
|
||||
* @param principal The currently authenticated user.
|
||||
* @return A redirect string to the account page after updating the settings.
|
||||
* @return A ResponseEntity with success or error information.
|
||||
* @throws SQLException If a database error occurs.
|
||||
* @throws UnsupportedProviderException If the operation is not supported for the user's
|
||||
* provider.
|
||||
*/
|
||||
public String updateUserSettings(@RequestBody Map<String, String> updates, Principal principal)
|
||||
public ResponseEntity<?> updateUserSettings(
|
||||
@RequestBody Map<String, String> updates, Principal principal)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
log.debug("Processed updates: {}", updates);
|
||||
// Assuming you have a method in userService to update the settings for a user
|
||||
userService.updateUserSettings(principal.getName(), updates);
|
||||
// Redirect to a page of your choice after updating
|
||||
return "redirect:/account";
|
||||
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/saveUser")
|
||||
public RedirectView saveUser(
|
||||
public ResponseEntity<?> saveUser(
|
||||
@RequestParam(name = "username", required = true) String username,
|
||||
@RequestParam(name = "password", required = false) String password,
|
||||
@RequestParam(name = "role") String role,
|
||||
@@ -274,33 +304,42 @@ public class UserController {
|
||||
boolean forceChange)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Invalid username format. Username must be 3-50 characters."));
|
||||
}
|
||||
if (applicationProperties.getPremium().isEnabled()
|
||||
&& applicationProperties.getPremium().getMaxUsers()
|
||||
<= userService.getTotalUsersCount()) {
|
||||
return new RedirectView("/adminSettings?messageType=maxUsersReached", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Maximum number of users reached for your license."));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
if (user.getUsername().equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
}
|
||||
}
|
||||
if (userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
}
|
||||
try {
|
||||
// Validate the role
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
// If the role is INTERNAL_API_USER, reject the request
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If the role ID is not valid, redirect with an error message
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
// If the role ID is not valid, return error
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified."));
|
||||
}
|
||||
|
||||
// Use teamId if provided, otherwise use default team
|
||||
@@ -316,28 +355,144 @@ public class UserController {
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team."));
|
||||
}
|
||||
}
|
||||
|
||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
|
||||
} else {
|
||||
if (password.isBlank()) {
|
||||
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
|
||||
if (password == null || password.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required."));
|
||||
}
|
||||
if (password.length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
|
||||
}
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/inviteUsers")
|
||||
public ResponseEntity<?> inviteUsers(
|
||||
@RequestParam(name = "emails", required = true) String emails,
|
||||
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
|
||||
// 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"));
|
||||
}
|
||||
|
||||
// Check if email service is available
|
||||
if (!emailService.isPresent()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Email service is not configured. Please configure SMTP settings."));
|
||||
}
|
||||
|
||||
// Parse comma-separated email addresses
|
||||
String[] emailArray = emails.split(",");
|
||||
if (emailArray.length == 0) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "At least one email address is required"));
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
StringBuilder errors = new StringBuilder();
|
||||
|
||||
// Process each email
|
||||
for (String email : emailArray) {
|
||||
email = email.trim();
|
||||
if (email.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
|
||||
if (result.isSuccess()) {
|
||||
successCount++;
|
||||
} else {
|
||||
failureCount++;
|
||||
errors.append(result.getErrorMessage()).append("; ");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("successCount", successCount);
|
||||
response.put("failureCount", failureCount);
|
||||
|
||||
if (failureCount > 0) {
|
||||
response.put("errors", errors.toString());
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
response.put("message", successCount + " user(s) invited successfully");
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
response.put("error", "Failed to invite any users");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/changeRole")
|
||||
@Transactional
|
||||
public RedirectView changeRole(
|
||||
public ResponseEntity<?> changeRole(
|
||||
@RequestParam(name = "username") String username,
|
||||
@RequestParam(name = "role") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@@ -345,27 +500,32 @@ public class UserController {
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (!userOpt.isPresent()) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot change your own role."));
|
||||
}
|
||||
try {
|
||||
// Validate the role
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
// If the role is INTERNAL_API_USER, reject the request
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If the role ID is not valid, redirect with an error message
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
// If the role ID is not valid, return error
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
@@ -375,15 +535,15 @@ public class UserController {
|
||||
if (team != null) {
|
||||
// Prevent assigning to Internal team
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team."));
|
||||
}
|
||||
|
||||
// Prevent moving users from Internal team
|
||||
if (user.getTeam() != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=cannotMoveInternalUsers", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot move users from Internal team."));
|
||||
}
|
||||
|
||||
user.setTeam(team);
|
||||
@@ -392,30 +552,31 @@ public class UserController {
|
||||
}
|
||||
|
||||
userService.changeRole(user, role);
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/changeUserEnabled/{username}")
|
||||
public RedirectView changeUserEnabled(
|
||||
public ResponseEntity<?> changeUserEnabled(
|
||||
@PathVariable("username") String username,
|
||||
@RequestParam("enabled") boolean enabled,
|
||||
Authentication authentication)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot disable your own account."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
userService.changeUserEnabled(user, enabled);
|
||||
@@ -442,23 +603,24 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
}
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/deleteUser/{username}")
|
||||
public RedirectView deleteUser(
|
||||
public ResponseEntity<?> deleteUser(
|
||||
@PathVariable("username") String username, Authentication authentication) {
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot delete your own account."));
|
||||
}
|
||||
// Invalidate all sessions before deleting the user
|
||||
List<SessionInformation> sessionsInformations =
|
||||
@@ -468,7 +630,7 @@ public class UserController {
|
||||
sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId());
|
||||
}
|
||||
userService.deleteUser(username);
|
||||
return new RedirectView("/adminSettings", true);
|
||||
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@@ -499,4 +661,73 @@ public class UserController {
|
||||
}
|
||||
return ResponseEntity.ok(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to process a single email invitation.
|
||||
*
|
||||
* @param email The email address to invite
|
||||
* @param teamId The team ID to assign the user to
|
||||
* @param role The role to assign to the user
|
||||
* @return InviteResult containing success status and optional error message
|
||||
*/
|
||||
private InviteResult processEmailInvite(String email, Long teamId, String role) {
|
||||
try {
|
||||
// Validate email format (basic check)
|
||||
if (!email.contains("@") || !email.contains(".")) {
|
||||
return InviteResult.failure(email + ": Invalid email format");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(email)) {
|
||||
return InviteResult.failure(email + ": User already exists");
|
||||
}
|
||||
|
||||
// Generate random password
|
||||
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
|
||||
|
||||
// Create user with forceChange=true
|
||||
userService.saveUser(email, temporaryPassword, teamId, role, true);
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
emailService.get().sendInviteEmail(email, email, temporaryPassword);
|
||||
log.info("Sent invite email to: {}", email);
|
||||
return InviteResult.success();
|
||||
} catch (Exception emailEx) {
|
||||
log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage());
|
||||
return InviteResult.failure(email + ": User created but email failed to send");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to invite user {}: {}", email, e.getMessage());
|
||||
return InviteResult.failure(email + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result object for individual email invite processing. */
|
||||
private static class InviteResult {
|
||||
private final boolean success;
|
||||
private final String errorMessage;
|
||||
|
||||
private InviteResult(boolean success, String errorMessage) {
|
||||
this.success = success;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
static InviteResult success() {
|
||||
return new InviteResult(true, null);
|
||||
}
|
||||
|
||||
static InviteResult failure(String errorMessage) {
|
||||
return new InviteResult(false, errorMessage);
|
||||
}
|
||||
|
||||
boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FirstLoginFilter extends OncePerRequestFilter {
|
||||
|
||||
@Lazy private final UserService userService;
|
||||
|
||||
public FirstLoginFilter(@Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String method = request.getMethod();
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
// Check if the request is for static resources
|
||||
boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI);
|
||||
// If it's a static resource, just continue the filter chain and skip the logic below
|
||||
if (isStaticResource) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
Optional<User> user = userService.findByUsernameIgnoreCase(authentication.getName());
|
||||
if ("GET".equalsIgnoreCase(method)
|
||||
&& user.isPresent()
|
||||
&& user.get().isFirstLogin()
|
||||
&& !(contextPath + "/change-creds").equals(requestURI)) {
|
||||
response.sendRedirect(contextPath + "/change-creds");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
HttpSession session = request.getSession(true);
|
||||
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
|
||||
String creationTime = timeFormat.format(new Date(session.getCreationTime()));
|
||||
log.debug(
|
||||
"Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}",
|
||||
session.isNew(),
|
||||
creationTime,
|
||||
session.getId(),
|
||||
request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"),
|
||||
request.getHeader("Referer"),
|
||||
request.getRequestURL().toString());
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "team_id")
|
||||
@JsonIgnore
|
||||
private Team team;
|
||||
|
||||
@ElementCollection
|
||||
@@ -81,6 +80,7 @@ public class User implements UserDetails, Serializable {
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
|
||||
|
||||
@CreationTimestamp
|
||||
|
||||
+86
@@ -73,4 +73,90 @@ public class EmailService {
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a plain text/HTML email without attachments asynchronously.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param subject The email subject
|
||||
* @param body The email body (can contain HTML)
|
||||
* @param isHtml Whether the body contains HTML content
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendPlainEmail(String to, String subject, String body, boolean isHtml)
|
||||
throws MessagingException {
|
||||
// Validate recipient email address
|
||||
if (to == null || to.trim().isEmpty()) {
|
||||
throw new MessagingException("Invalid recipient email address");
|
||||
}
|
||||
|
||||
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
|
||||
|
||||
// Creates a MimeMessage to represent the email
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
|
||||
// Helper class to set up the message content
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false);
|
||||
|
||||
// Sets the recipient, subject, body, and sender email
|
||||
helper.addTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, isHtml);
|
||||
helper.setFrom(mailProperties.getFrom());
|
||||
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation email to a new user with their credentials.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param username The username for the new account
|
||||
* @param temporaryPassword The temporary password
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteEmail(String to, String username, String temporaryPassword)
|
||||
throws MessagingException {
|
||||
String subject = "Welcome 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 workspace. Below are your login credentials:</p>
|
||||
<!-- Credentials Box -->
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> %s</p>
|
||||
<p style="margin: 0;"><strong>Temporary Password:</strong> %s</p>
|
||||
</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;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
|
||||
</div>
|
||||
<p>Please keep these credentials secure and do not share them with anyone.</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(username, temporaryPassword);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -642,6 +642,21 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
return authentication.getAuthorities().stream()
|
||||
.anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking admin status", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncCustomApiUser(String customApiKey) {
|
||||
if (customApiKey == null || customApiKey.trim().isBlank()) {
|
||||
|
||||
+34
-1
@@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
|
||||
this.licenseKeyChecker = licenseKeyChecker;
|
||||
}
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
@@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
return enabled && hasProOrEnterpriseAccess();
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
@@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
log.info("Server certificate feature requires Pro or Enterprise license");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
@@ -88,6 +106,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
@@ -114,6 +137,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
@@ -174,6 +202,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
|
||||
Reference in New Issue
Block a user