Merge remote-tracking branch 'origin/V2' into demo

This commit is contained in:
Anthony Stirling 2025-11-17 12:15:04 +00:00
commit 11c080bc43
5 changed files with 104 additions and 56 deletions

View File

@ -0,0 +1,37 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
Admin workflows for issuing time-limited invitations to external users
Self-service invite acceptance and team assignment
License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}

View File

@ -129,61 +129,53 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
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;
configuredOrigins);
} else {
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean

View File

@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@UserApi
@InviteApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;

View File

@ -107,7 +107,7 @@ class SpringAuthClient {
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
return decodeURIComponent(value);
}
}
return null;
@ -278,7 +278,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@ -311,7 +311,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});

View File

@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null {
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
} catch (error) {
console.error('[API Client] Failed to read XSRF token from cookies:', error);
return null;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
config.headers['X-XSRF-TOKEN'] = xsrfToken;
}
return config;
},
(error) => {