fix(cognito): honour PreventUserExistenceErrors=ENABLED on app clients
The setting was stored but never applied, so an unknown username always got UserNotFoundException and revealed which accounts exist. With ENABLED, answer as real Cognito does: password sign-in fails with NotAuthorizedException "Incorrect username or password", ForgotPassword and ResendConfirmationCode report a simulated delivery, and ConfirmForgotPassword and ConfirmSignUp fail with CodeMismatchException. LEGACY and unset keep UserNotFoundException. SRP and USER_AUTH flows are unchanged.
This commit is contained in:
+4
-1
@@ -291,7 +291,10 @@ final class CognitoAuthFlowHandler {
|
|||||||
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
if (!"UserNotFoundException".equals(ae.getErrorCode())) throw ae;
|
||||||
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
|
user = tryUserMigration(pool, client, username, password, null, clientMetadata,
|
||||||
"UserMigration_Authentication");
|
"UserMigration_Authentication");
|
||||||
if (user == null) throw ae;
|
if (user == null) {
|
||||||
|
if (!CognitoService.hidesUserExistence(client)) throw ae;
|
||||||
|
throw new AwsException("NotAuthorizedException", "Incorrect username or password", 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
firePreAuthentication(pool, client, user, null, clientMetadata, false);
|
firePreAuthentication(pool, client, user, null, clientMetadata, false);
|
||||||
|
|||||||
@@ -2592,7 +2592,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
String confirmationCode) {
|
String confirmationCode) {
|
||||||
UserPool pool = poolStore.get(userPoolId)
|
UserPool pool = poolStore.get(userPoolId)
|
||||||
.orElseThrow(() -> userPoolNotFound(userPoolId));
|
.orElseThrow(() -> userPoolNotFound(userPoolId));
|
||||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
CognitoUser user = userForClientOrNull(client, username);
|
||||||
|
if (user == null) throw codeMismatch();
|
||||||
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
|
if (verificationCodeService != null && isSignUpConfirmationEnabled(pool)) {
|
||||||
try {
|
try {
|
||||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||||
@@ -2636,7 +2637,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
|
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found",
|
||||||
400));
|
400));
|
||||||
UserPool pool = describeUserPool(client.getUserPoolId());
|
UserPool pool = describeUserPool(client.getUserPoolId());
|
||||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
CognitoUser user = userForClientOrNull(client, username);
|
||||||
|
if (user == null) return simulatedCodeDelivery(username);
|
||||||
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
|
if (!"UNCONFIRMED".equals(user.getUserStatus())) {
|
||||||
throw new AwsException("NotAuthorizedException",
|
throw new AwsException("NotAuthorizedException",
|
||||||
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
|
"User cannot be confirmed. Current status is " + user.getUserStatus(), 400);
|
||||||
@@ -2774,7 +2776,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
public Map<String, Object> forgotPassword(String clientId, String username) {
|
public Map<String, Object> forgotPassword(String clientId, String username) {
|
||||||
UserPoolClient client = clientStore.get(clientId)
|
UserPoolClient client = clientStore.get(clientId)
|
||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
||||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
CognitoUser user = userForClientOrNull(client, username);
|
||||||
|
if (user == null) return new LinkedHashMap<>(simulatedCodeDelivery(username));
|
||||||
UserPool pool = describeUserPool(client.getUserPoolId());
|
UserPool pool = describeUserPool(client.getUserPoolId());
|
||||||
ensureVerificationWiring();
|
ensureVerificationWiring();
|
||||||
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
|
DeliveryTarget deliveryTarget = resolveForgotPasswordDeliveryTarget(pool, user);
|
||||||
@@ -2800,7 +2803,8 @@ public class CognitoService implements ResourceProvider {
|
|||||||
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
|
public void confirmForgotPassword(String clientId, String username, String confirmationCode, String newPassword) {
|
||||||
UserPoolClient client = clientStore.get(clientId)
|
UserPoolClient client = clientStore.get(clientId)
|
||||||
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
.orElseThrow(() -> new AwsException("ResourceNotFoundException", "Client not found", 400));
|
||||||
CognitoUser user = adminGetUser(client.getUserPoolId(), username);
|
CognitoUser user = userForClientOrNull(client, username);
|
||||||
|
if (user == null) throw codeMismatch();
|
||||||
ensureVerificationWiring();
|
ensureVerificationWiring();
|
||||||
try {
|
try {
|
||||||
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
verificationCodeService.consume(client.getUserPoolId(), user.getUsername(),
|
||||||
@@ -4446,10 +4450,38 @@ public class CognitoService implements ResourceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app client hides which usernames exist (PreventUserExistenceErrors=ENABLED). Real Cognito then
|
||||||
|
* answers for an unknown user exactly as for a real one: a failed sign-in is "Incorrect username or password",
|
||||||
|
* a code request reports a simulated delivery and a code check reports a wrong code. Unset means LEGACY.
|
||||||
|
*/
|
||||||
|
static boolean hidesUserExistence(UserPoolClient client) {
|
||||||
|
return "ENABLED".equals(client.getPreventUserExistenceErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user, or null when it does not exist and the client hides that; otherwise UserNotFoundException. */
|
||||||
|
private CognitoUser userForClientOrNull(UserPoolClient client, String username) {
|
||||||
|
try {
|
||||||
|
return adminGetUser(client.getUserPoolId(), username);
|
||||||
|
} catch (AwsException e) {
|
||||||
|
if ("UserNotFoundException".equals(e.getErrorCode()) && hidesUserExistence(client)) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> simulatedCodeDelivery(String username) {
|
||||||
|
return username != null && username.contains("@")
|
||||||
|
? Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", maskEmail(username))
|
||||||
|
: Map.of("AttributeName", "phone_number", "DeliveryMedium", "SMS", "Destination", "+*******0000");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AwsException codeMismatch() {
|
||||||
|
return new AwsException("CodeMismatchException", "Invalid verification code provided, please try again.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
private AwsException mapVerificationCodeException(VerificationCodeException e) {
|
private AwsException mapVerificationCodeException(VerificationCodeException e) {
|
||||||
return switch (e.getKind()) {
|
return switch (e.getKind()) {
|
||||||
case MISMATCH, NOT_FOUND -> new AwsException("CodeMismatchException",
|
case MISMATCH, NOT_FOUND -> codeMismatch();
|
||||||
"Invalid verification code provided, please try again.", 400);
|
|
||||||
case EXPIRED -> new AwsException("ExpiredCodeException",
|
case EXPIRED -> new AwsException("ExpiredCodeException",
|
||||||
"Invalid code provided, please request a code again.", 400);
|
"Invalid code provided, please request a code again.", 400);
|
||||||
case RATE_LIMIT -> new AwsException("LimitExceededException",
|
case RATE_LIMIT -> new AwsException("LimitExceededException",
|
||||||
|
|||||||
@@ -373,6 +373,63 @@ class CognitoServiceTest {
|
|||||||
null, null, List.of(), null, null);
|
null, null, List.of(), null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class PreventUserExistenceErrors {
|
||||||
|
|
||||||
|
private UserPoolClient client(UserPool pool, String preventUserExistenceErrors) {
|
||||||
|
return service.createUserPoolClient(pool.getId(), "existence-client", false, false, List.of(), List.of(),
|
||||||
|
null, List.of(), null, List.of("ALLOW_USER_PASSWORD_AUTH"), null, null, List.of(),
|
||||||
|
preventUserExistenceErrors, List.of(), null, null, null, List.of(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledAnswersAnUnknownUserLikeAWrongPassword() {
|
||||||
|
UserPool pool = createPoolAndUser();
|
||||||
|
UserPoolClient client = client(pool, "ENABLED");
|
||||||
|
|
||||||
|
AwsException unknown = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
|
||||||
|
"USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "Perm1234!")));
|
||||||
|
AwsException wrongPassword = assertThrows(AwsException.class, () -> service.initiateAuth(client.getClientId(),
|
||||||
|
"USER_PASSWORD_AUTH", Map.of("USERNAME", "alice", "PASSWORD", "Wrong1234!")));
|
||||||
|
|
||||||
|
assertEquals("NotAuthorizedException", unknown.getErrorCode());
|
||||||
|
assertEquals(wrongPassword.getErrorCode(), unknown.getErrorCode());
|
||||||
|
assertEquals(wrongPassword.getMessage(), unknown.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enabledSimulatesCodeDeliveryAndRejectsCodesForAnUnknownUser() {
|
||||||
|
UserPool pool = createPoolAndUser();
|
||||||
|
UserPoolClient client = client(pool, "ENABLED");
|
||||||
|
|
||||||
|
assertEquals(Map.of("AttributeName", "email", "DeliveryMedium", "EMAIL", "Destination", "n***@e***"),
|
||||||
|
service.forgotPassword(client.getClientId(), "nobody@example.com"));
|
||||||
|
assertEquals("EMAIL", service.resendConfirmationCode(client.getClientId(), "nobody@example.com")
|
||||||
|
.get("DeliveryMedium"));
|
||||||
|
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
|
||||||
|
service.confirmForgotPassword(client.getClientId(), "nobody@example.com", "123456", "Perm1234!"))
|
||||||
|
.getErrorCode());
|
||||||
|
assertEquals("CodeMismatchException", assertThrows(AwsException.class, () ->
|
||||||
|
service.confirmSignUp(client.getClientId(), "nobody@example.com", "123456")).getErrorCode());
|
||||||
|
assertThrows(AwsException.class, () -> service.adminGetUser(pool.getId(), "nobody@example.com"),
|
||||||
|
"no user is created along the way");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyAndUnsetStillReportUserNotFound() {
|
||||||
|
UserPool pool = createPoolAndUser();
|
||||||
|
for (String setting : new String[] {"LEGACY", null}) {
|
||||||
|
UserPoolClient client = client(pool, setting);
|
||||||
|
|
||||||
|
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () -> service.initiateAuth(
|
||||||
|
client.getClientId(), "USER_PASSWORD_AUTH", Map.of("USERNAME", "nobody", "PASSWORD", "x")))
|
||||||
|
.getErrorCode());
|
||||||
|
assertEquals("UserNotFoundException", assertThrows(AwsException.class, () ->
|
||||||
|
service.forgotPassword(client.getClientId(), "nobody")).getErrorCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@CsvSource({
|
@CsvSource({
|
||||||
"Short1!a",
|
"Short1!a",
|
||||||
|
|||||||
Reference in New Issue
Block a user