mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Merge remote-tracking branch 'origin/V2' into mainToV2
This commit is contained in:
+1
-1
@@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@EnterpriseEndpoint
|
||||
public class ProprietaryUIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -89,6 +88,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@Operation(summary = "Get audit dashboard data")
|
||||
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
|
||||
AuditDashboardData data = new AuditDashboardData();
|
||||
|
||||
-1
@@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.WEB));
|
||||
jwtService.addToken(response, jwt);
|
||||
log.debug("JWT generated for user: {}", userName);
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, "/");
|
||||
|
||||
+13
-8
@@ -72,13 +72,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
authentication.getClass().getSimpleName());
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else if (jwtService != null) {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
jwtService.clearToken(response);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
if (jwtService != null) {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Redirect to login page after logout
|
||||
String path = checkForErrors(request);
|
||||
getRedirectStrategy().sendRedirect(request, response, path);
|
||||
@@ -119,8 +120,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
|
||||
// Redirect to identity provider for logout. todo: add relay state
|
||||
samlClient.redirectToIdentityProvider(response, null, nameIdValue);
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
|
||||
// Redirect to identity provider for logout with relay state
|
||||
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error retrieving logout URL from Provider {} for user {}",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public class RateLimitResetScheduler {
|
||||
|
||||
private final IPRateLimitingFilter rateLimitingFilter;
|
||||
|
||||
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
|
||||
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
|
||||
public void resetRateLimit() {
|
||||
rateLimitingFilter.resetRequestCounts();
|
||||
}
|
||||
|
||||
+60
-42
@@ -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;
|
||||
@@ -132,19 +128,14 @@ public class SecurityConfiguration {
|
||||
if (loginEnabledValue) {
|
||||
boolean v2Enabled = appConfig.v2Enabled();
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(
|
||||
jwtAuthenticationFilter(),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
jwtAuthenticationEntryPoint));
|
||||
}
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
||||
}
|
||||
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
@@ -156,6 +147,13 @@ public class SecurityConfiguration {
|
||||
csrf ->
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Ignore CSRF for auth endpoints
|
||||
if (uri.startsWith("/api/v1/auth/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
// If there's no API key, don't ignore CSRF
|
||||
// (return false)
|
||||
@@ -238,9 +236,12 @@ public class SecurityConfiguration {
|
||||
: uri;
|
||||
return trimmedUri.startsWith("/login")
|
||||
|| trimmedUri.startsWith("/oauth")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.endsWith(".svg")
|
||||
|| trimmedUri.startsWith("/register")
|
||||
|| trimmedUri.startsWith("/signup")
|
||||
|| trimmedUri.startsWith("/auth/callback")
|
||||
|| trimmedUri.startsWith("/error")
|
||||
|| trimmedUri.startsWith("/images/")
|
||||
|| trimmedUri.startsWith("/public/")
|
||||
@@ -252,6 +253,16 @@ public class SecurityConfiguration {
|
||||
|| trimmedUri.startsWith("/favicon")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/info/status")
|
||||
|| trimmedUri.startsWith("/api/v1/config")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/user/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/me")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| uri.contains("/v1/api-docs");
|
||||
})
|
||||
@@ -277,33 +288,40 @@ public class SecurityConfiguration {
|
||||
// Handle OAUTH2 Logins
|
||||
if (securityProperties.isOauth2Active()) {
|
||||
http.oauth2Login(
|
||||
oauth2 ->
|
||||
oauth2.loginPage("/oauth2")
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(
|
||||
new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties,
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll());
|
||||
oauth2 -> {
|
||||
// v1: Use /oauth2 as login page for Thymeleaf templates
|
||||
if (!v2Enabled) {
|
||||
oauth2.loginPage("/oauth2");
|
||||
}
|
||||
|
||||
// v2: Don't set loginPage, let default OAuth2 flow handle it
|
||||
oauth2
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties
|
||||
.getOauth2(),
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll();
|
||||
});
|
||||
}
|
||||
// Handle SAML
|
||||
if (securityProperties.isSaml2Active() && runningProOrHigher) {
|
||||
|
||||
+187
-5
@@ -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;
|
||||
@@ -33,7 +41,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.common.util.RegexPatternUtils;
|
||||
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||
@@ -47,6 +57,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 =
|
||||
@@ -203,8 +214,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(
|
||||
@@ -215,7 +226,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) {
|
||||
@@ -226,8 +239,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)
|
||||
@@ -401,6 +430,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;
|
||||
@@ -639,4 +763,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);
|
||||
}
|
||||
}
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
|
||||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
*
|
||||
* @param request Login credentials (email/username and password)
|
||||
* @param response HTTP response to set JWT cookie
|
||||
* @return User and session information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request, HttpServletResponse response) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
log.warn("Login attempt with null or empty username");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Username is required"));
|
||||
}
|
||||
|
||||
if (request.getPassword() == null || request.getPassword().isEmpty()) {
|
||||
log.warn(
|
||||
"Login attempt with null or empty password for user: {}",
|
||||
request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
log.debug("Login attempt for user: {}", request.getUsername());
|
||||
|
||||
UserDetails userDetails =
|
||||
userDetailsService.loadUserByUsername(request.getUsername().trim());
|
||||
User user = (User) userDetails;
|
||||
|
||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||
log.warn("Invalid password for user: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
log.warn("Disabled user attempted login: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
|
||||
log.info("Login successful for user: {}", request.getUsername());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid username or password"));
|
||||
} catch (AuthenticationException e) {
|
||||
log.error("Authentication failed for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
} catch (Exception e) {
|
||||
log.error("Login error for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*
|
||||
* @return Current authenticated user information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> getCurrentUser() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = (UserDetails) auth.getPrincipal();
|
||||
User user = (User) userDetails;
|
||||
|
||||
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Get current user error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint
|
||||
*
|
||||
* @param response HTTP response
|
||||
* @return Success message
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
try {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
log.debug("User logged out successfully");
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Logout error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token
|
||||
*
|
||||
* @param request HTTP request containing current JWT cookie
|
||||
* @param response HTTP response to set new JWT cookie
|
||||
* @return New token information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
|
||||
if (token == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Request/Response DTOs
|
||||
// ===========================
|
||||
|
||||
/** Login request DTO */
|
||||
public record LoginRequest(String email, String password) {}
|
||||
}
|
||||
+95
-29
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -16,7 +15,6 @@ 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 = MediaType.MULTIPART_FORM_DATA_VALUE, 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)
|
||||
.location(URI.create("/database?error=downloadFailed"))
|
||||
.build();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"downloadFailed",
|
||||
"message",
|
||||
"Failed to download file: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Create a database backup",
|
||||
description =
|
||||
"This endpoint triggers the creation of a database backup and redirects to the"
|
||||
+ " database management page.")
|
||||
description = "This endpoint triggers the creation of a database backup.")
|
||||
@GetMapping("/createDatabaseBackup")
|
||||
public String createDatabaseBackup() {
|
||||
public ResponseEntity<?> createDatabaseBackup() {
|
||||
log.info("Starting database backup creation...");
|
||||
databaseService.exportDatabase();
|
||||
log.info("Database backup successfully created.");
|
||||
return "redirect:/database?infoMessage=backupCreated";
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"backupCreated",
|
||||
"description",
|
||||
"Database backup created successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+44
-27
@@ -1,10 +1,12 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@@ -30,98 +32,113 @@ public class TeamController {
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/create")
|
||||
public RedirectView createTeam(@RequestParam("name") String name) {
|
||||
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
|
||||
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||
return new RedirectView("/teams?messageType=teamExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = new Team();
|
||||
team.setName(name);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamCreated");
|
||||
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/rename")
|
||||
public RedirectView renameTeam(
|
||||
public ResponseEntity<?> renameTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
|
||||
Optional<Team> existing = teamRepository.findById(teamId);
|
||||
if (existing.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
if (teamRepository.existsByNameIgnoreCase(newName)) {
|
||||
return new RedirectView("/teams?messageType=teamNameExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = existing.get();
|
||||
|
||||
// Prevent renaming the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot rename Internal team."));
|
||||
}
|
||||
|
||||
team.setName(newName);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamRenamed");
|
||||
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/delete")
|
||||
@Transactional
|
||||
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent deleting the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot delete Internal team."));
|
||||
}
|
||||
|
||||
long memberCount = userRepository.countByTeam(team);
|
||||
if (memberCount > 0) {
|
||||
return new RedirectView("/teams?messageType=teamHasUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Team must be empty before deletion. Please remove all members first."));
|
||||
}
|
||||
|
||||
teamRepository.delete(team);
|
||||
return new RedirectView("/teams?messageType=teamDeleted");
|
||||
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/addUser")
|
||||
@Transactional
|
||||
public RedirectView addUserToTeam(
|
||||
public ResponseEntity<?> addUserToTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
|
||||
|
||||
// Find the team
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent adding users to the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?error=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot add users to Internal team."));
|
||||
}
|
||||
|
||||
// Find the user
|
||||
User user =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
Optional<User> userOpt = userRepository.findById(userId);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
// Check if user is in the Internal team - prevent moving them
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot move users from Internal team."));
|
||||
}
|
||||
|
||||
// Assign user to team
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
|
||||
// Redirect back to team details page
|
||||
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
|
||||
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+375
-85
@@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -15,10 +16,7 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
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.ui.Model;
|
||||
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;
|
||||
@@ -41,6 +39,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;
|
||||
@@ -56,128 +55,218 @@ 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")
|
||||
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
|
||||
model.addAttribute("error", "Username already exists");
|
||||
return "register";
|
||||
}
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: invalid username format: {}",
|
||||
usernameAndPass.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null
|
||||
|| usernameAndPass.getPassword().length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters"));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
userService.saveUser(
|
||||
requestModel.getUsername(),
|
||||
requestModel.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
Map.of(
|
||||
"user",
|
||||
buildUserResponse(user),
|
||||
"message",
|
||||
"Account created successfully. Please log in."));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "redirect:/login?messageType=invalidUsername";
|
||||
log.error("Registration validation error: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/login?registered=true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-username")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
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")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
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")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
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')")
|
||||
@@ -195,23 +284,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,
|
||||
@@ -221,33 +310,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
|
||||
@@ -263,28 +361,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,
|
||||
@@ -292,27 +506,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();
|
||||
|
||||
@@ -322,15 +541,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);
|
||||
@@ -339,30 +558,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);
|
||||
@@ -389,23 +609,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 =
|
||||
@@ -415,7 +636,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')")
|
||||
@@ -446,4 +667,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
|
||||
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
|
||||
|
||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||
|
||||
@Query("SELECT u FROM User u WHERE u.team IS NULL")
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
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);
|
||||
DateTimeFormatter timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
String creationTime =
|
||||
timeFormat.format(
|
||||
Instant.ofEpochMilli(session.getCreationTime())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalTime());
|
||||
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);
|
||||
}
|
||||
}
|
||||
+45
-8
@@ -1,8 +1,9 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.*;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
@@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String jwtToken = jwtService.extractToken(request);
|
||||
|
||||
if (jwtToken == null) {
|
||||
// Any unauthenticated requests should redirect to /login
|
||||
// Allow specific auth endpoints to pass through without JWT
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if (!requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect("/login");
|
||||
// Public auth endpoints that don't require JWT
|
||||
boolean isPublicAuthEndpoint =
|
||||
requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/signup")
|
||||
|| requestURI.startsWith(contextPath + "/auth/")
|
||||
|| requestURI.startsWith(contextPath + "/oauth2")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
|
||||
|
||||
if (!isPublicAuthEndpoint) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null
|
||||
&& acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// For public auth endpoints without JWT, continue to the endpoint
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
jwtService.clearToken(response);
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
@@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private void processUserAuthenticationType(Map<String, Object> claims, String username)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
AuthenticationType authenticationType =
|
||||
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
|
||||
AuthenticationType.valueOf(
|
||||
claims.getOrDefault("authType", WEB).toString().toUpperCase());
|
||||
log.debug("Processing {} login for {} user", authenticationType, username);
|
||||
|
||||
switch (authenticationType) {
|
||||
case OAUTH2 -> {
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties =
|
||||
securityProperties.getOauth2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
}
|
||||
case SAML2 -> {
|
||||
ApplicationProperties.Security.SAML2 saml2Properties =
|
||||
securityProperties.getSaml2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username, null, null, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/pdfjs/",
|
||||
contextPath + "/pdfjs-legacy/",
|
||||
contextPath + "/api/v1/info/status",
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/register",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/site.webmanifest"
|
||||
};
|
||||
|
||||
|
||||
+18
-1
@@ -1,12 +1,15 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
@@ -59,12 +62,17 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "authenticationtype")
|
||||
private String authenticationType;
|
||||
|
||||
@Column(name = "sso_provider_id")
|
||||
private String ssoProviderId;
|
||||
|
||||
@Column(name = "sso_provider")
|
||||
private String ssoProvider;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "team_id")
|
||||
@JsonIgnore
|
||||
private Team team;
|
||||
|
||||
@ElementCollection
|
||||
@@ -72,8 +80,17 @@ 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
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public String getRoleName() {
|
||||
return Role.getRoleNameByRoleId(getRolesAsString());
|
||||
}
|
||||
|
||||
+85
-9
@@ -10,6 +10,7 @@ import java.util.Map;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@@ -76,12 +77,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
throw new LockedException(
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
jwtService.addToken(response, jwt);
|
||||
}
|
||||
if (userService.isUserDisabled(username)) {
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
@@ -102,14 +97,95 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
|
||||
return;
|
||||
}
|
||||
if (principal instanceof OAuth2User) {
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
// Extract SSO provider information from OAuth2User
|
||||
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
|
||||
// Extract provider from authentication - need to get it from the token/request
|
||||
// For now, we'll extract it in a more generic way
|
||||
String ssoProvider = extractProviderFromAuthentication(authentication);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
oauth2Properties.getAutoCreateUser(),
|
||||
OAUTH2);
|
||||
}
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
response.sendRedirect(contextPath + "/");
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the OAuth2 provider registration ID from the authentication object.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @return The provider registration ID (e.g., "google", "github"), or null if not available
|
||||
*/
|
||||
private String extractProviderFromAuthentication(Authentication authentication) {
|
||||
if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
|
||||
return oauth2Token.getAuthorizedClientRegistrationId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
// Fall back to other methods if referer is malformed
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin.toString() + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-8
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnBooleanProperty("security.oauth2.enabled")
|
||||
@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true")
|
||||
public class OAuth2Configuration {
|
||||
|
||||
public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/";
|
||||
@@ -53,6 +52,9 @@ public class OAuth2Configuration {
|
||||
ApplicationProperties applicationProperties, @Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
log.info(
|
||||
"OAuth2Configuration initialized - OAuth2 enabled: {}",
|
||||
applicationProperties.getSecurity().getOauth2().getEnabled());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -75,7 +77,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> keycloakClientRegistration() {
|
||||
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> googleClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -138,12 +140,23 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> githubClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2)) {
|
||||
log.debug("OAuth2 is disabled, skipping GitHub client registration");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Client client = oAuth2.getClient();
|
||||
if (client == null) {
|
||||
log.debug("OAuth2 client configuration is null, skipping GitHub");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
GitHubProvider githubClient = client.getGithub();
|
||||
if (githubClient == null) {
|
||||
log.debug("GitHub client configuration is null");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Provider github =
|
||||
new GitHubProvider(
|
||||
githubClient.getClientId(),
|
||||
@@ -151,7 +164,15 @@ public class OAuth2Configuration {
|
||||
githubClient.getScopes(),
|
||||
githubClient.getUseAsUsername());
|
||||
|
||||
return validateProvider(github)
|
||||
boolean isValid = validateProvider(github);
|
||||
log.info(
|
||||
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
|
||||
isValid,
|
||||
githubClient.getClientId(),
|
||||
githubClient.getClientSecret() != null ? "***" : "null",
|
||||
githubClient.getScopes());
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
ClientRegistration.withRegistrationId(github.getName())
|
||||
.clientId(github.getClientId())
|
||||
@@ -171,7 +192,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> oidcClientRegistration() {
|
||||
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) {
|
||||
if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -207,7 +228,7 @@ public class OAuth2Configuration {
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
|
||||
private boolean isOAuth2Disabled(OAUTH2 oAuth2) {
|
||||
return oAuth2 == null || !oAuth2.getEnabled();
|
||||
}
|
||||
|
||||
|
||||
+74
-10
@@ -120,13 +120,41 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
log.debug("Processing SSO post-login for user: {}", username);
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
username,
|
||||
ssoProvider,
|
||||
ssoProviderId);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
saml2Properties.getAutoCreateUser(),
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
generateJwt(response, authentication);
|
||||
response.sendRedirect(contextPath + "/");
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.debug(
|
||||
"Invalid username detected for user: {}, redirecting to logout",
|
||||
@@ -140,12 +168,48 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
}
|
||||
|
||||
private void generateJwt(HttpServletResponse response, Authentication authentication) {
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.SAML2));
|
||||
jwtService.addToken(response, jwt);
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.enumeration.UsernameAttribute;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
public CustomOAuth2UserService(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties,
|
||||
UserService userService,
|
||||
LoginAttemptService loginAttemptService) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.oauth2Properties = oauth2Properties;
|
||||
this.userService = userService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
}
|
||||
@@ -42,14 +41,22 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
try {
|
||||
OidcUser user = delegate.loadUser(userRequest);
|
||||
OAUTH2 oauth2 = securityProperties.getOauth2();
|
||||
UsernameAttribute usernameAttribute =
|
||||
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
|
||||
String usernameAttributeKey = usernameAttribute.getName();
|
||||
String usernameAttributeKey =
|
||||
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
|
||||
.getName();
|
||||
|
||||
// todo: save user by OIDC ID instead of username
|
||||
Optional<User> internalUser =
|
||||
userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey));
|
||||
// Extract SSO provider information
|
||||
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
|
||||
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
|
||||
String username = user.getAttribute(usernameAttributeKey);
|
||||
|
||||
log.debug(
|
||||
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
|
||||
ssoProvider,
|
||||
ssoProviderId,
|
||||
username);
|
||||
|
||||
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
|
||||
|
||||
if (internalUser.isPresent()) {
|
||||
String internalUsername = internalUser.get().getUsername();
|
||||
|
||||
+86
@@ -73,4 +73,90 @@ public class EmailService {
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a plain text/HTML email without attachments asynchronously.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param subject The email subject
|
||||
* @param body The email body (can contain HTML)
|
||||
* @param isHtml Whether the body contains HTML content
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendPlainEmail(String to, String subject, String body, boolean isHtml)
|
||||
throws MessagingException {
|
||||
// Validate recipient email address
|
||||
if (to == null || to.trim().isEmpty()) {
|
||||
throw new MessagingException("Invalid recipient email address");
|
||||
}
|
||||
|
||||
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
|
||||
|
||||
// Creates a MimeMessage to represent the email
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
|
||||
// Helper class to set up the message content
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false);
|
||||
|
||||
// Sets the recipient, subject, body, and sender email
|
||||
helper.addTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, isHtml);
|
||||
helper.setFrom(mailProperties.getFrom());
|
||||
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation email to a new user with their credentials.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param username The username for the new account
|
||||
* @param temporaryPassword The temporary password
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteEmail(String to, String username, String temporaryPassword)
|
||||
throws MessagingException {
|
||||
String subject = "Welcome to Stirling PDF";
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join the workspace. Below are your login credentials:</p>
|
||||
<!-- Credentials Box -->
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> %s</p>
|
||||
<p style="margin: 0;"><strong>Temporary Password:</strong> %s</p>
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
|
||||
</div>
|
||||
<p>Please keep these credentials secure and do not share them with anyone.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(username, temporaryPassword);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-46
@@ -14,14 +14,11 @@ import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import io.github.pixee.security.Newlines;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.UnsupportedJwtException;
|
||||
import io.jsonwebtoken.security.SignatureException;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String JWT_COOKIE_NAME = "stirling_jwt";
|
||||
private static final String ISSUER = "Stirling PDF";
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
|
||||
@Value("${stirling.security.jwt.secureCookie:true}")
|
||||
private boolean secureCookie;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
@@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public String extractToken(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
|
||||
if (cookies != null) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
// Extract from Authorization header Bearer token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addToken(HttpServletResponse response, String token) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("Strict")
|
||||
.maxAge(EXPIRATION / 1000)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, "")
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("None")
|
||||
.maxAge(0)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isJwtEnabled() {
|
||||
return v2Enabled;
|
||||
|
||||
-16
@@ -5,7 +5,6 @@ import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
public interface JwtServiceInterface {
|
||||
|
||||
@@ -66,21 +65,6 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractToken(HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* Add JWT token to HTTP response (header and cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
* @param token JWT token to add
|
||||
*/
|
||||
void addToken(HttpServletResponse response, String token);
|
||||
|
||||
/**
|
||||
* Clear JWT token from HTTP response (remove cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
*/
|
||||
void clearToken(HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* Check if JWT authentication is enabled
|
||||
*
|
||||
|
||||
+105
-4
@@ -61,19 +61,46 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
// Handle OAUTH2 login and user auto creation.
|
||||
public void processSSOPostLogin(
|
||||
String username, boolean autoCreateUser, AuthenticationType type)
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
boolean autoCreateUser,
|
||||
AuthenticationType type)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!isUsernameValid(username)) {
|
||||
return;
|
||||
}
|
||||
Optional<User> existingUser = findByUsernameIgnoreCase(username);
|
||||
|
||||
// Find user by SSO provider ID first
|
||||
Optional<User> existingUser;
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
existingUser =
|
||||
userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.debug("User found by SSO provider ID: {}", ssoProviderId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
existingUser = findByUsernameIgnoreCase(username);
|
||||
if (existingUser.isPresent()) {
|
||||
User user = existingUser.get();
|
||||
|
||||
// Migrate existing user to use provider ID if not already set
|
||||
if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) {
|
||||
log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId);
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, type);
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +182,21 @@ public class UserService implements UserServiceInterface {
|
||||
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
authenticationType,
|
||||
(Long) null,
|
||||
Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
@@ -169,6 +211,30 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
ssoProviderId, // ssoProviderId
|
||||
ssoProvider, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -184,6 +250,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -198,6 +266,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -213,6 +283,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -228,6 +300,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -248,6 +322,8 @@ public class UserService implements UserServiceInterface {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -412,6 +488,8 @@ public class UserService implements UserServiceInterface {
|
||||
*
|
||||
* @param username Username for the new user
|
||||
* @param password Password for the user (may be null for SSO/OAuth users)
|
||||
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
|
||||
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
|
||||
* @param authenticationType Type of authentication (WEB, SSO, etc.)
|
||||
* @param teamId ID of the team to assign (may be null to use default)
|
||||
* @param team Team object to assign (takes precedence over teamId if both provided)
|
||||
@@ -426,6 +504,8 @@ public class UserService implements UserServiceInterface {
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
@@ -446,6 +526,12 @@ public class UserService implements UserServiceInterface {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
|
||||
@@ -562,6 +648,21 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
return authentication.getAuthorities().stream()
|
||||
.anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking admin status", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncCustomApiUser(String customApiKey) {
|
||||
if (customApiKey == null || customApiKey.trim().isBlank()) {
|
||||
|
||||
+34
-1
@@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
|
||||
this.licenseKeyChecker = licenseKeyChecker;
|
||||
}
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
@@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
return enabled && hasProOrEnterpriseAccess();
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
@@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
log.info("Server certificate feature requires Pro or Enterprise license");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
@@ -88,6 +106,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
@@ -114,6 +137,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
@@ -174,6 +202,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
|
||||
+3
-4
@@ -1,6 +1,8 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
verify(jwtService).clearToken(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-1
@@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest {
|
||||
.setAuthentication(any(UsernamePasswordAuthenticationToken.class));
|
||||
verify(jwtService)
|
||||
.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims));
|
||||
verify(jwtService).addToken(response, newToken);
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-62
@@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.contains;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -17,7 +15,6 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -27,13 +24,10 @@ import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@@ -59,7 +53,7 @@ class JwtServiceTest {
|
||||
private JwtVerificationKey testVerificationKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchAlgorithmException {
|
||||
void setUp() throws Exception {
|
||||
// Generate a test keypair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
@@ -224,7 +218,8 @@ class JwtServiceTest {
|
||||
assertEquals("admin", extractedClaims.get("role"));
|
||||
assertEquals("IT", extractedClaims.get("department"));
|
||||
assertEquals(username, extractedClaims.get("sub"));
|
||||
assertEquals("Stirling PDF", extractedClaims.get("iss"));
|
||||
// Verify the constant issuer is set correctly
|
||||
assertEquals("https://stirling.com", extractedClaims.get("iss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -239,62 +234,27 @@ class JwtServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithCookie() {
|
||||
void testExtractTokenWithAuthorizationHeader() {
|
||||
String token = "test-token";
|
||||
Cookie[] cookies = {new Cookie("stirling_jwt", token)};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
|
||||
|
||||
assertEquals(token, jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithNoCookies() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
void testExtractTokenWithNoAuthorizationHeader() {
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithWrongCookie() {
|
||||
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
void testExtractTokenWithInvalidAuthorizationHeaderFormat() {
|
||||
when(request.getHeader("Authorization")).thenReturn("InvalidFormat token");
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithInvalidAuthorizationHeader() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void testAddToken(boolean secureCookie) throws Exception {
|
||||
String token = "test-token";
|
||||
|
||||
// Create new JwtService instance with the secureCookie parameter
|
||||
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
|
||||
|
||||
testJwtService.addToken(response, token);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
|
||||
|
||||
if (secureCookie) {
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearToken() {
|
||||
jwtService.clearToken(response);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt="));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateTokenWithKeyId() throws Exception {
|
||||
String username = "testuser";
|
||||
@@ -373,17 +333,4 @@ class JwtServiceTest {
|
||||
// Verify fallback logic was used
|
||||
verify(keystoreService, atLeast(1)).getActiveKey();
|
||||
}
|
||||
|
||||
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
|
||||
// Use reflection to create JwtService with custom secureCookie value
|
||||
JwtService testService = new JwtService(true, keystoreService);
|
||||
|
||||
// Set the secureCookie field using reflection
|
||||
java.lang.reflect.Field secureCookieField =
|
||||
JwtService.class.getDeclaredField("secureCookie");
|
||||
secureCookieField.setAccessible(true);
|
||||
secureCookieField.set(testService, secureCookie);
|
||||
|
||||
return testService;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user