Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support usernames recovery when claims are not unique #859

Merged
merged 14 commits into from
Oct 2, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ public static class ConnectorConfig {
public static final String FORCE_ADD_PW_RECOVERY_QUESTION = "Recovery.Question.Password.Forced.Enable";
public static final String FORCE_MIN_NO_QUESTION_ANSWERED = "Recovery.Question.MinQuestionsToAnswer";
public static final String USERNAME_RECOVERY_ENABLE = "Recovery.Notification.Username.Enable";
public static final String USERNAME_RECOVERY_NON_UNIQUE_USERNAME = "Recovery.Notification.Username.NonUniqueUsername";
public static final String QUESTION_CHALLENGE_SEPARATOR = "Recovery.Question.Password.Separator";
public static final String QUESTION_MIN_NO_ANSWER = "Recovery.Question.Password.MinAnswers";
public static final String EXPIRY_TIME = "Recovery.ExpiryTime";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
* Copyright (c) 2020, WSO2 LLC. (https://www.wso2.org)
*
* WSO2 Inc. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
Expand All @@ -18,7 +18,6 @@
package org.wso2.carbon.identity.recovery.internal.service.impl;

import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
Expand Down Expand Up @@ -69,12 +68,12 @@

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static java.lang.Integer.MAX_VALUE;
import static org.wso2.carbon.identity.recovery.RecoveryScenarios.NOTIFICATION_BASED_PW_RECOVERY;
import static org.wso2.carbon.identity.recovery.RecoveryScenarios.QUESTION_BASED_PWD_RECOVERY;
import static org.wso2.carbon.identity.recovery.RecoveryScenarios.USERNAME_RECOVERY;
Expand Down Expand Up @@ -360,6 +359,83 @@
}
}

/**
* Get the userlist for the given claims.
*
* @param claims List of UserClaims
* @param tenantDomain Tenant domain
* @return resultedUserList (Returns an empty list if there are no users).
* @throws IdentityRecoveryException Error while retrieving the users list.
*/
public ArrayList<org.wso2.carbon.user.core.common.User> getUserListByClaims(Map<String, String> claims, String tenantDomain)
throws IdentityRecoveryException {

ArrayList<org.wso2.carbon.user.core.common.User> resultedUserList = new ArrayList<>();

if (MapUtils.isEmpty(claims)) {
// Get error code with scenario.
String errorCode = Utils.prependOperationScenarioToErrorCode(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_NO_FIELD_FOUND_FOR_USER_RECOVERY.getCode(),

Check warning on line 378 in components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java

View check run for this annotation

Codecov / codecov/patch

components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java#L377-L378

Added lines #L377 - L378 were not covered by tests
IdentityRecoveryConstants.USER_ACCOUNT_RECOVERY);
throw Utils.handleClientException(errorCode,
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_NO_FIELD_FOUND_FOR_USER_RECOVERY.getMessage(),

Check warning on line 381 in components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java

View check run for this annotation

Codecov / codecov/patch

components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java#L380-L381

Added lines #L380 - L381 were not covered by tests
null);
}

MultiAttributeLoginService multiAttributeLoginService = IdentityRecoveryServiceDataHolder.getInstance()
.getMultiAttributeLoginService();

if (multiAttributeLoginService.isEnabled(tenantDomain) && claims.containsKey(MultiAttributeLoginConstants
.MULTI_ATTRIBUTE_USER_IDENTIFIER_CLAIM_URI)) {
/* Multiple claims are not allowed when user identifier claim is enabled since identifier claim cannot be
used in combination with other claims. */
if (claims.keySet().size() > 1) {
String errorCode = Utils.prependOperationScenarioToErrorCode(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MULTIPLE_CLAIMS_WITH_MULTI_ATTRIBUTE_URI
.getCode(), IdentityRecoveryConstants.USER_ACCOUNT_RECOVERY);
throw Utils.handleClientException(errorCode,
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MULTIPLE_CLAIMS_WITH_MULTI_ATTRIBUTE_URI
.getMessage(), null);
}
// Resolve the user with the multi attribute login service.
ResolvedUserResult resolvedUserResult = multiAttributeLoginService.resolveUser(
claims.get(MultiAttributeLoginConstants.MULTI_ATTRIBUTE_USER_IDENTIFIER_CLAIM_URI), tenantDomain);
if (resolvedUserResult != null && ResolvedUserResult.UserResolvedStatus.SUCCESS
.equals(resolvedUserResult.getResolvedStatus())) {
resultedUserList.add(resolvedUserResult.getUser());
return resultedUserList;

Check warning on line 406 in components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java

View check run for this annotation

Codecov / codecov/patch

components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java#L405-L406

Added lines #L405 - L406 were not covered by tests
}
return resultedUserList;
}

int tenantId = IdentityTenantUtil.getTenantId(tenantDomain);
try {
AbstractUserStoreManager abstractUserStoreManager = (AbstractUserStoreManager)
getUserStoreManager(tenantId);
String userstoreDomain = extractDomainFromClaims(claims, abstractUserStoreManager);
if (userstoreDomain != null) {
populateUserListFromClaimsForDomain(tenantId, claims, userstoreDomain, resultedUserList,

Check warning on line 417 in components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java

View check run for this annotation

Codecov / codecov/patch

components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java#L417

Added line #L417 was not covered by tests
abstractUserStoreManager);
} else {
// If a userstore domain is not specified in the request, consider all userstores.
List<String> userStoreDomainNames = getDomainNames(tenantId);
for (String domain : userStoreDomainNames) {
populateUserListFromClaimsForDomain(tenantId, claims, domain, resultedUserList,
abstractUserStoreManager);
}
}
return resultedUserList;
} catch (org.wso2.carbon.user.core.UserStoreException e) {
if (log.isDebugEnabled()) {
log.debug("Error while retrieving users from user store for the given claim set: " +
Arrays.toString(claims.keySet().toArray()));
}
throw new IdentityRecoveryException(e.getErrorCode(), "Error occurred while retrieving users.", e);
} catch (UserStoreException | IdentityRecoveryServerException e) {
throw new IdentityRecoveryException(e.getMessage(), e);

Check warning on line 435 in components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java

View check run for this annotation

Codecov / codecov/patch

components/org.wso2.carbon.identity.recovery/src/main/java/org/wso2/carbon/identity/recovery/internal/service/impl/UserAccountRecoveryManager.java#L434-L435

Added lines #L434 - L435 were not covered by tests
}
}

/**
* Extract and remove the userstore domain from the claim set.
*
Expand Down Expand Up @@ -421,11 +497,15 @@

if (!expressionConditionList.isEmpty()) {
Condition operationalCondition = getOperationalCondition(expressionConditionList);
// Get the user list that matches the condition limit : 2, offset : 1, sortBy : null, sortOrder : null
boolean nonUniqueUsernameEnabled = Boolean.parseBoolean(IdentityUtil.getProperty(
IdentityRecoveryConstants.ConnectorConfig.USERNAME_RECOVERY_NON_UNIQUE_USERNAME));
int limit = nonUniqueUsernameEnabled ? MAX_VALUE : 2;
// Get the user list that matches the condition limit : MAX_VALUE or 2, offset : 1, sortBy : null, sortOrder : null
userList.addAll(abstractUserStoreManager.getUserListWithID(operationalCondition, userstoreDomain,
UserCoreConstants.DEFAULT_PROFILE, 2, 1, null, null));
UserCoreConstants.DEFAULT_PROFILE, limit, 1, null, null));

if (userList.size() > 1) {
//If multiple users are found for the given claim set and the config is not enabled, throw an exception.
if (userList.size() > 1 && !nonUniqueUsernameEnabled) {
log.warn("Multiple users matched for given claims set: " + claims.keySet());
throw Utils.handleClientException(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_MULTIPLE_MATCHING_USERS, null);
Expand Down Expand Up @@ -665,49 +745,6 @@
return localClaimMaskingRegex;
}

/**
* Get the users list for a matching claim.
*
* @param tenantId Tenant ID
* @param claimUri Claim to be searched
* @param claimValue Claim value to be matched
* @return Matched users list
* @throws IdentityRecoveryServerException If an error occurred while retrieving claims from the userstore manager.
*/
private String[] getUserList(int tenantId, String claimUri, String claimValue)
throws IdentityRecoveryServerException {

String[] userList = new String[0];
UserStoreManager userStoreManager = getUserStoreManager(tenantId);
try {
if (userStoreManager != null) {
if (StringUtils.isNotBlank(claimValue) && claimValue.contains(FORWARD_SLASH)) {
String extractedDomain = IdentityUtil.extractDomainFromName(claimValue);
UserStoreManager secondaryUserStoreManager = userStoreManager.
getSecondaryUserStoreManager(extractedDomain);
/*
Some claims (Eg:- Birth date) can have "/" in claim values. But in user store level we are trying
to extract the claim value and find the user store domain. Hence we are adding an extra "/" to
the claim value to avoid such issues.
*/
if (secondaryUserStoreManager == null) {
claimValue = FORWARD_SLASH + claimValue;
}
}
userList = userStoreManager.getUserList(claimUri, claimValue, null);
}
return userList;
} catch (UserStoreException e) {
if (log.isDebugEnabled()) {
String error = String
.format("Unable to retrieve the claim : %1$s for the given tenant : %2$s", claimUri, tenantId);
log.debug(error, e);
}
throw Utils.handleServerException(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_ERROR_RETRIEVING_USER_CLAIM, claimUri, e);
}
}

/**
* Get all the domain names related to user stores.
* @param tenantId Tenant ID
Expand Down Expand Up @@ -788,39 +825,6 @@
}
}

/**
* Keep the common users list from the previously matched list and the new list.
*
* @param resultedUserList Already matched users for previous claims
* @param matchedUserList Retrieved users list for the given claim
* @param claim Claim used for filtering
* @param value Value given for the claim
* @return Users list with no duplicates.
*/
private String[] getCommonUserEntries(String[] resultedUserList, String[] matchedUserList, String claim,
String value) {

ArrayList<String> matchedUsers = new ArrayList<>(Arrays.asList(matchedUserList));
ArrayList<String> resultedUsers = new ArrayList<>(Arrays.asList(resultedUserList));
// Remove not matching users.
resultedUsers.retainAll(matchedUsers);
if (resultedUsers.size() > 0) {
resultedUserList = resultedUsers.toArray(new String[0]);
if (log.isDebugEnabled()) {
log.debug("Current matching temporary user list :" + Arrays.toString(resultedUserList));
}
return resultedUserList;
} else {
if (log.isDebugEnabled()) {
String message = String
.format("There are no common users for claim : %1$s with the value : %2$s with the "
+ "previously filtered user list", claim, value);
log.debug(message);
}
return new String[0];
}
}

/**
* Get claim values of a user for a given list of claims.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
* Copyright (c) 2020, WSO2 LLC. (http://www.wso2.org)
*
* WSO2 Inc. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
Expand Down Expand Up @@ -53,6 +53,7 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -90,40 +91,42 @@ public RecoveryInformationDTO initiate(Map<String, String> claims, String tenant
boolean manageNotificationsInternally = Utils.isNotificationsInternallyManaged(tenantDomain, properties);
if (useLegacyAPIApproach) {
// Use legacy API approach to support legacy username recovery.
String username = userAccountRecoveryManager.getUsernameByClaims(claims, tenantDomain);
if (StringUtils.isNotEmpty(username)) {
if (manageNotificationsInternally) {
User user = createUser(username, tenantDomain);
triggerNotification(user, NotificationChannels.EMAIL_CHANNEL.getChannelType(),
IdentityEventConstants.Event.TRIGGER_NOTIFICATION, null);
ArrayList<org.wso2.carbon.user.core.common.User> resultedUserList = userAccountRecoveryManager
.getUserListByClaims(claims, tenantDomain);
for (org.wso2.carbon.user.core.common.User recoveredUser : resultedUserList) {
String username = recoveredUser.getDomainQualifiedUsername();
if (StringUtils.isNotEmpty(username)) {
if (manageNotificationsInternally) {
User user = createUser(username, tenantDomain);
triggerNotification(user, NotificationChannels.EMAIL_CHANNEL.getChannelType(),
IdentityEventConstants.Event.TRIGGER_NOTIFICATION, null);
if (log.isDebugEnabled()) {
log.debug("Successful username recovery for user: " + username + ". " +
"User notified Internally");
}
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, NOTIFICATION_TYPE_INTERNAL,
username, null, FrameworkConstants.AUDIT_SUCCESS);
}
if (log.isDebugEnabled()) {
log.debug("Successful username recovery for user: " + username + ". " +
"User notified Internally");
log.debug("Successful username recovery for user: " + username + ". User notified Externally");
}
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, NOTIFICATION_TYPE_INTERNAL,
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, NOTIFICATION_TYPE_EXTERNAL,
username, null, FrameworkConstants.AUDIT_SUCCESS);
return null;
}
if (log.isDebugEnabled()) {
log.debug("Successful username recovery for user: " + username + ". User notified Externally");
}
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, NOTIFICATION_TYPE_EXTERNAL,
username, null, FrameworkConstants.AUDIT_SUCCESS);
recoveryInformationDTO.setUsername(username);
} else {
String errorMsg =
String.format("No user found for the given claims in tenant domain : %s", tenantDomain);
if (log.isDebugEnabled()) {
log.debug(errorMsg);
}
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, "N/A", username, errorMsg,
FrameworkConstants.AUDIT_FAILED);
if (Boolean.parseBoolean(
IdentityUtil.getProperty(IdentityRecoveryConstants.ConnectorConfig.NOTIFY_USER_EXISTENCE))) {
throw Utils.handleClientException(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_NO_USER_FOUND, null);
recoveryInformationDTO.setUsername(username);
} else {
String errorMsg =
String.format("No user found for the given claims in tenant domain : %s", tenantDomain);
if (log.isDebugEnabled()) {
log.debug(errorMsg);
}
auditUserNameRecovery(AuditConstants.ACTION_USERNAME_RECOVERY, claims, "N/A", username, errorMsg,
FrameworkConstants.AUDIT_FAILED);
if (Boolean.parseBoolean(
IdentityUtil.getProperty(IdentityRecoveryConstants.ConnectorConfig.NOTIFY_USER_EXISTENCE))) {
throw Utils.handleClientException(
IdentityRecoveryConstants.ErrorMessages.ERROR_CODE_NO_USER_FOUND, null);
}
}
return null;
}
return recoveryInformationDTO;
}
Expand Down Expand Up @@ -187,18 +190,7 @@ public UsernameRecoverDTO notify(String recoveryCode, String channelId, String t
*/
private boolean useLegacyAPIApproach(Map<String, String> properties) {

if (MapUtils.isNotEmpty(properties)) {
try {
return Boolean.parseBoolean(properties.get(IdentityRecoveryConstants.USE_LEGACY_API_PROPERTY_KEY));
} catch (NumberFormatException e) {
if (log.isDebugEnabled()) {
String message = String.format("Invalid boolean value : %s to enable legacyAPIs", properties
.get(IdentityRecoveryConstants.USE_LEGACY_API_PROPERTY_KEY));
log.debug(message);
}
}
}
return false;
return Boolean.parseBoolean(properties.get(IdentityRecoveryConstants.USE_LEGACY_API_PROPERTY_KEY));
}

/**
Expand Down
Loading
Loading