Co-authored-by: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
Anthony Stirling
2026-03-02 13:56:39 +00:00
committed by GitHub
co-authored by Claude Haiku 4.5
parent 8b25db37ad
commit 012bd1af92
21 changed files with 407 additions and 66 deletions
@@ -43,6 +43,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
@@ -357,8 +358,12 @@ public class ProprietaryUIDataController {
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
List<AdminUserSummary> userSummaries =
sortedUsers.stream().map(this::convertUserToSummary).toList();
AdminSettingsData data = new AdminSettingsData();
data.setUsers(sortedUsers);
data.setUsers(userSummaries);
data.setCurrentUsername(authentication.getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
@@ -518,6 +523,34 @@ public class ProprietaryUIDataController {
return ResponseEntity.ok(data);
}
/**
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
* apiKey.
*/
private AdminUserSummary convertUserToSummary(User user) {
AdminUserSummary summary = new AdminUserSummary();
summary.setId(user.getId());
summary.setUsername(user.getUsername());
summary.setEmail(user.getUsername()); // Use username as email for consistency
summary.setRoleName(user.getRoleName());
summary.setRolesAsString(user.getRolesAsString());
summary.setEnabled(user.isEnabled());
summary.setIsFirstLogin(user.isFirstLogin());
summary.setAuthenticationType(user.getAuthenticationType());
summary.setCreatedAt(user.getCreatedAt());
summary.setUpdatedAt(user.getUpdatedAt());
// Map team if present
if (user.getTeam() != null) {
AdminUserSummary.TeamSummary teamSummary = new AdminUserSummary.TeamSummary();
teamSummary.setId(user.getTeam().getId());
teamSummary.setName(user.getTeam().getName());
summary.setTeam(teamSummary);
}
return summary;
}
// Data classes
@Data
public static class AuditDashboardData {
@@ -544,7 +577,7 @@ public class ProprietaryUIDataController {
@Data
public static class AdminSettingsData {
private List<User> users;
private List<AdminUserSummary> users;
private String currentUsername;
private Map<String, String> roleDetails;
private Map<String, Boolean> userSessions;
@@ -196,8 +196,9 @@ public class AdminSettingsController {
log.info("Admin updating setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
// Track this as a pending change (convert null to empty string for
// ConcurrentHashMap)
pendingChanges.put(key, value != null ? value : "");
updatedCount++;
}
@@ -268,6 +269,9 @@ public class AdminSettingsController {
}
}
// Mask sensitive fields before returning to frontend
sectionMap = maskSensitiveFields(sectionMap);
log.debug(
"Admin requested settings section: {} (includePending={})",
sectionName,
@@ -404,6 +408,13 @@ public class AdminSettingsController {
return ResponseEntity.badRequest()
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
}
// Mask sensitive values before returning
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
if (isSensitiveFieldWithPath(keyName, key)) {
value = createMaskedValue(value);
}
log.debug("Admin requested setting: {}", key);
return ResponseEntity.ok(new SettingValueResponse(key, value));
} catch (IllegalArgumentException e) {
@@ -441,6 +452,20 @@ public class AdminSettingsController {
}
Object value = request.getValue();
// Prevent saving masked values for sensitive fields to avoid data loss
if ("********".equals(value)) {
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
if (isSensitiveFieldWithPath(keyName, key)) {
log.warn(
"Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.",
key);
return ResponseEntity.badRequest()
.body(
"Cannot save masked values for sensitive settings. Please provide the actual value.");
}
}
log.info("Admin updating single setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
@@ -123,7 +123,7 @@ public class AuthController {
log.warn("Invalid password for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
.body(Map.of("error", "Invalid username or password"));
}
if (!user.isEnabled()) {
@@ -358,27 +358,23 @@ public class InviteLinkController {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
return invalidInviteResponse();
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
return invalidInviteResponse();
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
return invalidInviteResponse();
}
// Check if user already exists (only if email is pre-set)
if (invite.getEmail() != null
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
return invalidInviteResponse();
}
Map<String, Object> response = new HashMap<>();
@@ -391,8 +387,7 @@ public class InviteLinkController {
} catch (Exception e) {
log.error("Failed to validate invite token: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate invite link"));
return invalidInviteResponse();
}
}
@@ -419,20 +414,17 @@ public class InviteLinkController {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
return invalidInviteResponse();
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
return invalidInviteResponse();
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
return invalidInviteResponse();
}
// Determine the email to use
@@ -455,8 +447,7 @@ public class InviteLinkController {
// Check if user already exists
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
return invalidInviteResponse();
}
// Create the user account
@@ -484,7 +475,12 @@ public class InviteLinkController {
} catch (Exception e) {
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
.body(Map.of("error", "Failed to create account"));
}
}
private ResponseEntity<Map<String, String>> invalidInviteResponse() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
}
}
@@ -942,7 +942,7 @@ public class UserController {
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
if (username == null || "anonymousUser".equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
@@ -46,9 +46,11 @@ public class User implements UserDetails, Serializable {
private String username;
@Column(name = "password")
@JsonIgnore
private String password;
@Column(name = "apiKey")
@JsonIgnore
private String apiKey;
@Column(name = "enabled")
@@ -0,0 +1,72 @@
package stirling.software.proprietary.security.model.dto;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Data Transfer Object for admin user listings. Contains only the fields needed by the admin UI for
* displaying user information. Excludes sensitive fields like password and apiKey.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Admin user summary for listing in admin UI - excludes sensitive fields")
public class AdminUserSummary {
@Schema(description = "User ID")
private Long id;
@Schema(description = "Username/login identifier")
private String username;
@Schema(description = "User email address")
private String email;
@Schema(description = "Role name (translation key like 'adminUserSettings.admin')")
private String roleName;
@Schema(description = "Role identifier (e.g., 'ROLE_ADMIN')")
private String rolesAsString;
@Schema(description = "Whether user account is enabled")
private boolean enabled;
@Schema(description = "Whether this is the user's first login")
private Boolean isFirstLogin;
@Schema(description = "Authentication type (WEB, OAUTH2, SAML2)")
private String authenticationType;
@Schema(description = "Team membership (if any)")
private TeamSummary team;
@Schema(description = "User account creation timestamp")
private LocalDateTime createdAt;
@Schema(description = "User account last update timestamp")
private LocalDateTime updatedAt;
/** Minimal Team DTO for admin user listings */
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Team summary (id and name only)")
public static class TeamSummary {
@Schema(description = "Team ID")
private Long id;
@Schema(description = "Team name")
private String name;
}
}