fixed login refresh issue

# Conflicts:
#	frontend/src/core/contexts/AppConfigContext.tsx
#	frontend/src/core/hooks/useEndpointConfig.ts
This commit is contained in:
Dario Ghunney Ware 2025-10-27 17:58:50 +00:00 committed by DarioGii
parent b0cbf38fb3
commit 9b8d36479f
13 changed files with 222 additions and 50 deletions

View File

@ -3,9 +3,9 @@ logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE

View File

@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
HttpServletResponse response,
AuthenticationException authException)
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());
}
}
}

View File

@ -195,6 +195,18 @@ public class SecurityConfiguration {
});
http.authenticationProvider(daoAuthenticationProvider());
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(
logout ->
logout.logoutRequestMatcher(
@ -262,7 +274,6 @@ public class SecurityConfiguration {
"/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");
})
@ -333,8 +344,12 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))

View File

@ -21,11 +21,15 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@ -39,6 +43,7 @@ public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
/**
* Login endpoint - replaces Supabase signInWithPassword
@ -49,8 +54,11 @@ public class AuthController {
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request, HttpServletResponse response) {
@RequestBody UsernameAndPass request,
HttpServletRequest httpRequest,
HttpServletResponse response) {
try {
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
@ -67,20 +75,30 @@ public class AuthController {
.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 =
userDetailsService.loadUserByUsername(request.getUsername().trim());
// Check if account is blocked due to too many failed attempts
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;
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)
.body(Map.of("error", "Invalid credentials"));
}
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)
.body(Map.of("error", "User account is disabled"));
}
@ -91,7 +109,9 @@ public class AuthController {
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(
Map.of(
@ -99,11 +119,15 @@ public class AuthController {
"session", Map.of("access_token", token, "expires_in", 3600)));
} 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)
.body(Map.of("error", "Invalid username or password"));
} 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)
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {

View File

@ -88,7 +88,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|| 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");
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh")
|| requestURI.startsWith(contextPath + "/api/v1/config");
if (!isPublicAuthEndpoint) {
// For API requests, return 401 JSON

View File

@ -165,12 +165,7 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info(
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
isValid,
githubClient.getClientId(),
githubClient.getClientSecret() != null ? "***" : "null",
githubClient.getScopes());
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(

View File

@ -29,6 +29,8 @@ class JwtAuthenticationEntryPointTest {
@Test
void testCommence() throws IOException {
String errorMessage = "Authentication failed";
when(request.getRequestURI()).thenReturn("/redact");
when(authException.getMessage()).thenReturn(errorMessage);
jwtAuthenticationEntryPoint.commence(request, response, authException);

View File

@ -1,6 +1,12 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
// Helper to get JWT from localStorage for Authorization header
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('stirling_jwt');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
export interface AppConfig {
baseUrl?: string;
contextPath?: string;
@ -48,35 +54,71 @@ export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const headers = useRequestHeaders();
const [hasFetched, setHasFetched] = useState(false);
const fetchConfig = async () => {
// Prevent duplicate fetches
if (hasFetched) {
console.debug('[useAppConfig] Already fetched, skipping');
return;
}
try {
setLoading(true);
setError(null);
setHasFetched(true);
const response = await fetch('/api/v1/config/app-config', {
headers,
headers: getAuthHeaders(),
});
if (!response.ok) {
// On 401 (not authenticated), use default config with login enabled
if (response.status === 401) {
console.debug('[useAppConfig] 401 error - using default config (login enabled)');
setConfig({ enableLogin: true });
setLoading(false);
return;
}
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
console.debug('[AppConfig] Config fetched successfully:', data);
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[AppConfig] Failed to fetch app config:', err);
// On error, assume login is enabled (safe default)
setConfig({ enableLogin: true });
} finally {
setLoading(false);
}
};
useEffect(() => {
// Only fetch config if we have JWT or if checking for anonymous mode
const hasJwt = !!localStorage.getItem('stirling_jwt');
// Always try to fetch config to check if login is disabled
// The endpoint should be public and return proper JSON
fetchConfig();
}, []);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[useAppConfig] JWT available event - refetching config');
// Reset the flag to allow refetch with JWT
setHasFetched(false);
fetchConfig();
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, []);
const value: AppConfigContextValue = {
config,
loading,

View File

@ -1,6 +1,12 @@
import { useState, useEffect } from 'react';
import { useRequestHeaders } from '@app/hooks/useRequestHeaders';
// Helper to get JWT from localStorage for Authorization header
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('stirling_jwt');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
/**
* Hook to check if a specific endpoint is enabled
*/
@ -27,7 +33,7 @@ export function useEndpointEnabled(endpoint: string): {
setError(null);
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, {
headers,
headers: getAuthHeaders(),
});
if (!response.ok) {
@ -69,15 +75,37 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastFetchedEndpoints, setLastFetchedEndpoints] = useState<string>('');
const headers = useRequestHeaders();
const fetchAllEndpointStatuses = async () => {
const endpointsKey = endpoints.join(',');
// Skip if we already fetched these exact endpoints
if (lastFetchedEndpoints === endpointsKey && Object.keys(endpointStatus).length > 0) {
console.debug('[useEndpointConfig] Already fetched these endpoints, skipping');
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
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 {
setLoading(true);
setError(null);
@ -86,26 +114,38 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const endpointsParam = endpoints.join(',');
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, {
headers,
headers: getAuthHeaders(),
});
if (!response.ok) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (response.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
setLoading(false);
return;
}
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
}
const statusMap: Record<string, boolean> = await response.json();
setEndpointStatus(statusMap);
setLastFetchedEndpoints(endpointsKey);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to check multiple endpoints:', err);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are disabled on error
const fallbackStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = false;
// 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(fallbackStatus);
setEndpointStatus(optimisticStatus);
} finally {
setLoading(false);
}
@ -115,6 +155,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
fetchAllEndpointStatuses();
}, [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 - refetching endpoints');
// Reset to allow refetch with JWT
setLastFetchedEndpoints('');
fetchAllEndpointStatuses();
};
window.addEventListener('jwt-available', handleJwtAvailable);
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
}, [endpoints.join(',')]);
return {
endpointStatus,
loading,

View File

@ -24,10 +24,18 @@ export const useToolManagement = (): ToolManagementResult => {
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
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]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {

View File

@ -95,23 +95,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
console.debug('[Auth] Initializing auth...');
// First check if login is enabled
const configResponse = await fetch('/api/v1/config/app-config');
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
// Skip config check entirely - let the app handle login state
// The config will be fetched by useAppConfig when needed
const { data, error } = await springAuth.getSession();
if (!mounted) return;

View File

@ -85,20 +85,44 @@ class SpringAuthClient {
}
// Verify with backend
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await fetch('/api/v1/auth/me', {
headers: {
'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) {
// 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
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
console.warn('[SpringAuth] getSession: Cleared invalid JWT from localStorage');
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();
console.debug('[SpringAuth] /me response data:', data);
// Create session object
const session: Session = {
@ -151,6 +175,9 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
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 = {
user: data.user,
access_token: token,
@ -299,6 +326,9 @@ class SpringAuthClient {
// Store new token
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
const userResponse = await fetch('/api/v1/auth/me', {
headers: {

View File

@ -36,6 +36,9 @@ export default function AuthCallback() {
localStorage.setItem('stirling_jwt', token);
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
await refreshSession();