diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index da1999dd0..fd19b9dbe 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -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.saml2=TRACE -#logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG -#logging.level.stirling.software.SPDF.config.security: DEBUG +logging.level.stirling.software: DEBUG logging.level.com.zaxxer.hikari=WARN spring.jpa.open-in-view=false server.forward-headers-strategy=NATIVE @@ -55,4 +55,14 @@ posthog.host=https://eu.i.posthog.com spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location -java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} \ No newline at end of file +java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} + + +#spring.security.oauth2.resourceserver.jwt.issuer-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1 + +spring.security.oauth2.resourceserver.jwk-set-uri=https://nrlkjfznsavsbmweiyqu.supabase.co/auth/v1/.well-known/jwks.json +spring.security.oauth2.resourceserver.audience=authenticated +logging: + level: + org.springframework.security: DEBUG + your.project.pkg.security: DEBUG \ No newline at end of file diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 4a855ec6b..b812606b2 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -37,6 +37,8 @@ dependencies { api "org.springframework.security:spring-security-core:$springSecuritySamlVersion" api "org.springframework.security:spring-security-web:$springSecuritySamlVersion" api "org.springframework.security:spring-security-config:$springSecuritySamlVersion" + api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.4") + api "org.springframework.security:spring-security-oauth2-jose:$springSecuritySamlVersion" api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion" api 'org.springframework.boot:spring-boot-starter-jetty' api 'org.springframework.boot:spring-boot-starter-security' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 25b3c5096..be1df249e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -7,14 +7,3 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; -@Component -@RequiredArgsConstructor -public class RateLimitResetScheduler { - - private final IPRateLimitingFilter rateLimitingFilter; - - @Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable - public void resetRateLimit() { - rateLimitingFilter.resetRequestCounts(); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfig.java new file mode 100644 index 000000000..5278f41d3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfig.java @@ -0,0 +1,311 @@ +package stirling.software.proprietary.security.configuration; + + + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationListener; +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.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.event.AbstractAuthenticationEvent; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +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 java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Security configuration for Supabase-issued JWTs only. + * + * Requires: + * + * spring: + * security: + * oauth2: + * resourceserver: + * jwt: + * issuer-uri: https://.supabase.co/auth/v1 + * + * Optional logging (application.yml): + * logging: + * level: + * org.springframework.security: DEBUG + * your.project.pkg.security: DEBUG + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + + /** Your Supabase project ref, e.g. abcd1234efgh5678ijkl */ + @Value("${app.supabase.project-ref:nrlkjfznsavsbmweiyqu}") + private String projectRef; + + /** Optional audience to enforce (leave empty to skip) */ + @Value("${app.jwt.expected-aud:}") + private String expectedAud; + + /** Clock skew in seconds for exp validation */ + @Value("${app.jwt.clock-skew-seconds:120}") + private long clockSkewSeconds; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // allow CORS preflight only + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + // public endpoints + .requestMatchers( + new AntPathRequestMatcher("/actuator/health"), + new AntPathRequestMatcher("/public/**"), + new AntPathRequestMatcher("/images/**"), + new AntPathRequestMatcher("/css/**"), + new AntPathRequestMatcher("/js/**") + ).permitAll() + // everything else requires auth + .anyRequest().authenticated() + ) + .addFilterBefore(new VerboseAuthLoggingFilter(), BearerTokenAuthenticationFilter.class) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ) + .oauth2ResourceServer(oauth -> oauth + .jwt(jwt -> jwt + .decoder(jwtDecoder) + .jwtAuthenticationConverter(this::toAuthentication) + ) + ); + + return http.build(); + } + + /** CORS config so browser can send Authorization on real requests. */ + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(List.of( + "http://localhost:3000", // dev + "http://localhost:5173", + "http://localhost:8080", + "https://your-frontend.example"// prod + )); + cfg.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); + cfg.setAllowedHeaders(List.of("Authorization","Content-Type","X-Requested-With","Accept","Origin")); + cfg.setExposedHeaders(List.of("WWW-Authenticate")); + cfg.setAllowCredentials(true); + cfg.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } + + /** JWKS-based decoder + custom validator (issuer/exp/aud). */ + @Bean + JwtDecoder jwtDecoder() { + String issuer = "https://" + projectRef + ".supabase.co/auth/v1"; // no trailing slash + String jwks = issuer + "/.well-known/jwks.json"; + + log.info("Configuring JWT decoder with JWKS: {}", jwks); + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build(); + decoder.setJwtValidator(new CompositeValidator(issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds))); + + if (expectedAud == null || expectedAud.isBlank()) { + log.info("JWT validation: enforcing issuer='{}' and exp (skew {}s)", issuer, clockSkewSeconds); + } else { + log.info("JWT validation: enforcing issuer='{}', audience='{}', and exp (skew {}s)", + issuer, expectedAud, clockSkewSeconds); + } + return decoder; + } + + /** Map claims -> authorities. DEBUG: hotmail.com => ROLE_ADMIN. */ + private AbstractAuthenticationToken toAuthentication(Jwt jwt) { + List authorities = new ArrayList<>(); + + // Supabase default role -> ROLE_authenticated (optional but handy) + String supabaseRole = jwt.getClaimAsString("role"); // often "authenticated" + if (supabaseRole != null && !supabaseRole.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole)); + } + + // Your custom app_role (if you add it via Access Token Hook) -> ROLE_* + String appRole = jwt.getClaimAsString("app_role"); + if (appRole != null && !appRole.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase())); + } + + // DEBUG RULE: email domain → admin + String email = jwt.getClaimAsString("email"); + if (email != null && email.toLowerCase(Locale.ROOT).endsWith("@hotmail.com")) { + authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + } else { + // Give a basic user role for convenience while debugging (optional) + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + } + + // Optional permissions array -> PERM_* + List perms = jwt.getClaimAsStringList("permissions"); + if (perms != null) { + perms.stream() + .filter(p -> p != null && !p.isBlank()) + .map(p -> new SimpleGrantedAuthority("PERM_" + p)) + .forEach(authorities::add); + } + + String principalName = (email != null && !email.isBlank()) ? email : jwt.getSubject(); + + if (log.isDebugEnabled()) { + log.debug("JWT accepted: sub='{}', email='{}', supabase.role='{}', app_role='{}', permissions={}", + jwt.getSubject(), email, supabaseRole, appRole, perms); + log.debug("Granted authorities: {}", authorities.stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.toList())); + } + + return new JwtAuthenticationToken(jwt, authorities, principalName); + } + + /** Logs authentication lifecycle events (success/failure). */ + @Bean + ApplicationListener authenticationEventsLogger() { + return event -> { + try { + if (event.getSource() instanceof AbstractAuthenticationToken auth) { + String type = event.getClass().getSimpleName(); + String name = auth.getName(); + String authorities = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")); + log.debug("[AuthEvent] {} principal='{}' authorities='{}' details={}", + type, name, authorities, auth.getDetails()); + } else { + log.debug("[AuthEvent] {} source={}", event.getClass().getSimpleName(), event.getSource()); + } + } catch (Exception e) { + log.warn("Failed to log authentication event", e); + } + }; + } + + /** Super-chatty per-request logger around bearer processing; never logs raw token. */ + static class VerboseAuthLoggingFilter extends OncePerRequestFilter { + private static final Logger flog = LoggerFactory.getLogger(VerboseAuthLoggingFilter.class); + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + boolean hasBearer = authHeader != null && authHeader.startsWith("Bearer "); + if (flog.isDebugEnabled()) { + flog.debug("[REQ] {} {} AuthorizationHeaderPresent={} (token hidden)", + request.getMethod(), request.getRequestURI(), hasBearer); + } + + try { + filterChain.doFilter(request, response); + } catch (InvalidBearerTokenException ibte) { + flog.warn("[AUTH] Invalid bearer token: {}", ibte.getMessage()); + throw ibte; + } catch (Exception ex) { + flog.error("[AUTH] Unexpected auth error: {}", ex.toString(), ex); + throw ex; + } + + var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof JwtAuthenticationToken jwtAuth) { + String authorities = jwtAuth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.joining(",")); + flog.debug("[AUTH] OK principal='{}' authorities='{}'", jwtAuth.getName(), authorities); + } else { + flog.debug("[AUTH] No authentication established"); + } + } + } + + /** Validator: issuer == expected, not expired (with skew), optional audience. */ + static final class CompositeValidator implements OAuth2TokenValidator { + private final String expectedIssuer; // not null + private final String expectedAudienceOrNull; // may be null/blank + private final Duration skew; + + CompositeValidator(String expectedIssuer, String expectedAudienceOrNull, Duration skew) { + this.expectedIssuer = Objects.requireNonNull(expectedIssuer); + this.expectedAudienceOrNull = (expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank()) + ? expectedAudienceOrNull : null; + this.skew = Objects.requireNonNull(skew); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + List errors = new ArrayList<>(); + + String iss = token.getIssuer() != null ? token.getIssuer().toString() : null; + if (iss == null || !iss.equals(expectedIssuer)) { + errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null)); + } + + Instant exp = token.getExpiresAt(); + if (exp == null) { + errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null)); + } else if (exp.isBefore(Instant.now().minus(skew))) { + errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null)); + } + + if (expectedAudienceOrNull != null) { + List aud = token.getAudience(); + if (aud == null || !aud.contains(expectedAudienceOrNull)) { + errors.add(new OAuth2Error("invalid_token", "Missing/invalid audience: " + expectedAudienceOrNull, null)); + } + } + + if (!errors.isEmpty()) { + errors.forEach(e -> log.warn("JWT validation error: {} - {}", e.getErrorCode(), e.getDescription())); + return OAuth2TokenValidatorResult.failure(errors); + } + return OAuth2TokenValidatorResult.success(); + } + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java deleted file mode 100644 index ab809a037..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ /dev/null @@ -1,326 +0,0 @@ -package stirling.software.proprietary.security.configuration; - -import java.util.Optional; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; -import org.springframework.context.annotation.Lazy; -import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -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.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; -import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; -import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; -import org.springframework.security.web.savedrequest.NullRequestCache; -import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.configuration.AppConfig; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; -import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler; -import stirling.software.proprietary.security.CustomLogoutSuccessHandler; -import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl; -import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; -import stirling.software.proprietary.security.filter.FirstLoginFilter; -import stirling.software.proprietary.security.filter.IPRateLimitingFilter; -import stirling.software.proprietary.security.filter.UserAuthenticationFilter; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; -import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler; -import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler; -import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler; -import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter; -import stirling.software.proprietary.security.service.CustomOAuth2UserService; -import stirling.software.proprietary.security.service.CustomUserDetailsService; -import stirling.software.proprietary.security.service.LoginAttemptService; -import stirling.software.proprietary.security.service.UserService; -import stirling.software.proprietary.security.session.SessionPersistentRegistry; - -@Slf4j -@Configuration -@EnableWebSecurity -@EnableMethodSecurity -@DependsOn("runningProOrHigher") -public class SecurityConfiguration { - - private final CustomUserDetailsService userDetailsService; - private final UserService userService; - private final boolean loginEnabledValue; - private final boolean runningProOrHigher; - - private final ApplicationProperties applicationProperties; - private final AppConfig appConfig; - private final UserAuthenticationFilter userAuthenticationFilter; - private final LoginAttemptService loginAttemptService; - private final FirstLoginFilter firstLoginFilter; - private final SessionPersistentRegistry sessionRegistry; - private final PersistentLoginRepository persistentLoginRepository; - private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; - private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; - private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver; - - public SecurityConfiguration( - PersistentLoginRepository persistentLoginRepository, - CustomUserDetailsService userDetailsService, - @Lazy UserService userService, - @Qualifier("loginEnabled") boolean loginEnabledValue, - @Qualifier("runningProOrHigher") boolean runningProOrHigher, - AppConfig appConfig, - ApplicationProperties applicationProperties, - UserAuthenticationFilter userAuthenticationFilter, - LoginAttemptService loginAttemptService, - FirstLoginFilter firstLoginFilter, - SessionPersistentRegistry sessionRegistry, - @Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper, - @Autowired(required = false) - RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, - @Autowired(required = false) - OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) { - this.userDetailsService = userDetailsService; - this.userService = userService; - this.loginEnabledValue = loginEnabledValue; - this.runningProOrHigher = runningProOrHigher; - this.appConfig = appConfig; - this.applicationProperties = applicationProperties; - this.userAuthenticationFilter = userAuthenticationFilter; - this.loginAttemptService = loginAttemptService; - this.firstLoginFilter = firstLoginFilter; - this.sessionRegistry = sessionRegistry; - this.persistentLoginRepository = persistentLoginRepository; - this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; - this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations; - this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver; - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { - http.csrf(csrf -> csrf.disable()); - } - - if (loginEnabledValue) { - http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - if (!applicationProperties.getSecurity().getCsrfDisabled()) { - CookieCsrfTokenRepository cookieRepo = - CookieCsrfTokenRepository.withHttpOnlyFalse(); - CsrfTokenRequestAttributeHandler requestHandler = - new CsrfTokenRequestAttributeHandler(); - requestHandler.setCsrfRequestAttributeName(null); - http.csrf( - csrf -> - csrf.ignoringRequestMatchers( - request -> { - String apiKey = request.getHeader("X-API-KEY"); - // If there's no API key, don't ignore CSRF - // (return false) - if (apiKey == null || apiKey.trim().isEmpty()) { - return false; - } - // Validate API key using existing UserService - try { - Optional user = - userService.getUserByApiKey(apiKey); - // If API key is valid, ignore CSRF (return - // true) - // If API key is invalid, don't ignore CSRF - // (return false) - return user.isPresent(); - } catch (Exception e) { - // If there's any error validating the API - // key, don't ignore CSRF - return false; - } - }) - .csrfTokenRepository(cookieRepo) - .csrfTokenRequestHandler(requestHandler)); - } - http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); - http.sessionManagement( - sessionManagement -> - sessionManagement - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(10) - .maxSessionsPreventsLogin(false) - .sessionRegistry(sessionRegistry) - .expiredUrl("/login?logout=true")); - http.authenticationProvider(daoAuthenticationProvider()); - http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); - http.logout( - logout -> - logout.logoutRequestMatcher( - PathPatternRequestMatcher.withDefaults() - .matcher("/logout")) - .logoutSuccessHandler( - new CustomLogoutSuccessHandler( - applicationProperties, appConfig)) - .clearAuthentication(true) - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "remember-me")); - http.rememberMe( - rememberMeConfigurer -> // Use the configurator directly - rememberMeConfigurer - .tokenRepository(persistentTokenRepository()) - .tokenValiditySeconds( // 14 days - 14 * 24 * 60 * 60) - .userDetailsService( // Your existing UserDetailsService - userDetailsService) - .useSecureCookie( // Enable secure cookie - true) - .rememberMeParameter( // Form parameter name - "remember-me") - .rememberMeCookieName( // Cookie name - "remember-me") - .alwaysRemember(false)); - http.authorizeHttpRequests( - authz -> - authz.requestMatchers( - req -> { - String uri = req.getRequestURI(); - String contextPath = req.getContextPath(); - // Remove the context path from the URI - String trimmedUri = - uri.startsWith(contextPath) - ? uri.substring( - contextPath.length()) - : uri; - return trimmedUri.startsWith("/login") - || trimmedUri.startsWith("/oauth") - || trimmedUri.startsWith("/saml2") - || trimmedUri.endsWith(".svg") - || trimmedUri.startsWith("/register") - || trimmedUri.startsWith("/error") - || trimmedUri.startsWith("/images/") - || trimmedUri.startsWith("/public/") - || trimmedUri.startsWith("/css/") - || trimmedUri.startsWith("/fonts/") - || trimmedUri.startsWith("/js/") - || trimmedUri.startsWith( - "/api/v1/info/status"); - }) - .permitAll() - .anyRequest() - .authenticated()); - // Handle User/Password Logins - if (applicationProperties.getSecurity().isUserPass()) { - http.formLogin( - formLogin -> - formLogin - .loginPage("/login") - .successHandler( - new CustomAuthenticationSuccessHandler( - loginAttemptService, userService)) - .failureHandler( - new CustomAuthenticationFailureHandler( - loginAttemptService, userService)) - .defaultSuccessUrl("/") - .permitAll()); - } - // Handle OAUTH2 Logins - if (applicationProperties.getSecurity().isOauth2Active()) { - http.oauth2Login( - oauth2 -> - oauth2.loginPage("/oauth2") - /* - This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' - is set as true, else login fails with an error message advising the same. - */ - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - applicationProperties, - userService)) - .failureHandler( - new CustomOAuth2AuthenticationFailureHandler()) - . // Add existing Authorities from the database - userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - applicationProperties, - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll()); - } - // Handle SAML - if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) { - // Configure the authentication provider - OpenSaml4AuthenticationProvider authenticationProvider = - new OpenSaml4AuthenticationProvider(); - authenticationProvider.setResponseAuthenticationConverter( - new CustomSaml2ResponseAuthenticationConverter(userService)); - http.authenticationProvider(authenticationProvider) - .saml2Login( - saml2 -> { - try { - saml2.loginPage("/saml2") - .relyingPartyRegistrationRepository( - saml2RelyingPartyRegistrations) - .authenticationManager( - new ProviderManager(authenticationProvider)) - .successHandler( - new CustomSaml2AuthenticationSuccessHandler( - loginAttemptService, - applicationProperties, - userService)) - .failureHandler( - new CustomSaml2AuthenticationFailureHandler()) - .authenticationRequestResolver( - saml2AuthenticationRequestResolver); - } catch (Exception e) { - log.error("Error configuring SAML 2 login", e); - throw new RuntimeException(e); - } - }); - } - } else { - log.debug("Login is not enabled."); - http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); - } - return http.build(); - } - - public DaoAuthenticationProvider daoAuthenticationProvider() { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); - provider.setPasswordEncoder(passwordEncoder()); - return provider; - } - - @Bean - public IPRateLimitingFilter rateLimitingFilter() { - // Example limit TODO add config level - int maxRequestsPerIp = 1000000; - return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp); - } - - @Bean - public PersistentTokenRepository persistentTokenRepository() { - return new JPATokenRepositoryImpl(persistentLoginRepository); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index ebe856b00..81cb1f609 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -43,7 +43,7 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ @Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs") @RequestMapping("/api/v1/admin/settings") @RequiredArgsConstructor -@PreAuthorize("hasRole('ROLE_ADMIN')") +@PreAuthorize("hasRole('ADMIN')") @Slf4j public class AdminSettingsController { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 4401403c6..6288c3bf7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -105,9 +105,7 @@ public class UserController { if (user.getUsername().equals(newUsername)) { return new RedirectView("/account?messageType=usernameExists", true); } - if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); - } + if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) { return new RedirectView("/account?messageType=usernameExists", true); } @@ -141,9 +139,7 @@ public class UserController { return new RedirectView("/change-creds?messageType=userNotFound", true); } User user = userOpt.get(); - if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/change-creds?messageType=incorrectPassword", true); - } + userService.changePassword(user, newPassword); userService.changeFirstUse(user, false); // Logout using Spring's utility @@ -169,9 +165,7 @@ public class UserController { return new RedirectView("/account?messageType=userNotFound", true); } User user = userOpt.get(); - if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); - } + userService.changePassword(user, newPassword); // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 50c8027f6..0e1580ca9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -51,7 +51,6 @@ public class UserService implements UserServiceInterface { private final TeamRepository teamRepository; private final AuthorityRepository authorityRepository; - private final PasswordEncoder passwordEncoder; private final MessageSource messageSource; @@ -344,7 +343,7 @@ public class UserService implements UserServiceInterface { public void changePassword(User user, String newPassword) throws SQLException, UnsupportedProviderException { - user.setPassword(passwordEncoder.encode(newPassword)); + //user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); databaseService.exportDatabase(); } @@ -381,10 +380,7 @@ public class UserService implements UserServiceInterface { databaseService.exportDatabase(); } - public boolean isPasswordCorrect(User user, String currentPassword) { - return passwordEncoder.matches(currentPassword, user.getPassword()); - } - + /** * Resolves a team based on the provided information, with consistent error handling. * @@ -456,7 +452,7 @@ public class UserService implements UserServiceInterface { // Set password if provided if (password != null && !password.isEmpty()) { - user.setPassword(passwordEncoder.encode(password)); + // user.setPassword(passwordEncoder.encode(password)); } // Set authentication type