diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java index fce444315f1..296d870ebc0 100644 --- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java @@ -68,6 +68,31 @@ public void setLockSeconds(final long lockSeconds) { } } + public static class AuthenticationErrorProperties { + + public static final String DEFAULT_GENERIC_MESSAGE = "Authentication failed"; + + private boolean exposeDetails = false; + + private String genericMessage = DEFAULT_GENERIC_MESSAGE; + + public boolean isExposeDetails() { + return exposeDetails; + } + + public void setExposeDetails(final boolean exposeDetails) { + this.exposeDetails = exposeDetails; + } + + public String getGenericMessage() { + return genericMessage; + } + + public void setGenericMessage(final String genericMessage) { + this.genericMessage = genericMessage; + } + } + public static class DigesterProperties { private int saltIterations = 1; @@ -149,6 +174,8 @@ public void setUseLenientSaltSizeCheck(final boolean useLenientSaltSizeCheck) { private final AuthenticationThrottleProperties authenticationThrottle = new AuthenticationThrottleProperties(); + private final AuthenticationErrorProperties authenticationError = new AuthenticationErrorProperties(); + private final DigesterProperties digester = new DigesterProperties(); public String getAdminUser() { @@ -243,6 +270,10 @@ public AuthenticationThrottleProperties getAuthenticationThrottle() { return authenticationThrottle; } + public AuthenticationErrorProperties getAuthenticationError() { + return authenticationError; + } + public DigesterProperties getDigester() { return digester; } diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java index 24efdce799a..9693780dbd3 100644 --- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import org.apache.commons.lang3.StringUtils; import org.apache.syncope.common.rest.api.RESTHeaders; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -32,11 +33,23 @@ */ public class SyncopeBasicAuthenticationEntryPoint extends BasicAuthenticationEntryPoint { + private final SecurityProperties securityProperties; + + public SyncopeBasicAuthenticationEntryPoint(final SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + @Override public void commence(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException authException) throws IOException { - response.addHeader(RESTHeaders.ERROR_INFO, authException.getMessage()); + response.addHeader( + RESTHeaders.ERROR_INFO, + securityProperties.getAuthenticationError().isExposeDetails() + ? authException.getMessage() + : StringUtils.defaultIfBlank( + securityProperties.getAuthenticationError().getGenericMessage(), + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE)); if (authException instanceof RateLimitAuthenticationException rateLimit) { response.addHeader(HttpHeaders.RETRY_AFTER, Long.toString(rateLimit.getRetryAfterSeconds())); response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), authException.getMessage()); diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java index bb948d09611..c99fd6cc83e 100644 --- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java @@ -84,7 +84,8 @@ public SecurityFilterChain filterChain( final UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, final AccessDeniedHandler accessDeniedHandler, final AuthDataAccessor dataAccessor, - final DefaultCredentialChecker defaultCredentialChecker) throws Exception { + final DefaultCredentialChecker defaultCredentialChecker, + final SecurityProperties securityProperties) throws Exception { AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManagerBuilder.class). parentAuthenticationManager(null). @@ -96,7 +97,7 @@ public SecurityFilterChain filterChain( new SyncopeAuthenticationDetailsSource(); SyncopeBasicAuthenticationEntryPoint basicAuthenticationEntryPoint = - new SyncopeBasicAuthenticationEntryPoint(); + new SyncopeBasicAuthenticationEntryPoint(securityProperties); basicAuthenticationEntryPoint.setRealmName("Apache Syncope authentication"); http.httpBasic(customizer -> customizer. authenticationEntryPoint(basicAuthenticationEntryPoint). diff --git a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java index 7f34017eb85..191b6b00289 100644 --- a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java +++ b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java @@ -21,17 +21,32 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.syncope.common.rest.api.RESTHeaders; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; class SyncopeBasicAuthenticationEntryPointTest { + private SyncopeBasicAuthenticationEntryPoint entryPoint; + + private SecurityProperties securityProperties; + + @BeforeEach + public void setUp() throws Exception { + securityProperties = new SecurityProperties(); + entryPoint = new SyncopeBasicAuthenticationEntryPoint(securityProperties); + entryPoint.setRealmName("Apache Syncope authentication"); + entryPoint.afterPropertiesSet(); + } + @Test void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws Exception { - SyncopeBasicAuthenticationEntryPoint entryPoint = new SyncopeBasicAuthenticationEntryPoint(); MockHttpServletResponse response = new MockHttpServletResponse(); entryPoint.commence( @@ -41,6 +56,77 @@ void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws Exception { assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), response.getStatus()); assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER)); - assertEquals("Too many authentication failures", response.getHeader(RESTHeaders.ERROR_INFO)); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + response.getHeader(RESTHeaders.ERROR_INFO)); + } + + @Test + public void badCredentialsExposeGenericErrorInfo() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest(), + response, + new BadCredentialsException("rossini: invalid password provided")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + response.getHeader(RESTHeaders.ERROR_INFO)); + } + + @Test + public void missingUserExposeGenericErrorInfo() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest(), + response, + new UsernameNotFoundException("not-a-user")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + response.getHeader(RESTHeaders.ERROR_INFO)); + } + + @Test + public void disabledUserExposeGenericErrorInfo() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest(), + response, + new DisabledException("User rossini is suspended")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + response.getHeader(RESTHeaders.ERROR_INFO)); + } + + @Test + public void genericErrorInfoCanBeConfigured() throws Exception { + securityProperties.getAuthenticationError().setGenericMessage("Login failed"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest(), + response, + new BadCredentialsException("rossini: invalid password provided")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals("Login failed", response.getHeader(RESTHeaders.ERROR_INFO)); + } + + @Test + public void detailsCanBeExposedWhenConfigured() throws Exception { + securityProperties.getAuthenticationError().setExposeDetails(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence( + new MockHttpServletRequest(), + response, + new BadCredentialsException("rossini: invalid password provided")); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals("rossini: invalid password provided", response.getHeader(RESTHeaders.ERROR_INFO)); } } diff --git a/core/starter/src/main/resources/core.properties b/core/starter/src/main/resources/core.properties index fb4c232b998..5a522b6fda1 100644 --- a/core/starter/src/main/resources/core.properties +++ b/core/starter/src/main/resources/core.properties @@ -107,6 +107,9 @@ security.authenticationThrottle.maxAttempts=5 security.authenticationThrottle.windowSeconds=60 security.authenticationThrottle.lockSeconds=60 +security.authenticationError.exposeDetails=false +security.authenticationError.genericMessage=Authentication failed + # default for LDAP / RFC2307 SSHA security.digester.saltIterations=1 security.digester.saltSizeBytes=8 diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java index 009db1e5b81..529b4762630 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java @@ -71,6 +71,7 @@ import org.apache.syncope.common.rest.api.service.AnyObjectService; import org.apache.syncope.common.rest.api.service.SchemaService; import org.apache.syncope.common.rest.api.service.UserService; +import org.apache.syncope.core.spring.security.SecurityProperties; import org.apache.syncope.fit.AbstractITCase; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -324,6 +325,27 @@ public void checkFailedLogins() { assertEquals(0, userService4.read(userKey).getFailedLogins()); } + @Test + public void authenticationFailureErrorInfoIsGeneric() { + NotAuthorizedException e = assertThrows( + NotAuthorizedException.class, + () -> CLIENT_FACTORY.create(getUUIDString(), "anypassword").self()); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO)); + + UserCR userCR = UserITCase.getUniqueSample("genericAuthError@syncope.apache.org"); + userCR.getRoles().add("User manager"); + + UserTO userTO = createUser(userCR).getEntity(); + assertNotNull(userTO); + + e = assertThrows( + NotAuthorizedException.class, + () -> CLIENT_FACTORY.create(userTO.getUsername(), "wrongpwd")); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO)); + } + @Test public void checkUserSuspension() { UserCR userCR = UserITCase.getUniqueSample("checkSuspension@syncope.apache.org"); diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java index c1f626b9b90..b77027f6369 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/JWTITCase.java @@ -54,6 +54,7 @@ import org.apache.syncope.common.rest.api.RESTHeaders; import org.apache.syncope.common.rest.api.service.AccessTokenService; import org.apache.syncope.common.rest.api.service.UserSelfService; +import org.apache.syncope.core.spring.security.SecurityProperties; import org.apache.syncope.core.spring.security.jws.AccessTokenJWSSigner; import org.apache.syncope.core.spring.security.jws.AccessTokenJWSVerifier; import org.apache.syncope.fit.AbstractITCase; @@ -142,7 +143,7 @@ public void queryUsingToken() throws ParseException { jwtUserSelfService.read(); fail("Failure expected on a modified token"); } catch (NotAuthorizedException e) { - assertEquals("Invalid signature found in JWT", e.getMessage()); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, e.getMessage()); } } diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java index be474f0893f..40ce6f19960 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/MultitenancyITCase.java @@ -21,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -66,6 +65,7 @@ import org.apache.syncope.common.rest.api.service.TaskService; import org.apache.syncope.common.rest.api.service.UserSelfService; import org.apache.syncope.common.rest.api.service.UserService; +import org.apache.syncope.core.spring.security.SecurityProperties; import org.apache.syncope.fit.AbstractITCase; import org.identityconnectors.framework.common.objects.ObjectClass; import org.junit.jupiter.api.BeforeEach; @@ -238,7 +238,7 @@ public void issueSYNCOPE1377() { create(UserITCase.getUniqueSample("syncope1377@syncope.apache.org")); fail("This should not happen"); } catch (NotAuthorizedException e) { - assertTrue(e.getMessage().contains("Could not find domain NotExisting")); + assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, e.getMessage()); } } } diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index 11f98d3a352..6533ae2292c 100644 --- a/src/main/asciidoc/reference-guide/usage/core.adoc +++ b/src/main/asciidoc/reference-guide/usage/core.adoc @@ -107,6 +107,39 @@ https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/anon <>, handed over to users via <>. **** +[[authentication-error-details]] +===== Authentication Error Details +Since 4.1.2 version, by default, authentication failures return a generic error message in the `X-Syncope-Error-Info` response header. + +This avoids exposing details that could help distinguish between different authentication failure causes, such as an unknown user, invalid credentials, disabled account, invalid domain or invalid token. + +The behavior can be configured with the following properties: +[cols="1,1,3",options="header"] +|=== +|Property +|Default +|Description +|`security.authenticationError.exposeDetails` +|`false` +|When `false`, authentication failures expose the configured generic message. When `true`, the original authentication exception message is exposed. Setting this to `true` restores the previous, more detailed behavior. +|`security.authenticationError.genericMessage` +|`Authentication failed` +|Message returned for authentication failures when `security.authenticationError.exposeDetails` is `false`. If left empty, Syncope + falls back to `Authentication failed`. +|=== +Example: +[source,properties] +---- +security.authenticationError.exposeDetails=false +security.authenticationError.genericMessage=Authentication failed +---- + +For compatibility with previous behavior, administrators can enable detailed messages: +[source,properties] +---- +security.authenticationError.exposeDetails=true +---- + ===== Authentication Throttling This functionality can throttle repeated failed username / password authentication attempts, to reduce the effectiveness of automated guessing attacks.