SecurityConfig.java
package com.smartsupplypro.inventory.config;
import java.io.IOException;
import java.util.List;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.lang.NonNull;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import com.smartsupplypro.inventory.security.CookieOAuth2AuthorizationRequestRepository;
import com.smartsupplypro.inventory.security.OAuth2LoginSuccessHandler;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* Enterprise OAuth2 security configuration with role-based access control.
*
* <p>Implements session-based Google OAuth2 authentication with stateless authorization
* request persistence, role-based API authorization, and cross-origin request support.</p>
*
* <p><strong>Enterprise Features:</strong> OAuth2 login with custom user provisioning,
* dual authentication entry points (API vs browser), CORS with secure cookies,
* demo mode read-only access, and comprehensive session management.</p>
*
* <p><strong>Demo Mode Integration:</strong> Uses SpEL expressions with {@code @appProperties.demoReadonly}
* for conditional read-only access. See {@link SecuritySpelBridgeConfig} for SpEL bridge setup.</p>
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@EnableConfigurationProperties(AppProperties.class)
public class SecurityConfig {
/**
* OAuth2 authentication success handler for user provisioning.
* Creates local user records on first login and handles frontend redirect.
*/
@Autowired
private OAuth2LoginSuccessHandler successHandler;
/** Custom OIDC user service for Google OAuth2 integration. */
@Autowired
private com.smartsupplypro.inventory.service.CustomOidcUserService customOidcUserService;
/** Custom OAuth2 user service for non-OIDC providers. */
@Autowired
private com.smartsupplypro.inventory.service.CustomOAuth2UserService customOAuth2UserService;
/** Application configuration properties for demo mode and frontend URLs. */
@Autowired
private AppProperties props;
/**
* Primary security filter chain with OAuth2 and role-based authorization.
*
* <p>Configures dual entry points: API requests receive JSON 401, browser
* requests redirect to OAuth2 login. Implements CORS, session management,
* and demo mode access patterns.</p>
*
* <p><strong>Enterprise Pattern:</strong> Uses request attribute flagging to distinguish
* API calls from browser requests for appropriate authentication failure responses.</p>
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Flags JSON API requests so we can return 401 (not a redirect) on auth failures.
OncePerRequestFilter apiFlagFilter = new OncePerRequestFilter() {
@Override
protected void doFilterInternal(@NonNull HttpServletRequest req,
@NonNull HttpServletResponse res,
@NonNull FilterChain chain)
throws ServletException, IOException {
String accept = req.getHeader("Accept");
// Flag API requests that accept JSON, so we can handle them differently later
if (req.getRequestURI().startsWith("/api/")
&& accept != null && accept.contains("application/json")) {
req.setAttribute("IS_API_REQUEST", true);
}
chain.doFilter(req, res);
}
};
// Request matcher to identify API requests based on the custom attribute set by the filter
RequestMatcher apiMatcher = request -> Boolean.TRUE.equals(request.getAttribute("IS_API_REQUEST"));
// Entry point for API requests that should return JSON 401 instead of redirecting
AuthenticationEntryPoint apiEntry = (req, res, ex) -> {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/json");
res.getWriter().write("{\"message\":\"Unauthorized\"}");
};
// Entry point for web requests that should redirect to the login page
AuthenticationEntryPoint webEntry = (req, res, ex) -> {
String target = props.getFrontend().getBaseUrl() + "/login";
res.sendRedirect(target);
};
// Main security configuration
http
.addFilterBefore(apiFlagFilter, AbstractPreAuthenticatedProcessingFilter.class)
.cors(Customizer.withDefaults())
// ---------- AUTHZ RULES (block form) ----------
.authorizeHttpRequests(auth -> {
// 1) Allow CORS preflight requests to pass through the security filter
auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(); // allow preflight requests globally
auth.requestMatchers("/logout").permitAll();
// Public (always)
auth.requestMatchers(
"/", "/actuator/**", "/health/**",
"/oauth2/**", "/login/oauth2/**", "/login/**", "/error"
).permitAll();
// 2) Demo mode: allow read-only endpoints without login
if (props.isDemoReadonly()) { // using the getter (with parentheses)
auth.requestMatchers(HttpMethod.GET, "/api/inventory/**").permitAll();
auth.requestMatchers(HttpMethod.GET, "/api/analytics/**").permitAll();
auth.requestMatchers(HttpMethod.GET, "/api/suppliers/**").permitAll();
}
// 3) Default (non-demo): any signed-in user may READ these resources
auth.requestMatchers(HttpMethod.GET, "/api/inventory/**").authenticated();
auth.requestMatchers(HttpMethod.GET, "/api/suppliers/**").authenticated();
auth.requestMatchers(HttpMethod.GET, "/api/analytics/**").authenticated();
// 4) Admin-only area stays role-protected
auth.requestMatchers("/api/admin/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.POST, "/api/inventory/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.PUT, "/api/inventory/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.PATCH, "/api/inventory/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.DELETE, "/api/inventory/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.POST, "/api/suppliers/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.PUT, "/api/suppliers/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.PATCH, "/api/suppliers/**").hasRole("ADMIN");
auth.requestMatchers(HttpMethod.DELETE, "/api/suppliers/**").hasRole("ADMIN");
// 5) Everything else under /api/** must at least be authenticated
auth.requestMatchers("/api/**").authenticated();
// 6) Anything else authenticated as well (e.g., app shell)
auth.anyRequest().authenticated();
})
// ----------------------------------------------
.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(apiEntry, apiMatcher) // JSON APIs -> 401 JSON
.defaultAuthenticationEntryPointFor(webEntry, request -> true) // everything else -> redirect
)
.oauth2Login(oauth -> oauth
.authorizationEndpoint(ae -> ae
// Store the outbound authorization request in a cookie so the callback
// can be processed by any instance (no sticky sessions required).
.authorizationRequestRepository(authorizationRequestRepository())
)
.userInfoEndpoint(ui -> ui
// OIDC path (Google): use the OIDC service that returns an OidcUser
.oidcUserService(customOidcUserService)
// Non-OIDC OAuth2 providers (if you ever add one): keep your existing service
.userService(customOAuth2UserService)
)
.failureHandler(oauthFailureHandler(props))
.successHandler(successHandler)
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler((req, res, auth) -> {
boolean isApi = Boolean.TRUE.equals(req.getAttribute("IS_API_REQUEST"));
if (isApi) {
res.setStatus(HttpServletResponse.SC_NO_CONTENT); // 204 for XHR/API
return;
}
// Support FE form-post with ?return=<absolute-url>, but guard against open redirects
String base = props.getFrontend().getBaseUrl(); // e.g., https://your-fe.example
String ret = req.getParameter("return"); // e.g., https://your-fe.example/logout-success
String target = base + "/logout-success";
if (ret != null && ret.startsWith(base)) { // simple allowlist: must start with FE base
target = ret;
}
res.sendRedirect(target);
})
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "SESSION")
.permitAll() // explicit: anyone can hit /logout (it only clears if there is a session)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
// Enterprise Security: CSRF protection disabled for REST APIs to support SPA architecture
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/logout", "/actuator/**"));
return http.build();
}
/**
* CORS configuration for cross-origin frontend access with secure cookies.
*
* <p>Supports development (localhost) and production origins with credentialed
* requests. Uses SameSite=None and Secure flags for cross-site compatibility.</p>
*
* <p><strong>Enterprise Security:</strong> Explicit origin allowlist prevents CORS bypass attacks.
* Credentials enabled for authenticated session cookie transmission.</p>
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
// Dev (Vite default)
"http://localhost:5173",
"http://127.0.0.1:5173",
// Dev over HTTPS (only if actually use mkcert/HTTPS locally)
"https://localhost:5173",
// Production Frontend (in this case, Koyeb frontend domain)
"https://inventory-service.koyeb.app"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Set-Cookie"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
/**
* Session cookie configuration for cross-site authentication.
*
* <p>Configures SameSite=None and Secure flags for cross-origin cookie transmission.
* Essential for OAuth2 flows when frontend and backend are on different domains.</p>
*
* <p><strong>Security Note:</strong> Requires HTTPS in production for Secure flag compliance.</p>
*/
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setSameSite("None");
serializer.setUseSecureCookie(true);
serializer.setCookiePath("/");
return serializer;
}
/**
* OAuth2 authentication failure handler with frontend redirect.
*
* <p>Logs authentication failures and redirects to frontend login page with error parameter.
* Frontend is responsible for displaying user-friendly error messages.</p>
*/
@Bean
public AuthenticationFailureHandler oauthFailureHandler(AppProperties props) {
return (request, response, exception) -> {
// Enterprise Audit: Log authentication failures for security monitoring
LoggerFactory.getLogger(SecurityConfig.class).warn("OAuth2 failure: {}", exception.toString());
String target = props.getFrontend().getBaseUrl() + "/login?error=oauth";
if (!response.isCommitted()) response.sendRedirect(target);
};
};
/**
* Stateless OAuth2 authorization request repository using secure cookies.
*
* <p>Stores OAuth2 authorization requests in HttpOnly cookies instead of server sessions.
* Enables stateless authentication flows across multiple application instances.</p>
*
* <p><strong>Enterprise Benefit:</strong> Eliminates need for sticky sessions in load-balanced deployments.</p>
*/
@Bean
public AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
return new CookieOAuth2AuthorizationRequestRepository();
}
};