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:
Anthony Stirling
2025-10-28 14:47:41 +00:00
committed by GitHub
co-authored by James Brunton
parent d2b38ef4b8
commit d0c5d74471
68 changed files with 9133 additions and 282 deletions
@@ -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();
@@ -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);
@@ -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);
}
}
@@ -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"));
}
}
@@ -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"));
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -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
@@ -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;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, temporaryPassword);
sendPlainEmail(to, subject, body, true);
}
}
@@ -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()) {
@@ -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());