mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
SaaS Consolidation (#6384)
Co-authored-by: ConnorYoh <[email protected]>
This commit is contained in:
co-authored by
ConnorYoh
parent
089de247b4
commit
9d081d1792
+39
@@ -0,0 +1,39 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
@Controller
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class AuditDashboardWebController {
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
/** Display the audit dashboard. */
|
||||
@GetMapping("/audit")
|
||||
@Hidden
|
||||
public String showDashboard(Model model) {
|
||||
model.addAttribute("auditEnabled", auditConfig.isEnabled());
|
||||
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
|
||||
model.addAttribute("auditLevelInt", auditConfig.getLevel());
|
||||
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
|
||||
|
||||
// Add audit level enum values for display
|
||||
model.addAttribute("auditLevels", AuditLevel.values());
|
||||
|
||||
// Add audit event types for the dropdown
|
||||
model.addAttribute("auditEventTypes", AuditEventType.values());
|
||||
|
||||
return "audit/dashboard";
|
||||
}
|
||||
}
|
||||
@@ -50,13 +50,7 @@ public class AsyncConfig {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI orchestration runs on a background executor, so the incoming request's {@code
|
||||
* SecurityContext} must be propagated for downstream calls to see the authenticated user.
|
||||
* Without this, {@code JobOwnershipService} scopes job keys without a user prefix and
|
||||
* authenticated downloads fail with 403; {@code InternalApiClient} also falls back to the
|
||||
* internal-API-user key instead of the caller's.
|
||||
*/
|
||||
/** Propagates the request's SecurityContext onto background AI-orchestration threads. */
|
||||
@Bean(name = "aiStreamExecutor")
|
||||
public Executor aiStreamExecutor() {
|
||||
TaskExecutorAdapter adapter =
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ import stirling.software.common.service.TaskManager;
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs")
|
||||
public class AdminJobController {
|
||||
|
||||
@@ -41,7 +41,7 @@ public class AdminJobController {
|
||||
*/
|
||||
@GetMapping("/job/stats")
|
||||
@Operation(summary = "Get job statistics")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<JobStats> getJobStats() {
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
log.info(
|
||||
@@ -58,7 +58,7 @@ public class AdminJobController {
|
||||
*/
|
||||
@GetMapping("/job/queue/stats")
|
||||
@Operation(summary = "Get job queue statistics")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> getQueueStats() {
|
||||
Map<String, Object> queueStats = jobQueue.getQueueStats();
|
||||
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
|
||||
@@ -72,7 +72,7 @@ public class AdminJobController {
|
||||
*/
|
||||
@PostMapping("/job/cleanup")
|
||||
@Operation(summary = "Cleanup old jobs")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> cleanupOldJobs() {
|
||||
int beforeCount = taskManager.getJobStats().getTotalJobs();
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/audit")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
@Tag(name = "Audit", description = "Only Enterprise - Audit related operations")
|
||||
|
||||
+7
-7
@@ -46,7 +46,7 @@ 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;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
@@ -66,7 +66,7 @@ public class ProprietaryUIDataController {
|
||||
private final UserRepository userRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final DatabaseService databaseService;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
private final boolean runningEE;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
@@ -81,7 +81,7 @@ public class ProprietaryUIDataController {
|
||||
UserRepository userRepository,
|
||||
TeamRepository teamRepository,
|
||||
SessionRepository sessionRepository,
|
||||
DatabaseService databaseService,
|
||||
DatabaseServiceInterface databaseService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
@@ -251,7 +251,7 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/admin-settings")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get admin settings data")
|
||||
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
|
||||
List<User> allUsers = userRepository.findAllWithTeam();
|
||||
@@ -450,7 +450,7 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/teams")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get teams list data")
|
||||
public ResponseEntity<TeamsData> getTeamsData() {
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
@@ -476,7 +476,7 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/teams/{id}")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get team details data")
|
||||
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
|
||||
Team team =
|
||||
@@ -520,7 +520,7 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
@GetMapping("/database")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get database management data")
|
||||
public ResponseEntity<DatabaseData> getDatabaseData() {
|
||||
List<FileInfo> backupList = databaseService.getBackupList();
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitResetScheduler {
|
||||
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
import lombok.Getter;
|
||||
@@ -71,6 +72,7 @@ public class DatabaseConfig {
|
||||
@Bean
|
||||
@Qualifier("dataSource")
|
||||
@Primary
|
||||
@Profile("!saas")
|
||||
public DataSource dataSource() throws UnsupportedProviderException {
|
||||
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/** Standalone {@link PasswordEncoder} bean. */
|
||||
@Configuration
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -9,6 +9,7 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
@@ -20,7 +21,6 @@ import org.springframework.security.config.annotation.web.configurers.CsrfConfig
|
||||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
|
||||
@@ -69,6 +69,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@DependsOn("runningProOrHigher")
|
||||
@Profile("!saas")
|
||||
public class SecurityConfiguration {
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
@@ -91,6 +92,7 @@ public class SecurityConfiguration {
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -112,8 +114,8 @@ public class SecurityConfiguration {
|
||||
@Autowired(required = false)
|
||||
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
@@ -132,11 +134,7 @@ public class SecurityConfiguration {
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public static PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +291,7 @@ public class SecurityConfiguration {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
// TODO: IPRateLimitingFilter disabled — limit is 1M (no-op) and raw Filter
|
||||
// TODO: IPRateLimitingFilter disabled (limit is 1M, no-op) and raw Filter
|
||||
// impl causes Spring Security async dispatch bug (response already committed
|
||||
// errors on StreamingResponseBody endpoints). Re-enable once converted to
|
||||
// OncePerRequestFilter with proper config-driven limits.
|
||||
@@ -462,7 +460,7 @@ public class SecurityConfiguration {
|
||||
|
||||
public DaoAuthenticationProvider daoAuthenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder());
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
return provider;
|
||||
}
|
||||
|
||||
|
||||
+4
-7
@@ -4,7 +4,6 @@ import static stirling.software.proprietary.security.configuration.ee.KeygenLice
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
@@ -28,28 +27,26 @@ public class EEAppConfig {
|
||||
migrateEnterpriseSettingsToPremium(this.applicationProperties);
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Profile("security & !saas")
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Primary
|
||||
public boolean runningProOrHigher() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Profile("security & !saas")
|
||||
@Bean(name = "license")
|
||||
@Primary
|
||||
public String licenseType() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult().name();
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Profile("security & !saas")
|
||||
@Bean(name = "runningEE")
|
||||
@Primary
|
||||
public boolean runningEnterprise() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult() == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Profile("security & !saas")
|
||||
@Bean(name = "SSOAutoLogin")
|
||||
public boolean ssoAutoLogin() {
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
|
||||
public class AdminLicenseController {
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@AdminApi
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Slf4j
|
||||
public class AdminSettingsController {
|
||||
|
||||
|
||||
+1
-1
@@ -585,7 +585,7 @@ public class AuthController {
|
||||
* @param username Username of the user to disable MFA for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/mfa/disable/admin/{username}")
|
||||
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
|
||||
|
||||
@Slf4j
|
||||
@DatabaseApi
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Conditional(H2SQLCondition.class)
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseController {
|
||||
|
||||
+4
-4
@@ -52,7 +52,7 @@ public class InviteLinkController {
|
||||
* @param request The HTTP request
|
||||
* @return ResponseEntity with the invite link or error
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/generate")
|
||||
public ResponseEntity<?> generateInviteLink(
|
||||
@RequestParam(name = "email", required = false) String email,
|
||||
@@ -257,7 +257,7 @@ public class InviteLinkController {
|
||||
*
|
||||
* @return List of active invite tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<?> listInviteLinks() {
|
||||
try {
|
||||
@@ -297,7 +297,7 @@ public class InviteLinkController {
|
||||
* @param inviteId The invite token ID to revoke
|
||||
* @return Success or error response
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@DeleteMapping("/revoke/{inviteId}")
|
||||
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
|
||||
try {
|
||||
@@ -324,7 +324,7 @@ public class InviteLinkController {
|
||||
*
|
||||
* @return Number of deleted tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/cleanup")
|
||||
public ResponseEntity<?> cleanupExpiredInvites() {
|
||||
try {
|
||||
|
||||
+4
-4
@@ -30,7 +30,7 @@ public class TeamController {
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
|
||||
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||
@@ -43,7 +43,7 @@ public class TeamController {
|
||||
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/rename")
|
||||
public ResponseEntity<?> renameTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
|
||||
@@ -69,7 +69,7 @@ public class TeamController {
|
||||
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/delete")
|
||||
@Transactional
|
||||
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
@@ -100,7 +100,7 @@ public class TeamController {
|
||||
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/addUser")
|
||||
@Transactional
|
||||
public ResponseEntity<?> addUserToTeam(
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ public class UIDataTessdataController {
|
||||
private static final long REMOTE_TESSDATA_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
@GetMapping("/tessdata-languages")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List installed and remotely available tessdata languages")
|
||||
public ResponseEntity<TessdataLanguagesResponse> getTessdataLanguages() {
|
||||
TessdataLanguagesResponse response = new TessdataLanguagesResponse();
|
||||
@@ -58,7 +58,7 @@ public class UIDataTessdataController {
|
||||
}
|
||||
|
||||
@PostMapping("/tessdata/download")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Download selected tessdata languages from the official repository")
|
||||
public ResponseEntity<Map<String, Object>> downloadTessdataLanguages(
|
||||
@RequestBody TessdataDownloadRequest request) {
|
||||
|
||||
+7
-7
@@ -360,7 +360,7 @@ public class UserController {
|
||||
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/saveUser")
|
||||
public ResponseEntity<?> saveUser(
|
||||
@RequestParam(name = "username", required = true) String username,
|
||||
@@ -468,7 +468,7 @@ public class UserController {
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/inviteUsers")
|
||||
public ResponseEntity<?> inviteUsers(
|
||||
@RequestParam(name = "emails", required = true) String emails,
|
||||
@@ -585,7 +585,7 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/changeRole")
|
||||
@Transactional
|
||||
public ResponseEntity<?> changeRole(
|
||||
@@ -651,7 +651,7 @@ public class UserController {
|
||||
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/changePasswordForUser")
|
||||
public ResponseEntity<?> changePasswordForUser(
|
||||
@RequestParam(name = "username") String username,
|
||||
@@ -725,7 +725,7 @@ public class UserController {
|
||||
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/changeUserEnabled/{username}")
|
||||
public ResponseEntity<?> changeUserEnabled(
|
||||
@PathVariable("username") String username,
|
||||
@@ -777,7 +777,7 @@ public class UserController {
|
||||
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/unlockUser/{username}")
|
||||
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> unlockUser(@PathVariable("username") String username) {
|
||||
@@ -785,7 +785,7 @@ public class UserController {
|
||||
return ResponseEntity.ok(Map.of("message", "User account unlocked successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/admin/deleteUser/{username}")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> deleteUser(
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/api/v1/database")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@Conditional(H2SQLCondition.class)
|
||||
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
|
||||
|
||||
+18
-7
@@ -1,23 +1,34 @@
|
||||
package stirling.software.proprietary.security.database;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
/** Returns {@code true} when the active deployment is genuinely on H2. */
|
||||
public class H2SQLCondition implements Condition {
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
var env = context.getEnvironment();
|
||||
boolean enableCustomDatabase =
|
||||
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
||||
|
||||
// If custom database is not enabled, H2 is used by default
|
||||
if (!enableCustomDatabase) {
|
||||
return true;
|
||||
if (Arrays.asList(env.getActiveProfiles()).contains("saas")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
||||
return "h2".equalsIgnoreCase(dataSourceType);
|
||||
// Legacy custom-DB block, if explicitly enabled, is authoritative.
|
||||
boolean enableCustomDatabase =
|
||||
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
||||
if (enableCustomDatabase) {
|
||||
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
||||
return "h2".equalsIgnoreCase(dataSourceType);
|
||||
}
|
||||
|
||||
String springDsUrl = env.getProperty("spring.datasource.url", String.class, "");
|
||||
if (springDsUrl == null || springDsUrl.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return springDsUrl.startsWith("jdbc:h2:");
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -1,7 +1,10 @@
|
||||
package stirling.software.proprietary.security.database.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
@@ -26,6 +29,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findBySupabaseId(UUID supabaseId);
|
||||
|
||||
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
|
||||
|
||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||
@@ -92,4 +99,23 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
nativeQuery = true)
|
||||
void deleteSettingsByUserIdAndKeys(
|
||||
@Param("userId") Long userId, @Param("keys") List<String> keys);
|
||||
|
||||
/** Anonymous users (no username) created before the cut-off, streamed for batch cleanup. */
|
||||
@Query("SELECT u.id FROM User u WHERE u.username IS NULL AND u.createdAt < :cutoffDate")
|
||||
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
|
||||
@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
/** Users with an API key but no row in {@code user_credits}. */
|
||||
@Query(
|
||||
value =
|
||||
"SELECT u.* FROM users u "
|
||||
+ "LEFT JOIN user_credits uc ON uc.user_id = u.user_id "
|
||||
+ "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL",
|
||||
nativeQuery = true)
|
||||
List<User> findUsersWithApiKeyButNoCredits();
|
||||
|
||||
/** Single-shot UPDATE that reassigns a user to a different team. */
|
||||
@Modifying
|
||||
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
|
||||
int updateUserTeamId(@Param("userId") Long userId, @Param("teamId") Long teamId);
|
||||
}
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@@ -37,6 +38,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApplicationProperties.Security securityProp;
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
@@ -28,6 +29,7 @@ import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Component
|
||||
@Profile("!saas")
|
||||
public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
|
||||
private final Map<String, Bucket> apiBuckets = new ConcurrentHashMap<>();
|
||||
|
||||
+3
-1
@@ -5,5 +5,7 @@ public enum AuthenticationType {
|
||||
@Deprecated(since = "1.0.2")
|
||||
SSO,
|
||||
OAUTH2,
|
||||
SAML2
|
||||
SAML2,
|
||||
/** Supabase anonymous session. Saas profile only, no email yet. */
|
||||
ANONYMOUS
|
||||
}
|
||||
|
||||
+17
-2
@@ -7,6 +7,7 @@ import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
@@ -50,12 +51,13 @@ public class User implements UserDetails, Serializable {
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
|
||||
@Column(name = "apiKey")
|
||||
@Column(name = "apiKey", unique = true)
|
||||
@JsonIgnore
|
||||
private String apiKey;
|
||||
|
||||
// Boxed so SaaS rows from Supabase can leave it null; isEnabled() treats null as enabled.
|
||||
@Column(name = "enabled")
|
||||
private boolean enabled;
|
||||
private Boolean enabled;
|
||||
|
||||
@Column(name = "isFirstLogin")
|
||||
private Boolean isFirstLogin = false;
|
||||
@@ -81,6 +83,13 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "oauth_grandfathered")
|
||||
private Boolean oauthGrandfathered = false;
|
||||
|
||||
@Column(name = "email", unique = true)
|
||||
private String email;
|
||||
|
||||
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
|
||||
@Column(name = "supabase_id", unique = true)
|
||||
private UUID supabaseId;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@@ -90,6 +99,7 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
@@ -107,6 +117,11 @@ public class User implements UserDetails, Serializable {
|
||||
return Role.getRoleNameByRoleId(getRolesAsString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled == null || enabled;
|
||||
}
|
||||
|
||||
public boolean isFirstLogin() {
|
||||
return isFirstLogin != null && isFirstLogin;
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Slf4j
|
||||
public class JwtSaml2AuthenticationRequestRepository
|
||||
implements Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest> {
|
||||
private final Map<String, String> tokenStore;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
|
||||
|
||||
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
|
||||
|
||||
public JwtSaml2AuthenticationRequestRepository(
|
||||
Map<String, String> tokenStore,
|
||||
JwtServiceInterface jwtService,
|
||||
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
|
||||
this.tokenStore = tokenStore;
|
||||
this.jwtService = jwtService;
|
||||
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAuthenticationRequest(
|
||||
Saml2PostAuthenticationRequest authRequest,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (!jwtService.isJwtEnabled()) {
|
||||
log.debug("V2 is not enabled, skipping SAMLRequest token storage");
|
||||
return;
|
||||
}
|
||||
|
||||
if (authRequest == null) {
|
||||
removeAuthenticationRequest(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = serializeSamlRequest(authRequest);
|
||||
String token = jwtService.generateToken("", claims);
|
||||
String relayState = authRequest.getRelayState();
|
||||
|
||||
tokenStore.put(relayState, token);
|
||||
request.setAttribute(SAML_REQUEST_TOKEN, relayState);
|
||||
response.addHeader(SAML_REQUEST_TOKEN, relayState);
|
||||
|
||||
log.debug("Saved SAMLRequest token with RelayState: {}", relayState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
|
||||
String token = extractTokenFromStore(request);
|
||||
|
||||
if (token == null) {
|
||||
log.debug("No SAMLResponse token found in RelayState");
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
return deserializeSamlRequest(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Saml2PostAuthenticationRequest removeAuthenticationRequest(
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request);
|
||||
|
||||
String relayStateId = request.getParameter("RelayState");
|
||||
if (relayStateId != null) {
|
||||
tokenStore.remove(relayStateId);
|
||||
log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId);
|
||||
}
|
||||
|
||||
return authRequest;
|
||||
}
|
||||
|
||||
private String extractTokenFromStore(HttpServletRequest request) {
|
||||
String authnRequestId = request.getParameter("RelayState");
|
||||
|
||||
if (authnRequestId != null && !authnRequestId.isEmpty()) {
|
||||
String token = tokenStore.get(authnRequestId);
|
||||
|
||||
if (token != null) {
|
||||
tokenStore.remove(authnRequestId);
|
||||
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
return token;
|
||||
} else {
|
||||
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
claims.put("id", authRequest.getId());
|
||||
claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId());
|
||||
claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri());
|
||||
claims.put("samlRequest", authRequest.getSamlRequest());
|
||||
claims.put("relayState", authRequest.getRelayState());
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private Saml2PostAuthenticationRequest deserializeSamlRequest(Map<String, Object> claims) {
|
||||
String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId");
|
||||
RelyingPartyRegistration relyingPartyRegistration =
|
||||
relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId);
|
||||
|
||||
if (relyingPartyRegistration == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration)
|
||||
.id((String) claims.get("id"))
|
||||
.authenticationRequestUri((String) claims.get("authenticationRequestUri"))
|
||||
.samlRequest((String) claims.get("samlRequest"))
|
||||
.relayState((String) claims.get("relayState"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+13
-31
@@ -28,6 +28,7 @@ import java.util.stream.Collectors;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.jdbc.datasource.init.CannotReadScriptException;
|
||||
import org.springframework.jdbc.datasource.init.ScriptException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -42,6 +43,7 @@ import stirling.software.proprietary.security.model.exception.BackupNotFoundExce
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("!saas")
|
||||
public class DatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
public static final String BACKUP_PREFIX = "backup_";
|
||||
@@ -391,6 +393,7 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
*
|
||||
* @return <code>String</code> of the H2 version
|
||||
*/
|
||||
@Override
|
||||
public String getH2Version() {
|
||||
String version = "Unknown";
|
||||
|
||||
@@ -411,39 +414,18 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
return version;
|
||||
}
|
||||
|
||||
/*
|
||||
* Checks if the current datasource is H2.
|
||||
*
|
||||
* @return true if the datasource is H2, false otherwise
|
||||
*/
|
||||
/** Returns {@code true} when the active runtime DataSource is genuinely H2. */
|
||||
private boolean isH2Database() {
|
||||
boolean isTypeH2 =
|
||||
datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name());
|
||||
boolean isDBUrlH2 =
|
||||
datasourceProps.getCustomDatabaseUrl().contains("h2")
|
||||
|| datasourceProps.getCustomDatabaseUrl().contains("H2");
|
||||
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
|
||||
|
||||
if (isCustomDatabase) {
|
||||
if (isTypeH2 && !isDBUrlH2) {
|
||||
log.warn(
|
||||
"Datasource type is H2, but the URL does not contain 'h2'. "
|
||||
+ "Please check your configuration.");
|
||||
throw new IllegalStateException(
|
||||
"Datasource type is H2, but the URL does not contain 'h2'. Please check"
|
||||
+ " your configuration.");
|
||||
} else if (!isTypeH2 && isDBUrlH2) {
|
||||
log.warn(
|
||||
"Datasource URL contains 'h2', but the type is not H2. "
|
||||
+ "Please check your configuration.");
|
||||
throw new IllegalStateException(
|
||||
"Datasource URL contains 'h2', but the type is not H2. Please check your"
|
||||
+ " configuration.");
|
||||
}
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
String product = conn.getMetaData().getDatabaseProductName();
|
||||
return product != null && product.toLowerCase().contains("h2");
|
||||
} catch (SQLException e) {
|
||||
log.warn(
|
||||
"Could not read DatabaseMetaData to determine driver; assuming non-H2 to avoid"
|
||||
+ " issuing H2-only SQL against another database: {}",
|
||||
e.getMessage());
|
||||
return false;
|
||||
}
|
||||
boolean isH2 = isTypeH2 && isDBUrlH2;
|
||||
|
||||
return !isCustomDatabase || isH2;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -20,4 +20,7 @@ public interface DatabaseServiceInterface {
|
||||
List<Pair<FileInfo, Boolean>> deleteAllBackups();
|
||||
|
||||
List<Pair<FileInfo, Boolean>> deleteLastBackup();
|
||||
|
||||
/** Display string for the active database product/version. */
|
||||
String getH2Version();
|
||||
}
|
||||
|
||||
+16
@@ -338,6 +338,22 @@ public class UserService implements UserServiceInterface {
|
||||
return userRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
/** Resolves a user by Supabase auth UUID; empty for rows with no supabase_id. */
|
||||
public Optional<User> findBySupabaseId(UUID supabaseId) {
|
||||
return userRepository.findBySupabaseId(supabaseId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void trackApiKeyFirstUse(User user) {
|
||||
// No-op default; saas mode overrides via SaasUserExtensionService#trackApiKeyFirstUse.
|
||||
}
|
||||
|
||||
/** Low-level user persistence; bypasses {@link #saveUserCore}'s settings/audit lifecycle. */
|
||||
@Transactional
|
||||
public User saveUser(User user) {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Optional<User> findByUsernameIgnoreCase(String username) {
|
||||
return userRepository.findByUsernameIgnoreCase(username);
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.proprietary.security.supabase;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stirling's customer-facing Supabase project endpoints ({@code auth.stirling.com} in production).
|
||||
* Overridable via {@code stirling.supabase.url} / {@code stirling.supabase.publishable-key}.
|
||||
*/
|
||||
@Component
|
||||
public class SupabaseEndpoints {
|
||||
|
||||
public static final String DEFAULT_URL = "https://auth.stirling.com";
|
||||
|
||||
/** Publishable (anon) key — safe to ship in client/server code. */
|
||||
public static final String DEFAULT_PUBLISHABLE_KEY =
|
||||
"sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb"; // gitleaks:allow
|
||||
|
||||
@Value("${stirling.supabase.url:" + DEFAULT_URL + "}")
|
||||
private String url;
|
||||
|
||||
@Value("${stirling.supabase.publishable-key:" + DEFAULT_PUBLISHABLE_KEY + "}")
|
||||
private String publishableKey;
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public String getPublishableKey() {
|
||||
return publishableKey;
|
||||
}
|
||||
|
||||
/** JWT issuer claim used to validate tokens minted by this project. */
|
||||
public String getIssuer() {
|
||||
return url + "/auth/v1";
|
||||
}
|
||||
|
||||
/** JWKS URL for {@code NimbusJwtDecoder.withJwkSetUri(...)}. */
|
||||
public String getJwksUrl() {
|
||||
return getIssuer() + "/.well-known/jwks.json";
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package stirling.software.proprietary.security.supabase;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Produces a {@link JwtDecoder} bean for the proprietary Supabase login path. Only registered when
|
||||
* {@code security.supabase.user-login.enabled=true}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
prefix = "security.supabase.user-login",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@RequiredArgsConstructor
|
||||
public class SupabaseJwtDecoderFactory {
|
||||
|
||||
private final SupabaseUserLoginProperties properties;
|
||||
|
||||
@Bean
|
||||
public JwtDecoder supabaseUserLoginJwtDecoder() {
|
||||
if (!properties.isJwtConfigured()) {
|
||||
log.warn(
|
||||
"security.supabase.user-login.enabled=true but issuer URL is not set;"
|
||||
+ " producing a fail-closed JwtDecoder that rejects every token."
|
||||
+ " Set security.supabase.user-login.issuer to enable real verification.");
|
||||
return token -> {
|
||||
throw new org.springframework.security.oauth2.jwt.JwtException(
|
||||
"Supabase user-login issuer not configured");
|
||||
};
|
||||
}
|
||||
String jwks = properties.getIssuer() + "/.well-known/jwks.json";
|
||||
log.info("Configuring proprietary-mode Supabase JwtDecoder with JWKS: {}", jwks);
|
||||
return NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.security.supabase;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Config for the optional Supabase login on proprietary deployments. */
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "security.supabase.user-login")
|
||||
public class SupabaseUserLoginProperties {
|
||||
|
||||
/** Master switch. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Supabase project issuer URL, e.g. {@code https://abcd1234.supabase.co/auth/v1}. */
|
||||
private String issuer;
|
||||
|
||||
/** Optional expected JWT audience; empty disables aud validation. */
|
||||
private String expectedAud;
|
||||
|
||||
/** Clock skew tolerance for JWT exp validation. */
|
||||
private long clockSkewSeconds = 120L;
|
||||
|
||||
/** When true, a Supabase JWT for an unknown email auto-creates a local user. */
|
||||
private boolean autoCreate = false;
|
||||
|
||||
public boolean isJwtConfigured() {
|
||||
return enabled && issuer != null && !issuer.isBlank();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user