Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -243,6 +270,10 @@ public AuthenticationThrottleProperties getAuthenticationThrottle() {
return authenticationThrottle;
}

public AuthenticationErrorProperties getAuthenticationError() {
return authenticationError;
}

public DigesterProperties getDigester() {
return digester;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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));
}
}
3 changes: 3 additions & 0 deletions core/starter/src/main/resources/core.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
}
33 changes: 33 additions & 0 deletions src/main/asciidoc/reference-guide/usage/core.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/anon
<<entitlements,entitlements>>, handed over to users via <<roles,roles>>.
****

[[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.

Expand Down