mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Login Refresh Fix (#4779)
Main Issues Fixed: 1. Tools Disabled on Initial Login (Required Page Refresh) Problem: After successful login, all PDF tools appeared grayed out/disabled until the user refreshed the page. Root Cause: Race condition where tools checked endpoint availability before JWT was stored in localStorage. Fix: - Implemented optimistic defaults in useEndpointConfig - assumes endpoints are enabled when no JWT exists - Added JWT availability event system (jwt-available event) to notify components when authentication is ready - Tools now remain enabled during auth initialization instead of defaulting to disabled 2. Session Lost on Page Refresh (Immediate Logout) Problem: Users were immediately logged out when refreshing the page, losing their authenticated session. Root Causes: - Spring Security form login was redirecting API calls to /login with 302 responses instead of returning JSON - /api/v1/auth/me endpoint was incorrectly in the permitAll list - JWT filter wasn't allowing /api/v1/config endpoints without authentication Fixes: - Backend: Disabled form login in v2/JWT mode by adding && !v2Enabled condition to form login configuration - Backend: Removed /api/v1/auth/me from permitAll list - it now requires authentication - Backend: Added /api/v1/config to public endpoints in JWT filter - Backend: Configured proper exception handling for API endpoints to return JSON (401) instead of HTML redirects (302) 3. Multiple Duplicate API Calls Problem: After login, /app-config was called 5+ times, /endpoints-enabled and /me called multiple times, causing unnecessary network traffic. Root Cause: Multiple React components each had their own instance of useAppConfig and useEndpointConfig hooks, each fetching data independently. Fix: - Frontend: Created singleton AppConfigContext provider to ensure only one global config fetch - Frontend: Added global caching to useEndpointConfig with module-level cache variables - Frontend: Implemented fetch deduplication with fetchCount tracking and globalFetchedSets - Result: Reduced API calls from 5+ to 1-2 per endpoint (2 in dev due to React StrictMode) Additional Improvements: CORS Configuration - Added flexible CORS configuration matching SaaS pattern - Explicitly allows localhost development ports (3000, 5173, 5174, etc.) - No hardcoded URLs in application.properties Security Handlers Integration - Added IP-based account locking without dependency on form login - Preserved audit logging with @Audited annotations Key Code Changes: Backend Files: - SecurityConfiguration.java - Disabled form login for v2, added CORS config - JwtAuthenticationFilter.java - Added /api/v1/config to public endpoints - JwtAuthenticationEntryPoint.java - Returns JSON for API requests Frontend Files: - AppConfigContext.tsx - New singleton context for app configuration - useEndpointConfig.ts - Added global caching and deduplication - UseSession.tsx - Removed redundant config checking - Various hooks - Updated to use context providers instead of direct fetching --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy <Ludy87@users.noreply.github.com> Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan <ethan@MacBook-Pro.local> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
9440e99227
commit
f5c67a3239
@ -49,4 +49,33 @@ public class RequestUriUtils {
|
|||||||
|| requestURI.startsWith("/fonts")
|
|| requestURI.startsWith("/fonts")
|
||||||
|| requestURI.startsWith("/pdfjs"));
|
|| requestURI.startsWith("/pdfjs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the request URI is a public authentication endpoint that doesn't require
|
||||||
|
* authentication. This includes login, signup, OAuth callbacks, and public config endpoints.
|
||||||
|
*
|
||||||
|
* @param requestURI The full request URI
|
||||||
|
* @param contextPath The servlet context path
|
||||||
|
* @return true if the endpoint is public and doesn't require authentication
|
||||||
|
*/
|
||||||
|
public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
|
||||||
|
// Remove context path from URI to normalize path matching
|
||||||
|
String trimmedUri =
|
||||||
|
requestURI.startsWith(contextPath)
|
||||||
|
? requestURI.substring(contextPath.length())
|
||||||
|
: requestURI;
|
||||||
|
|
||||||
|
// Public auth endpoints that don't require authentication
|
||||||
|
return trimmedUri.startsWith("/login")
|
||||||
|
|| trimmedUri.startsWith("/auth/")
|
||||||
|
|| trimmedUri.startsWith("/oauth2")
|
||||||
|
|| trimmedUri.startsWith("/saml2")
|
||||||
|
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|
||||||
|
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||||
|
|| trimmedUri.startsWith("/v1/api-docs")
|
||||||
|
|| trimmedUri.contains("/v1/api-docs");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
|||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
AuthenticationException authException)
|
AuthenticationException authException)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
|
String contextPath = request.getContextPath();
|
||||||
|
String requestURI = request.getRequestURI();
|
||||||
|
|
||||||
|
// For API requests, return JSON error
|
||||||
|
if (requestURI.startsWith(contextPath + "/api/")) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
String message =
|
||||||
|
authException != null ? authException.getMessage() : "Authentication required";
|
||||||
|
response.getWriter().write("{\"error\":\"" + message + "\"}");
|
||||||
|
} else {
|
||||||
|
// For non-API requests, use default behavior
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package stirling.software.proprietary.security.configuration;
|
package stirling.software.proprietary.security.configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -28,11 +29,15 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|||||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.configuration.AppConfig;
|
import stirling.software.common.configuration.AppConfig;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.util.RequestUriUtils;
|
||||||
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
||||||
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
|
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
|
||||||
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||||
@ -67,6 +72,7 @@ public class SecurityConfiguration {
|
|||||||
private final boolean loginEnabledValue;
|
private final boolean loginEnabledValue;
|
||||||
private final boolean runningProOrHigher;
|
private final boolean runningProOrHigher;
|
||||||
|
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
private final ApplicationProperties.Security securityProperties;
|
private final ApplicationProperties.Security securityProperties;
|
||||||
private final AppConfig appConfig;
|
private final AppConfig appConfig;
|
||||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||||
@ -86,6 +92,7 @@ public class SecurityConfiguration {
|
|||||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||||
AppConfig appConfig,
|
AppConfig appConfig,
|
||||||
|
ApplicationProperties applicationProperties,
|
||||||
ApplicationProperties.Security securityProperties,
|
ApplicationProperties.Security securityProperties,
|
||||||
UserAuthenticationFilter userAuthenticationFilter,
|
UserAuthenticationFilter userAuthenticationFilter,
|
||||||
JwtServiceInterface jwtService,
|
JwtServiceInterface jwtService,
|
||||||
@ -102,6 +109,7 @@ public class SecurityConfiguration {
|
|||||||
this.loginEnabledValue = loginEnabledValue;
|
this.loginEnabledValue = loginEnabledValue;
|
||||||
this.runningProOrHigher = runningProOrHigher;
|
this.runningProOrHigher = runningProOrHigher;
|
||||||
this.appConfig = appConfig;
|
this.appConfig = appConfig;
|
||||||
|
this.applicationProperties = applicationProperties;
|
||||||
this.securityProperties = securityProperties;
|
this.securityProperties = securityProperties;
|
||||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
@ -120,7 +128,79 @@ public class SecurityConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
// Read CORS allowed origins from settings
|
||||||
|
if (applicationProperties.getSystem() != null
|
||||||
|
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||||
|
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||||
|
|
||||||
|
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||||
|
|
||||||
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
|
|
||||||
|
// Use setAllowedOriginPatterns for better wildcard and port support
|
||||||
|
cfg.setAllowedOriginPatterns(allowedOrigins);
|
||||||
|
log.debug(
|
||||||
|
"CORS configured with allowed origin patterns from settings.yml: {}",
|
||||||
|
allowedOrigins);
|
||||||
|
|
||||||
|
// Set allowed methods explicitly (including OPTIONS for preflight)
|
||||||
|
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
|
|
||||||
|
// Set allowed headers explicitly
|
||||||
|
cfg.setAllowedHeaders(
|
||||||
|
List.of(
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"X-API-KEY",
|
||||||
|
"X-CSRF-TOKEN"));
|
||||||
|
|
||||||
|
// Set exposed headers (headers that the browser can access)
|
||||||
|
cfg.setExposedHeaders(
|
||||||
|
List.of(
|
||||||
|
"WWW-Authenticate",
|
||||||
|
"X-Total-Count",
|
||||||
|
"X-Page-Number",
|
||||||
|
"X-Page-Size",
|
||||||
|
"Content-Disposition",
|
||||||
|
"Content-Type"));
|
||||||
|
|
||||||
|
// Allow credentials (cookies, authorization headers)
|
||||||
|
cfg.setAllowCredentials(true);
|
||||||
|
|
||||||
|
// Set max age for preflight cache
|
||||||
|
cfg.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", cfg);
|
||||||
|
return source;
|
||||||
|
} else {
|
||||||
|
// No CORS origins configured - return null to disable CORS processing entirely
|
||||||
|
// This avoids empty CORS policy that unexpectedly rejects preflights
|
||||||
|
log.info(
|
||||||
|
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(
|
||||||
|
HttpSecurity http,
|
||||||
|
@Lazy IPRateLimitingFilter rateLimitingFilter,
|
||||||
|
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
|
||||||
|
throws Exception {
|
||||||
|
// Enable CORS only if we have configured origins
|
||||||
|
CorsConfigurationSource corsSource = corsConfigurationSource();
|
||||||
|
if (corsSource != null) {
|
||||||
|
http.cors(cors -> cors.configurationSource(corsSource));
|
||||||
|
} else {
|
||||||
|
// Explicitly disable CORS when no origins are configured
|
||||||
|
http.cors(cors -> cors.disable());
|
||||||
|
}
|
||||||
|
|
||||||
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
|
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
|
||||||
http.csrf(CsrfConfigurer::disable);
|
http.csrf(CsrfConfigurer::disable);
|
||||||
}
|
}
|
||||||
@ -130,12 +210,8 @@ public class SecurityConfiguration {
|
|||||||
|
|
||||||
http.addFilterBefore(
|
http.addFilterBefore(
|
||||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.addFilterBefore(
|
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||||
|
|
||||||
if (v2Enabled) {
|
|
||||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!securityProperties.getCsrfDisabled()) {
|
if (!securityProperties.getCsrfDisabled()) {
|
||||||
CookieCsrfTokenRepository cookieRepo =
|
CookieCsrfTokenRepository cookieRepo =
|
||||||
@ -195,6 +271,18 @@ public class SecurityConfiguration {
|
|||||||
});
|
});
|
||||||
http.authenticationProvider(daoAuthenticationProvider());
|
http.authenticationProvider(daoAuthenticationProvider());
|
||||||
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
||||||
|
|
||||||
|
// Configure exception handling for API endpoints
|
||||||
|
http.exceptionHandling(
|
||||||
|
exceptions ->
|
||||||
|
exceptions.defaultAuthenticationEntryPointFor(
|
||||||
|
jwtAuthenticationEntryPoint,
|
||||||
|
request -> {
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
String requestURI = request.getRequestURI();
|
||||||
|
return requestURI.startsWith(contextPath + "/api/");
|
||||||
|
}));
|
||||||
|
|
||||||
http.logout(
|
http.logout(
|
||||||
logout ->
|
logout ->
|
||||||
logout.logoutRequestMatcher(
|
logout.logoutRequestMatcher(
|
||||||
@ -228,43 +316,12 @@ public class SecurityConfiguration {
|
|||||||
String uri = req.getRequestURI();
|
String uri = req.getRequestURI();
|
||||||
String contextPath = req.getContextPath();
|
String contextPath = req.getContextPath();
|
||||||
|
|
||||||
// Remove the context path from the URI
|
// Check if it's a public auth endpoint or static
|
||||||
String trimmedUri =
|
// resource
|
||||||
uri.startsWith(contextPath)
|
return RequestUriUtils.isStaticResource(
|
||||||
? uri.substring(
|
contextPath, uri)
|
||||||
contextPath.length())
|
|| RequestUriUtils.isPublicAuthEndpoint(
|
||||||
: uri;
|
uri, contextPath);
|
||||||
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/")
|
|
||||||
|| trimmedUri.startsWith("/css/")
|
|
||||||
|| trimmedUri.startsWith("/fonts/")
|
|
||||||
|| trimmedUri.startsWith("/js/")
|
|
||||||
|| trimmedUri.startsWith("/pdfjs/")
|
|
||||||
|| trimmedUri.startsWith("/pdfjs-legacy/")
|
|
||||||
|| 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");
|
|
||||||
})
|
})
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
@ -333,8 +390,12 @@ public class SecurityConfiguration {
|
|||||||
.saml2Login(
|
.saml2Login(
|
||||||
saml2 -> {
|
saml2 -> {
|
||||||
try {
|
try {
|
||||||
saml2.loginPage("/saml2")
|
// Only set login page for v1/Thymeleaf mode
|
||||||
.relyingPartyRegistrationRepository(
|
if (!v2Enabled) {
|
||||||
|
saml2.loginPage("/saml2");
|
||||||
|
}
|
||||||
|
|
||||||
|
saml2.relyingPartyRegistrationRepository(
|
||||||
saml2RelyingPartyRegistrations)
|
saml2RelyingPartyRegistrations)
|
||||||
.authenticationManager(
|
.authenticationManager(
|
||||||
new ProviderManager(authenticationProvider))
|
new ProviderManager(authenticationProvider))
|
||||||
|
|||||||
@ -21,11 +21,15 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.audit.AuditEventType;
|
||||||
|
import stirling.software.proprietary.audit.AuditLevel;
|
||||||
|
import stirling.software.proprietary.audit.Audited;
|
||||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||||
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
/** REST API Controller for authentication operations. */
|
/** REST API Controller for authentication operations. */
|
||||||
@ -39,6 +43,7 @@ public class AuthController {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final JwtServiceInterface jwtService;
|
private final JwtServiceInterface jwtService;
|
||||||
private final CustomUserDetailsService userDetailsService;
|
private final CustomUserDetailsService userDetailsService;
|
||||||
|
private final LoginAttemptService loginAttemptService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login endpoint - replaces Supabase signInWithPassword
|
* Login endpoint - replaces Supabase signInWithPassword
|
||||||
@ -49,8 +54,11 @@ public class AuthController {
|
|||||||
*/
|
*/
|
||||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
|
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||||
public ResponseEntity<?> login(
|
public ResponseEntity<?> login(
|
||||||
@RequestBody UsernameAndPass request, HttpServletResponse response) {
|
@RequestBody UsernameAndPass request,
|
||||||
|
HttpServletRequest httpRequest,
|
||||||
|
HttpServletResponse response) {
|
||||||
try {
|
try {
|
||||||
// Validate input parameters
|
// Validate input parameters
|
||||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||||
@ -67,20 +75,30 @@ public class AuthController {
|
|||||||
.body(Map.of("error", "Password is required"));
|
.body(Map.of("error", "Password is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("Login attempt for user: {}", request.getUsername());
|
String username = request.getUsername().trim();
|
||||||
|
String ip = httpRequest.getRemoteAddr();
|
||||||
|
|
||||||
UserDetails userDetails =
|
// Check if account is blocked due to too many failed attempts
|
||||||
userDetailsService.loadUserByUsername(request.getUsername().trim());
|
if (loginAttemptService.isBlocked(username)) {
|
||||||
|
log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip);
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(Map.of("error", "Account is locked due to too many failed attempts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Login attempt for user: {} from IP: {}", username, ip);
|
||||||
|
|
||||||
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
User user = (User) userDetails;
|
User user = (User) userDetails;
|
||||||
|
|
||||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||||
log.warn("Invalid password for user: {}", request.getUsername());
|
log.warn("Invalid password for user: {} from IP: {}", username, ip);
|
||||||
|
loginAttemptService.loginFailed(username);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("error", "Invalid credentials"));
|
.body(Map.of("error", "Invalid credentials"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isEnabled()) {
|
if (!user.isEnabled()) {
|
||||||
log.warn("Disabled user attempted login: {}", request.getUsername());
|
log.warn("Disabled user attempted login: {} from IP: {}", username, ip);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("error", "User account is disabled"));
|
.body(Map.of("error", "User account is disabled"));
|
||||||
}
|
}
|
||||||
@ -91,7 +109,9 @@ public class AuthController {
|
|||||||
|
|
||||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||||
|
|
||||||
log.info("Login successful for user: {}", request.getUsername());
|
// Record successful login
|
||||||
|
loginAttemptService.loginSucceeded(username);
|
||||||
|
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
Map.of(
|
Map.of(
|
||||||
@ -99,11 +119,15 @@ public class AuthController {
|
|||||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||||
|
|
||||||
} catch (UsernameNotFoundException e) {
|
} catch (UsernameNotFoundException e) {
|
||||||
log.warn("User not found: {}", request.getUsername());
|
String username = request.getUsername();
|
||||||
|
log.warn("User not found: {}", username);
|
||||||
|
loginAttemptService.loginFailed(username);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("error", "Invalid username or password"));
|
.body(Map.of("error", "Invalid username or password"));
|
||||||
} catch (AuthenticationException e) {
|
} catch (AuthenticationException e) {
|
||||||
log.error("Authentication failed for user: {}", request.getUsername(), e);
|
String username = request.getUsername();
|
||||||
|
log.error("Authentication failed for user: {}", username, e);
|
||||||
|
loginAttemptService.loginFailed(username);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("error", "Invalid credentials"));
|
.body(Map.of("error", "Invalid credentials"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -228,11 +252,4 @@ public class AuthController {
|
|||||||
|
|
||||||
return userMap;
|
return userMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================
|
|
||||||
// Request/Response DTOs
|
|
||||||
// ===========================
|
|
||||||
|
|
||||||
/** Login request DTO */
|
|
||||||
public record LoginRequest(String email, String password) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package stirling.software.proprietary.security.filter;
|
package stirling.software.proprietary.security.filter;
|
||||||
|
|
||||||
|
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
|
||||||
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
||||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
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.SAML2;
|
||||||
@ -80,17 +81,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
String contextPath = request.getContextPath();
|
String contextPath = request.getContextPath();
|
||||||
|
|
||||||
// Public auth endpoints that don't require JWT
|
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||||
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
|
// For API requests, return 401 JSON
|
||||||
String acceptHeader = request.getHeader("Accept");
|
String acceptHeader = request.getHeader("Accept");
|
||||||
if (requestURI.startsWith(contextPath + "/api/")
|
if (requestURI.startsWith(contextPath + "/api/")
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package stirling.software.proprietary.security.filter;
|
package stirling.software.proprietary.security.filter;
|
||||||
|
|
||||||
|
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -105,11 +107,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we still don't have any authentication, deny the request
|
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
|
||||||
if (authentication == null || !authentication.isAuthenticated()) {
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
String method = request.getMethod();
|
String method = request.getMethod();
|
||||||
String contextPath = request.getContextPath();
|
String contextPath = request.getContextPath();
|
||||||
|
|
||||||
|
// Allow public auth endpoints to pass through without authentication
|
||||||
|
if (isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
|
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
|
||||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||||
} else {
|
} else {
|
||||||
@ -200,6 +208,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
|
||||||
|
// Remove context path from URI to normalize path matching
|
||||||
|
String trimmedUri =
|
||||||
|
requestURI.startsWith(contextPath)
|
||||||
|
? requestURI.substring(contextPath.length())
|
||||||
|
: requestURI;
|
||||||
|
|
||||||
|
// Public auth endpoints that don't require authentication
|
||||||
|
return trimmedUri.startsWith("/login")
|
||||||
|
|| trimmedUri.startsWith("/auth/")
|
||||||
|
|| trimmedUri.startsWith("/oauth2")
|
||||||
|
|| trimmedUri.startsWith("/saml2")
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||||
|
|| trimmedUri.startsWith("/api/v1/auth/logout");
|
||||||
|
}
|
||||||
|
|
||||||
private enum UserLoginType {
|
private enum UserLoginType {
|
||||||
USERDETAILS("UserDetails"),
|
USERDETAILS("UserDetails"),
|
||||||
OAUTH2USER("OAuth2User"),
|
OAUTH2USER("OAuth2User"),
|
||||||
@ -225,7 +250,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String contextPath = request.getContextPath();
|
String contextPath = request.getContextPath();
|
||||||
String[] permitAllPatterns = {
|
String[] permitAllPatterns = {
|
||||||
contextPath + "/login",
|
contextPath + "/login",
|
||||||
contextPath + "/signup",
|
|
||||||
contextPath + "/register",
|
contextPath + "/register",
|
||||||
contextPath + "/error",
|
contextPath + "/error",
|
||||||
contextPath + "/images/",
|
contextPath + "/images/",
|
||||||
@ -237,7 +261,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
contextPath + "/pdfjs-legacy/",
|
contextPath + "/pdfjs-legacy/",
|
||||||
contextPath + "/api/v1/info/status",
|
contextPath + "/api/v1/info/status",
|
||||||
contextPath + "/api/v1/auth/login",
|
contextPath + "/api/v1/auth/login",
|
||||||
contextPath + "/api/v1/auth/register",
|
|
||||||
contextPath + "/api/v1/auth/refresh",
|
contextPath + "/api/v1/auth/refresh",
|
||||||
contextPath + "/api/v1/auth/me",
|
contextPath + "/api/v1/auth/me",
|
||||||
contextPath + "/site.webmanifest"
|
contextPath + "/site.webmanifest"
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
|
|||||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.security.authentication.LockedException;
|
import org.springframework.security.authentication.LockedException;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
@ -16,6 +21,7 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
|
|||||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||||
|
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
@ -37,6 +43,9 @@ import stirling.software.proprietary.security.service.UserService;
|
|||||||
public class CustomOAuth2AuthenticationSuccessHandler
|
public class CustomOAuth2AuthenticationSuccessHandler
|
||||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||||
|
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||||
|
|
||||||
private final LoginAttemptService loginAttemptService;
|
private final LoginAttemptService loginAttemptService;
|
||||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
@ -119,7 +128,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||||
|
|
||||||
// Build context-aware redirect URL based on the original request
|
// Build context-aware redirect URL based on the original request
|
||||||
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
|
String redirectUrl =
|
||||||
|
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
|
||||||
|
|
||||||
response.sendRedirect(redirectUrl);
|
response.sendRedirect(redirectUrl);
|
||||||
} else {
|
} else {
|
||||||
@ -149,30 +159,110 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
* Builds a context-aware redirect URL based on the request's origin
|
* Builds a context-aware redirect URL based on the request's origin
|
||||||
*
|
*
|
||||||
* @param request The HTTP request
|
* @param request The HTTP request
|
||||||
|
* @param response HTTP response (used to clear redirect cookies)
|
||||||
* @param contextPath The application context path
|
* @param contextPath The application context path
|
||||||
* @param jwt The JWT token to include
|
* @param jwt The JWT token to include
|
||||||
* @return The appropriate redirect URL
|
* @return The appropriate redirect URL
|
||||||
*/
|
*/
|
||||||
private String buildContextAwareRedirectUrl(
|
private String buildContextAwareRedirectUrl(
|
||||||
HttpServletRequest request, String contextPath, String jwt) {
|
HttpServletRequest request,
|
||||||
// Try to get the origin from the Referer header first
|
HttpServletResponse response,
|
||||||
|
String contextPath,
|
||||||
|
String jwt) {
|
||||||
|
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||||
|
String origin =
|
||||||
|
resolveForwardedOrigin(request)
|
||||||
|
.orElseGet(
|
||||||
|
() ->
|
||||||
|
resolveOriginFromReferer(request)
|
||||||
|
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||||
|
clearRedirectCookie(response);
|
||||||
|
return origin + redirectPath + "#access_token=" + jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||||
|
return extractRedirectPathFromCookie(request)
|
||||||
|
.filter(path -> path.startsWith("/"))
|
||||||
|
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||||
|
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||||
|
if (!value.isEmpty()) {
|
||||||
|
return Optional.of(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultCallbackPath(String contextPath) {
|
||||||
|
if (contextPath == null
|
||||||
|
|| contextPath.isBlank()
|
||||||
|
|| "/".equals(contextPath)
|
||||||
|
|| "\\".equals(contextPath)) {
|
||||||
|
return DEFAULT_CALLBACK_PATH;
|
||||||
|
}
|
||||||
|
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||||
|
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
|
||||||
|
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String host = forwardedHostHeader.split(",")[0].trim();
|
||||||
|
if (host.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
|
||||||
|
String proto =
|
||||||
|
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
|
||||||
|
? request.getScheme()
|
||||||
|
: forwardedProtoHeader.split(",")[0].trim();
|
||||||
|
|
||||||
|
if (!host.contains(":")) {
|
||||||
|
String forwardedPort = request.getHeader("X-Forwarded-Port");
|
||||||
|
if (forwardedPort != null
|
||||||
|
&& !forwardedPort.isBlank()
|
||||||
|
&& !isDefaultPort(proto, forwardedPort.trim())) {
|
||||||
|
host = host + ":" + forwardedPort.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.of(proto + "://" + host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
|
||||||
String referer = request.getHeader("Referer");
|
String referer = request.getHeader("Referer");
|
||||||
if (referer != null && !referer.isEmpty()) {
|
if (referer != null && !referer.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
java.net.URL refererUrl = new java.net.URL(referer);
|
java.net.URL refererUrl = new java.net.URL(referer);
|
||||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
String refererHost = refererUrl.getHost().toLowerCase();
|
||||||
if (refererUrl.getPort() != -1
|
|
||||||
&& refererUrl.getPort() != 80
|
if (!isOAuthProviderDomain(refererHost)) {
|
||||||
&& refererUrl.getPort() != 443) {
|
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||||
origin += ":" + refererUrl.getPort();
|
if (refererUrl.getPort() != -1
|
||||||
|
&& refererUrl.getPort() != 80
|
||||||
|
&& refererUrl.getPort() != 443) {
|
||||||
|
origin += ":" + refererUrl.getPort();
|
||||||
|
}
|
||||||
|
return Optional.of(origin);
|
||||||
}
|
}
|
||||||
return origin + "/auth/callback#access_token=" + jwt;
|
|
||||||
} catch (java.net.MalformedURLException e) {
|
} catch (java.net.MalformedURLException e) {
|
||||||
// Fall back to other methods if referer is malformed
|
// ignore and fall back
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to building from request host/port
|
private String buildOriginFromRequest(HttpServletRequest request) {
|
||||||
String scheme = request.getScheme();
|
String scheme = request.getScheme();
|
||||||
String serverName = request.getServerName();
|
String serverName = request.getServerName();
|
||||||
int serverPort = request.getServerPort();
|
int serverPort = request.getServerPort();
|
||||||
@ -180,12 +270,50 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
|||||||
StringBuilder origin = new StringBuilder();
|
StringBuilder origin = new StringBuilder();
|
||||||
origin.append(scheme).append("://").append(serverName);
|
origin.append(scheme).append("://").append(serverName);
|
||||||
|
|
||||||
// Only add port if it's not the default port for the scheme
|
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
|
||||||
if ((!"http".equals(scheme) || serverPort != 80)
|
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
|
||||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
|
||||||
origin.append(":").append(serverPort);
|
origin.append(":").append(serverPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
return origin.toString() + "/auth/callback#access_token=" + jwt;
|
return origin.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDefaultPort(String scheme, String port) {
|
||||||
|
if (port == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int parsedPort = Integer.parseInt(port);
|
||||||
|
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|
||||||
|
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearRedirectCookie(HttpServletResponse response) {
|
||||||
|
ResponseCookie cookie =
|
||||||
|
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
|
||||||
|
.path("/")
|
||||||
|
.sameSite("Lax")
|
||||||
|
.maxAge(0)
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given hostname belongs to a known OAuth provider.
|
||||||
|
*
|
||||||
|
* @param hostname The hostname to check
|
||||||
|
* @return true if it's an OAuth provider domain, false otherwise
|
||||||
|
*/
|
||||||
|
private boolean isOAuthProviderDomain(String hostname) {
|
||||||
|
return hostname.contains("google.com")
|
||||||
|
|| hostname.contains("googleapis.com")
|
||||||
|
|| hostname.contains("github.com")
|
||||||
|
|| hostname.contains("microsoft.com")
|
||||||
|
|| hostname.contains("microsoftonline.com")
|
||||||
|
|| hostname.contains("linkedin.com")
|
||||||
|
|| hostname.contains("apple.com");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,12 +165,7 @@ public class OAuth2Configuration {
|
|||||||
githubClient.getUseAsUsername());
|
githubClient.getUseAsUsername());
|
||||||
|
|
||||||
boolean isValid = validateProvider(github);
|
boolean isValid = validateProvider(github);
|
||||||
log.info(
|
log.info("Initialised GitHub OAuth2 provider");
|
||||||
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
|
|
||||||
isValid,
|
|
||||||
githubClient.getClientId(),
|
|
||||||
githubClient.getClientSecret() != null ? "***" : "null",
|
|
||||||
githubClient.getScopes());
|
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
? Optional.of(
|
? Optional.of(
|
||||||
|
|||||||
@ -4,15 +4,21 @@ import static stirling.software.proprietary.security.model.AuthenticationType.SA
|
|||||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.security.authentication.LockedException;
|
import org.springframework.security.authentication.LockedException;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||||
|
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
@ -36,6 +42,9 @@ import stirling.software.proprietary.security.service.UserService;
|
|||||||
public class CustomSaml2AuthenticationSuccessHandler
|
public class CustomSaml2AuthenticationSuccessHandler
|
||||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||||
|
|
||||||
|
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||||
|
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||||
|
|
||||||
private LoginAttemptService loginAttemptService;
|
private LoginAttemptService loginAttemptService;
|
||||||
private ApplicationProperties.Security.SAML2 saml2Properties;
|
private ApplicationProperties.Security.SAML2 saml2Properties;
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
@ -148,7 +157,7 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
|
|
||||||
// Build context-aware redirect URL based on the original request
|
// Build context-aware redirect URL based on the original request
|
||||||
String redirectUrl =
|
String redirectUrl =
|
||||||
buildContextAwareRedirectUrl(request, contextPath, jwt);
|
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
|
||||||
|
|
||||||
response.sendRedirect(redirectUrl);
|
response.sendRedirect(redirectUrl);
|
||||||
} else {
|
} else {
|
||||||
@ -177,8 +186,81 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
* @return The appropriate redirect URL
|
* @return The appropriate redirect URL
|
||||||
*/
|
*/
|
||||||
private String buildContextAwareRedirectUrl(
|
private String buildContextAwareRedirectUrl(
|
||||||
HttpServletRequest request, String contextPath, String jwt) {
|
HttpServletRequest request,
|
||||||
// Try to get the origin from the Referer header first
|
HttpServletResponse response,
|
||||||
|
String contextPath,
|
||||||
|
String jwt) {
|
||||||
|
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||||
|
String origin =
|
||||||
|
resolveForwardedOrigin(request)
|
||||||
|
.orElseGet(
|
||||||
|
() ->
|
||||||
|
resolveOriginFromReferer(request)
|
||||||
|
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||||
|
clearRedirectCookie(response);
|
||||||
|
return origin + redirectPath + "#access_token=" + jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||||
|
return extractRedirectPathFromCookie(request)
|
||||||
|
.filter(path -> path.startsWith("/"))
|
||||||
|
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||||
|
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||||
|
if (!value.isEmpty()) {
|
||||||
|
return Optional.of(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultCallbackPath(String contextPath) {
|
||||||
|
if (contextPath == null
|
||||||
|
|| contextPath.isBlank()
|
||||||
|
|| "/".equals(contextPath)
|
||||||
|
|| "\\".equals(contextPath)) {
|
||||||
|
return DEFAULT_CALLBACK_PATH;
|
||||||
|
}
|
||||||
|
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||||
|
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
|
||||||
|
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String host = forwardedHostHeader.split(",")[0].trim();
|
||||||
|
if (host.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
|
||||||
|
String proto =
|
||||||
|
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
|
||||||
|
? request.getScheme()
|
||||||
|
: forwardedProtoHeader.split(",")[0].trim();
|
||||||
|
|
||||||
|
if (!host.contains(":")) {
|
||||||
|
String forwardedPort = request.getHeader("X-Forwarded-Port");
|
||||||
|
if (forwardedPort != null
|
||||||
|
&& !forwardedPort.isBlank()
|
||||||
|
&& !isDefaultPort(proto, forwardedPort.trim())) {
|
||||||
|
host = host + ":" + forwardedPort.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.of(proto + "://" + host);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
|
||||||
String referer = request.getHeader("Referer");
|
String referer = request.getHeader("Referer");
|
||||||
if (referer != null && !referer.isEmpty()) {
|
if (referer != null && !referer.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
@ -189,14 +271,16 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
&& refererUrl.getPort() != 443) {
|
&& refererUrl.getPort() != 443) {
|
||||||
origin += ":" + refererUrl.getPort();
|
origin += ":" + refererUrl.getPort();
|
||||||
}
|
}
|
||||||
return origin + "/auth/callback#access_token=" + jwt;
|
return Optional.of(origin);
|
||||||
} catch (java.net.MalformedURLException e) {
|
} catch (java.net.MalformedURLException e) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to building from request host/port
|
private String buildOriginFromRequest(HttpServletRequest request) {
|
||||||
String scheme = request.getScheme();
|
String scheme = request.getScheme();
|
||||||
String serverName = request.getServerName();
|
String serverName = request.getServerName();
|
||||||
int serverPort = request.getServerPort();
|
int serverPort = request.getServerPort();
|
||||||
@ -204,12 +288,34 @@ public class CustomSaml2AuthenticationSuccessHandler
|
|||||||
StringBuilder origin = new StringBuilder();
|
StringBuilder origin = new StringBuilder();
|
||||||
origin.append(scheme).append("://").append(serverName);
|
origin.append(scheme).append("://").append(serverName);
|
||||||
|
|
||||||
// Only add port if it's not the default port for the scheme
|
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
|
||||||
if ((!"http".equals(scheme) || serverPort != 80)
|
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
|
||||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
|
||||||
origin.append(":").append(serverPort);
|
origin.append(":").append(serverPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
return origin + "/auth/callback#access_token=" + jwt;
|
return origin.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDefaultPort(String scheme, String port) {
|
||||||
|
if (port == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int parsedPort = Integer.parseInt(port);
|
||||||
|
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|
||||||
|
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearRedirectCookie(HttpServletResponse response) {
|
||||||
|
ResponseCookie cookie =
|
||||||
|
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
|
||||||
|
.path("/")
|
||||||
|
.sameSite("Lax")
|
||||||
|
.maxAge(0)
|
||||||
|
.build();
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ class JwtAuthenticationEntryPointTest {
|
|||||||
@Test
|
@Test
|
||||||
void testCommence() throws IOException {
|
void testCommence() throws IOException {
|
||||||
String errorMessage = "Authentication failed";
|
String errorMessage = "Authentication failed";
|
||||||
|
|
||||||
|
when(request.getRequestURI()).thenReturn("/redact");
|
||||||
when(authException.getMessage()).thenReturn(errorMessage);
|
when(authException.getMessage()).thenReturn(errorMessage);
|
||||||
|
|
||||||
jwtAuthenticationEntryPoint.commence(request, response, authException);
|
jwtAuthenticationEntryPoint.commence(request, response, authException);
|
||||||
|
|||||||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
@ -441,6 +441,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@ -487,6 +488,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -510,6 +512,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||||
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
|
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/engines": "1.4.1",
|
"@embedpdf/engines": "1.4.1",
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
@ -593,6 +596,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
|
||||||
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
|
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -609,6 +613,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
|
||||||
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
|
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -626,6 +631,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
|
||||||
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
|
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -662,6 +668,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
||||||
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
|
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -696,6 +703,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
|
||||||
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
|
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -732,6 +740,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
|
||||||
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
|
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -807,6 +816,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
|
||||||
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
|
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@embedpdf/models": "1.4.1"
|
"@embedpdf/models": "1.4.1"
|
||||||
},
|
},
|
||||||
@ -962,6 +972,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@ -1005,6 +1016,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@ -2035,6 +2047,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
|
||||||
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
|
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27.16",
|
"@floating-ui/react": "^0.27.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -2085,6 +2098,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
|
||||||
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
|
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.x || ^19.x"
|
"react": "^18.x || ^19.x"
|
||||||
}
|
}
|
||||||
@ -2152,6 +2166,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
|
||||||
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
|
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/core-downloads-tracker": "^7.3.4",
|
"@mui/core-downloads-tracker": "^7.3.4",
|
||||||
@ -3835,6 +3850,7 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@ -4158,6 +4174,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@ -4168,6 +4185,7 @@
|
|||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@ -4228,6 +4246,7 @@
|
|||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
@ -4941,7 +4960,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
||||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.22"
|
||||||
}
|
}
|
||||||
@ -4951,7 +4969,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
||||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.22",
|
"@vue/reactivity": "3.5.22",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.22"
|
||||||
@ -4962,7 +4979,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
||||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.22",
|
"@vue/reactivity": "3.5.22",
|
||||||
"@vue/runtime-core": "3.5.22",
|
"@vue/runtime-core": "3.5.22",
|
||||||
@ -4975,7 +4991,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
||||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.22",
|
"@vue/compiler-ssr": "3.5.22",
|
||||||
"@vue/shared": "3.5.22"
|
"@vue/shared": "3.5.22"
|
||||||
@ -5002,6 +5017,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -5686,6 +5702,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@ -6731,7 +6748,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
|
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
|
||||||
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
|
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dezalgo": {
|
"node_modules/dezalgo": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
@ -7126,6 +7144,7 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -7296,6 +7315,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@ -8618,6 +8638,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@ -9425,6 +9446,7 @@
|
|||||||
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
|
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/dom-selector": "^6.7.2",
|
"@asamuzakjp/dom-selector": "^6.7.2",
|
||||||
"cssstyle": "^5.3.1",
|
"cssstyle": "^5.3.1",
|
||||||
@ -11201,6 +11223,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -11480,6 +11503,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@ -11852,6 +11876,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -11861,6 +11886,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -13531,6 +13557,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -13832,6 +13859,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -13914,6 +13942,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napi-postinstall": "^0.3.0"
|
"napi-postinstall": "^0.3.0"
|
||||||
},
|
},
|
||||||
@ -14118,6 +14147,7 @@
|
|||||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -14269,6 +14299,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -14282,6 +14313,7 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@ -36,52 +36,84 @@ interface AppConfigContextValue {
|
|||||||
refetch: () => Promise<void>;
|
refetch: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create context
|
const AppConfigContext = createContext<AppConfigContextValue | undefined>({
|
||||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
|
config: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
refetch: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider component that fetches and provides app configuration
|
* Provider component that fetches and provides app configuration
|
||||||
* Should be placed at the top level of the app, before any components that need config
|
* Should be placed at the top level of the app, before any components that need config
|
||||||
*/
|
*/
|
||||||
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const headers = useRequestHeaders();
|
const [fetchCount, setFetchCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchConfig = async (force = false) => {
|
||||||
|
// Prevent duplicate fetches unless forced
|
||||||
|
if (!force && fetchCount > 0) {
|
||||||
|
console.debug('[AppConfig] Already fetched, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch('/api/v1/config/app-config', {
|
// apiClient automatically adds JWT header if available via interceptors
|
||||||
headers,
|
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
|
||||||
});
|
const data = response.data;
|
||||||
|
|
||||||
if (!response.ok) {
|
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||||
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
|
setConfig(data);
|
||||||
|
setFetchCount(prev => prev + 1);
|
||||||
|
} catch (err: any) {
|
||||||
|
// On 401 (not authenticated), use default config with login enabled
|
||||||
|
// This allows the app to work even without authentication
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
console.debug('[AppConfig] 401 error - using default config (login enabled)');
|
||||||
|
setConfig({ enableLogin: true });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: AppConfig = await response.json();
|
|
||||||
setConfig(data);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error('[AppConfig] Failed to fetch app config:', err);
|
console.error('[AppConfig] Failed to fetch app config:', err);
|
||||||
|
// On error, assume login is enabled (safe default)
|
||||||
|
setConfig({ enableLogin: true });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Always try to fetch config to check if login is disabled
|
||||||
|
// The endpoint should be public and return proper JSON
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Listen for JWT availability (triggered on login/signup)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleJwtAvailable = () => {
|
||||||
|
console.debug('[AppConfig] JWT available event - refetching config');
|
||||||
|
// Force refetch with JWT
|
||||||
|
fetchConfig(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||||
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value: AppConfigContextValue = {
|
const value: AppConfigContextValue = {
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch: fetchConfig,
|
refetch: () => fetchConfig(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -104,4 +136,3 @@ export function useAppConfig(): AppConfigContextValue {
|
|||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
|
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||||
|
const globalFetchedSets = new Set<string>();
|
||||||
|
const globalEndpointCache: Record<string, boolean> = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to check if a specific endpoint is enabled
|
* Hook to check if a specific endpoint is enabled
|
||||||
|
* This wraps the context for single endpoint checks
|
||||||
*/
|
*/
|
||||||
export function useEndpointEnabled(endpoint: string): {
|
export function useEndpointEnabled(endpoint: string): {
|
||||||
enabled: boolean | null;
|
enabled: boolean | null;
|
||||||
@ -13,7 +18,6 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const headers = useRequestHeaders();
|
|
||||||
|
|
||||||
const fetchEndpointStatus = async () => {
|
const fetchEndpointStatus = async () => {
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
@ -26,15 +30,8 @@ export function useEndpointEnabled(endpoint: string): {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
|
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
||||||
headers,
|
const isEnabled = response.data;
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEnabled: boolean = await response.json();
|
|
||||||
setEnabled(isEnabled);
|
setEnabled(isEnabled);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
@ -69,43 +66,101 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const headers = useRequestHeaders();
|
|
||||||
|
|
||||||
const fetchAllEndpointStatuses = async () => {
|
const fetchAllEndpointStatuses = async (force = false) => {
|
||||||
|
const endpointsKey = [...endpoints].sort().join(',');
|
||||||
|
|
||||||
|
// Skip if we already fetched these exact endpoints globally
|
||||||
|
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||||
|
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||||
|
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
|
if (endpoint in globalEndpointCache) {
|
||||||
|
acc[endpoint] = globalEndpointCache[endpoint];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
setEndpointStatus(cachedStatus);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!endpoints || endpoints.length === 0) {
|
if (!endpoints || endpoints.length === 0) {
|
||||||
setEndpointStatus({});
|
setEndpointStatus({});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if JWT exists - if not, optimistically enable all endpoints
|
||||||
|
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
||||||
|
if (!hasJwt) {
|
||||||
|
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
||||||
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
|
acc[endpoint] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
setEndpointStatus(optimisticStatus);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Use batch API for efficiency
|
// Check which endpoints we haven't fetched yet
|
||||||
const endpointsParam = endpoints.join(',');
|
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||||
|
if (newEndpoints.length === 0) {
|
||||||
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, {
|
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||||
headers,
|
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
});
|
acc[endpoint] = globalEndpointCache[endpoint];
|
||||||
|
return acc;
|
||||||
if (!response.ok) {
|
}, {} as Record<string, boolean>);
|
||||||
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
|
setEndpointStatus(cachedStatus);
|
||||||
|
globalFetchedSets.add(endpointsKey);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, boolean> = await response.json();
|
// Use batch API for efficiency - only fetch new endpoints
|
||||||
setEndpointStatus(statusMap);
|
const endpointsParam = newEndpoints.join(',');
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
||||||
setError(errorMessage);
|
|
||||||
console.error('Failed to check multiple endpoints:', err);
|
|
||||||
|
|
||||||
// Fallback: assume all endpoints are disabled on error
|
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||||
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
|
const statusMap = response.data;
|
||||||
acc[endpoint] = false;
|
|
||||||
|
// Update global cache with new results
|
||||||
|
Object.assign(globalEndpointCache, statusMap);
|
||||||
|
|
||||||
|
// Get all requested endpoints from cache (including previously cached ones)
|
||||||
|
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
|
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, boolean>);
|
}, {} as Record<string, boolean>);
|
||||||
setEndpointStatus(fallbackStatus);
|
|
||||||
|
setEndpointStatus(fullStatus);
|
||||||
|
globalFetchedSets.add(endpointsKey);
|
||||||
|
} catch (err: any) {
|
||||||
|
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||||
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
|
acc[endpoint] = true;
|
||||||
|
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
setEndpointStatus(optimisticStatus);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||||
|
|
||||||
|
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||||
|
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||||
|
acc[endpoint] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
setEndpointStatus(optimisticStatus);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -115,10 +170,24 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
fetchAllEndpointStatuses();
|
fetchAllEndpointStatuses();
|
||||||
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
||||||
|
|
||||||
|
// Listen for JWT availability (triggered on login/signup)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleJwtAvailable = () => {
|
||||||
|
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
|
||||||
|
// Clear the global cache to allow refetch with JWT
|
||||||
|
globalFetchedSets.clear();
|
||||||
|
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
|
||||||
|
fetchAllEndpointStatuses(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||||
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||||
|
}, [endpoints.join(',')]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpointStatus,
|
endpointStatus,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch: fetchAllEndpointStatuses,
|
refetch: () => fetchAllEndpointStatuses(true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => {
|
|||||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||||
|
|
||||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||||
|
// Keep tools enabled during loading (optimistic UX)
|
||||||
if (endpointsLoading) return true;
|
if (endpointsLoading) return true;
|
||||||
|
|
||||||
const tool = baseRegistry[toolKey as ToolId];
|
const tool = baseRegistry[toolKey as ToolId];
|
||||||
const endpoints = tool?.endpoints || [];
|
const endpoints = tool?.endpoints || [];
|
||||||
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
|
|
||||||
|
// Tools without endpoints are always available
|
||||||
|
if (endpoints.length === 0) return true;
|
||||||
|
|
||||||
|
// Check if at least one endpoint is enabled
|
||||||
|
// If endpoint is not in status map, assume enabled (optimistic fallback)
|
||||||
|
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||||
|
|
||||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||||
|
|||||||
@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
console.debug('[Auth] Initializing auth...');
|
console.debug('[Auth] Initializing auth...');
|
||||||
|
|
||||||
// First check if login is enabled
|
// Skip config check entirely - let the app handle login state
|
||||||
const configResponse = await fetch('/api/v1/config/app-config');
|
// The config will be fetched by useAppConfig when needed
|
||||||
if (configResponse.ok) {
|
|
||||||
const config = await configResponse.json();
|
|
||||||
|
|
||||||
// If login is disabled, skip authentication entirely
|
|
||||||
if (config.enableLogin === false) {
|
|
||||||
console.debug('[Auth] Login disabled - skipping authentication');
|
|
||||||
if (mounted) {
|
|
||||||
setSession(null);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login is enabled, proceed with normal auth check
|
|
||||||
const { data, error } = await springAuth.getSession();
|
const { data, error } = await springAuth.getSession();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Auth Client
|
* Spring Auth Client
|
||||||
*
|
*
|
||||||
@ -7,6 +9,37 @@
|
|||||||
* - No email confirmation flow (auto-confirmed on registration)
|
* - No email confirmation flow (auto-confirmed on registration)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
|
||||||
|
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
|
||||||
|
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
|
||||||
|
|
||||||
|
function normalizeRedirectPath(target?: string): string {
|
||||||
|
if (!target || typeof target !== 'string') {
|
||||||
|
return DEFAULT_REDIRECT_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(target, window.location.origin);
|
||||||
|
const path = parsed.pathname || '/';
|
||||||
|
const query = parsed.search || '';
|
||||||
|
return `${path}${query}`;
|
||||||
|
} catch {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_REDIRECT_PATH;
|
||||||
|
}
|
||||||
|
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRedirectPath(path: string): void {
|
||||||
|
try {
|
||||||
|
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SpringAuth] Failed to persist OAuth redirect path', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auth types
|
// Auth types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -85,20 +118,44 @@ class SpringAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify with backend
|
// Verify with backend
|
||||||
|
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||||
const response = await fetch('/api/v1/auth/me', {
|
const response = await fetch('/api/v1/auth/me', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.debug('[SpringAuth] /me response status:', response.status);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
console.debug('[SpringAuth] /me content-type:', contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Log the error response for debugging
|
||||||
|
const errorBody = await response.text();
|
||||||
|
console.error('[SpringAuth] getSession: /api/v1/auth/me failed', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorBody
|
||||||
|
});
|
||||||
|
|
||||||
// Token invalid or expired - clear it
|
// Token invalid or expired - clear it
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
|
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
|
||||||
return { data: { session: null }, error: null };
|
return { data: { session: null }, error: { message: `Auth failed: ${response.status}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response is JSON before parsing
|
||||||
|
if (!contentType?.includes('application/json')) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error('[SpringAuth] /me returned non-JSON:', {
|
||||||
|
contentType,
|
||||||
|
bodyPreview: text.substring(0, 200)
|
||||||
|
});
|
||||||
|
throw new Error(`/api/v1/auth/me returned HTML instead of JSON`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
console.debug('[SpringAuth] /me response data:', data);
|
||||||
|
|
||||||
// Create session object
|
// Create session object
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
@ -151,6 +208,9 @@ class SpringAuthClient {
|
|||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
console.log('[SpringAuth] JWT stored in localStorage');
|
console.log('[SpringAuth] JWT stored in localStorage');
|
||||||
|
|
||||||
|
// Dispatch custom event for other components to react to JWT availability
|
||||||
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
user: data.user,
|
user: data.user,
|
||||||
access_token: token,
|
access_token: token,
|
||||||
@ -220,6 +280,9 @@ class SpringAuthClient {
|
|||||||
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
||||||
}): Promise<{ error: AuthError | null }> {
|
}): Promise<{ error: AuthError | null }> {
|
||||||
try {
|
try {
|
||||||
|
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
|
||||||
|
persistRedirectPath(redirectPath);
|
||||||
|
|
||||||
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
|
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
|
||||||
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
||||||
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
||||||
@ -299,6 +362,9 @@ class SpringAuthClient {
|
|||||||
// Store new token
|
// Store new token
|
||||||
localStorage.setItem('stirling_jwt', newToken);
|
localStorage.setItem('stirling_jwt', newToken);
|
||||||
|
|
||||||
|
// Dispatch custom event for other components to react to JWT availability
|
||||||
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
|
||||||
// Get updated user info
|
// Get updated user info
|
||||||
const userResponse = await fetch('/api/v1/auth/me', {
|
const userResponse = await fetch('/api/v1/auth/me', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -36,6 +36,9 @@ export default function AuthCallback() {
|
|||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
console.log('[AuthCallback] JWT stored in localStorage');
|
console.log('[AuthCallback] JWT stored in localStorage');
|
||||||
|
|
||||||
|
// Dispatch custom event for other components to react to JWT availability
|
||||||
|
window.dispatchEvent(new CustomEvent('jwt-available'))
|
||||||
|
|
||||||
// Refresh session to load user info into state
|
// Refresh session to load user info into state
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
|
|
||||||
|
|||||||
@ -30,21 +30,24 @@ export default defineConfig(({ mode }) => {
|
|||||||
// tell vite to ignore watching `src-tauri`
|
// tell vite to ignore watching `src-tauri`
|
||||||
ignored: ['**/src-tauri/**'],
|
ignored: ['**/src-tauri/**'],
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
xfwd: true,
|
||||||
'/oauth2': {
|
},
|
||||||
target: 'http://localhost:8080',
|
'/oauth2': {
|
||||||
changeOrigin: true,
|
target: 'http://localhost:8080',
|
||||||
secure: false,
|
changeOrigin: true,
|
||||||
},
|
secure: false,
|
||||||
'/login/oauth2': {
|
xfwd: true,
|
||||||
target: 'http://localhost:8080',
|
},
|
||||||
changeOrigin: true,
|
'/login/oauth2': {
|
||||||
secure: false,
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
xfwd: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user