Skip to content

Commit

Permalink
Add account lock capability to SMS OTP Service
Browse files Browse the repository at this point in the history
  • Loading branch information
dewniMW committed Jun 3, 2024
1 parent 29382a7 commit 1134c29
Show file tree
Hide file tree
Showing 8 changed files with 378 additions and 7 deletions.
9 changes: 8 additions & 1 deletion component/common/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,16 @@
org.wso2.carbon.context; version="${carbon.kernel.package.import.version.range}",
org.wso2.carbon.identity.application.authentication.framework.store;
version="${carbon.identity.framework.imp.pkg.version.range}",
org.wso2.carbon.identity.application.common.model; version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.core.util; version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.event; version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.event.event;
version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.event.bean;
version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.event.services;
version="${carbon.identity.package.import.version.range}",
org.wso2.carbon.identity.governance; version="${identity.governance.imp.pkg.version.range}",
org.wso2.carbon.identity.governance.service.notification;
version="${identity.governance.imp.pkg.version.range}",
org.wso2.carbon.identity.recovery.internal;
Expand All @@ -118,7 +121,11 @@
org.wso2.carbon.user.core.common; version="${carbon.kernel.package.import.version.range}",
org.wso2.carbon.user.core.constants;
version="${carbon.kernel.package.import.version.range}",
org.wso2.carbon.user.core.service; version="${carbon.kernel.package.import.version.range}"
org.wso2.carbon.user.core.service; version="${carbon.kernel.package.import.version.range}",
org.wso2.carbon.identity.handler.event.account.lock.exception;
version="${carbon.identity.account.lock.handler.imp.pkg.version.range}",
org.wso2.carbon.identity.handler.event.account.lock.service;
version="${carbon.identity.account.lock.handler.imp.pkg.version.range}"
</Import-Package>
<Export-Package>
!org.wso2.carbon.identity.smsotp.common.internal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang.math.NumberUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.slf4j.MDC;
import org.wso2.carbon.context.PrivilegedCarbonContext;
import org.wso2.carbon.identity.application.authentication.framework.store.SessionDataStore;
import org.wso2.carbon.identity.application.common.model.Property;
import org.wso2.carbon.identity.core.util.IdentityUtil;
import org.wso2.carbon.identity.event.IdentityEventConstants;
import org.wso2.carbon.identity.event.IdentityEventException;
import org.wso2.carbon.identity.event.event.Event;
Expand Down Expand Up @@ -172,18 +175,24 @@ public ValidationResponseDTO validateSMSOTP(String transactionId, String userId,
}
// Valid OTP. Clear OTP session data.
SessionDataStore.getInstance().clearSessionData(sessionId, Constants.SESSION_TYPE_OTP);
resetOtpFailedAttempts(userId);
return new ValidationResponseDTO(userId, true);
}

private ValidationResponseDTO isValid(SessionDTO sessionDTO, String smsOTP, String userId,
String transactionId, int validateAttempt, boolean showFailureReason) {
String transactionId, int validateAttempt, boolean showFailureReason)
throws SMSOTPException {

FailureReasonDTO error;
// Check if the provided OTP is correct.
if (!StringUtils.equals(smsOTP, sessionDTO.getOtp())) {
if (log.isDebugEnabled()) {
log.debug(String.format("Invalid OTP provided for the user : %s.", userId));
}
ValidationResponseDTO responseDTO = handleAccountLock(userId, showFailureReason);
if (responseDTO != null) {
return responseDTO;
}
int remainingFailedAttempts =
SMSOTPServiceDataHolder.getConfigs().getMaxValidationAttemptsAllowed() - validateAttempt;
error = showFailureReason
Expand Down Expand Up @@ -388,4 +397,196 @@ public static String getCorrelationId() {
return StringUtils.isBlank(MDC.get(Constants.CORRELATION_ID_MDC))
? UUID.randomUUID().toString() : MDC.get(Constants.CORRELATION_ID_MDC);
}

/**
* Execute account lock flow for OTP verification failures.
*
*/
private ValidationResponseDTO handleAccountLock(String userId, boolean showFailureReason)
throws SMSOTPException {

boolean lockAccountOnFailedAttempts = SMSOTPServiceDataHolder.getConfigs().isLockAccountOnFailedAttempts();
if (!lockAccountOnFailedAttempts) {
return null;
}

User user = getUserById(userId);
if (Utils.isAccountLocked(user)) {
FailureReasonDTO error = showFailureReason
? new FailureReasonDTO(Constants.ErrorMessage.CLIENT_ACCOUNT_LOCKED, userId)
: null;
return new ValidationResponseDTO(userId, false, error);
}

int maxAttempts = 0;
long unlockTimePropertyValue = 0;
double unlockTimeRatio = 1;

Property[] connectorConfigs = Utils.getAccountLockConnectorConfigs(user.getTenantDomain());
for (Property connectorConfig : connectorConfigs) {
switch (connectorConfig.getName()) {
case Constants.PROPERTY_ACCOUNT_LOCK_ON_FAILURE:
if (!Boolean.parseBoolean(connectorConfig.getValue())) {
return null;
}
case Constants.PROPERTY_ACCOUNT_LOCK_ON_FAILURE_MAX:
if (NumberUtils.isNumber(connectorConfig.getValue())) {
maxAttempts = Integer.parseInt(connectorConfig.getValue());
}
break;
case Constants.PROPERTY_ACCOUNT_LOCK_TIME:
if (NumberUtils.isNumber(connectorConfig.getValue())) {
unlockTimePropertyValue = Integer.parseInt(connectorConfig.getValue());
}
break;
case Constants.PROPERTY_LOGIN_FAIL_TIMEOUT_RATIO:
if (NumberUtils.isNumber(connectorConfig.getValue())) {
double value = Double.parseDouble(connectorConfig.getValue());
if (value > 0) {
unlockTimeRatio = value;
}
}
break;
}
}
Map<String, String> claimValues = getUserClaimValues(user, new String[]{
Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM, Constants.FAILED_LOGIN_LOCKOUT_COUNT_CLAIM});
if (claimValues == null) {
claimValues = new HashMap<>();
}
int currentAttempts = 0;
if (NumberUtils.isNumber(claimValues.get(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM))) {
currentAttempts = Integer.parseInt(claimValues.get(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM));
}
int failedLoginLockoutCountValue = 0;
if (NumberUtils.isNumber(claimValues.get(Constants.FAILED_LOGIN_LOCKOUT_COUNT_CLAIM))) {
failedLoginLockoutCountValue =
Integer.parseInt(claimValues.get(Constants.FAILED_LOGIN_LOCKOUT_COUNT_CLAIM));
}

Map<String, String> updatedClaims = new HashMap<>();
if ((currentAttempts + 1) >= maxAttempts) {
// Calculate the incremental unlock time interval in milli seconds.
unlockTimePropertyValue = (long) (unlockTimePropertyValue * 1000 * 60 * Math.pow(unlockTimeRatio,
failedLoginLockoutCountValue));
// Calculate unlock time by adding current time and unlock time interval in milli seconds.
long unlockTime = System.currentTimeMillis() + unlockTimePropertyValue;
updatedClaims.put(Constants.ACCOUNT_LOCKED_CLAIM, Boolean.TRUE.toString());
updatedClaims.put(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM, "0");
updatedClaims.put(Constants.ACCOUNT_UNLOCK_TIME_CLAIM, String.valueOf(unlockTime));
updatedClaims.put(Constants.FAILED_LOGIN_LOCKOUT_COUNT_CLAIM,
String.valueOf(failedLoginLockoutCountValue + 1));
updatedClaims.put(Constants.ACCOUNT_LOCKED_REASON_CLAIM_URI,
Constants.MAX_SMS_OTP_ATTEMPTS_EXCEEDED);
IdentityUtil.threadLocalProperties.get().put(Constants.ADMIN_INITIATED, false);
setUserClaimValues(user, updatedClaims);
FailureReasonDTO error = showFailureReason
? new FailureReasonDTO(Constants.ErrorMessage.CLIENT_ACCOUNT_LOCKED, userId)
: null;
return new ValidationResponseDTO(userId, false, error);
} else {
updatedClaims.put(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM, String.valueOf(currentAttempts + 1));
setUserClaimValues(user, updatedClaims);
return null;
}
}

private User getUserById(String userId) throws SMSOTPException {

try {
AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) SMSOTPServiceDataHolder
.getInstance().getRealmService().getTenantUserRealm(getTenantId()).getUserStoreManager();
return userStoreManager.getUser(userId, null);
} catch (UserStoreException e) {
// Handle user not found.
String errorCode = ((org.wso2.carbon.user.core.UserStoreException) e).getErrorCode();
if (UserCoreErrorConstants.ErrorMessages.ERROR_CODE_NON_EXISTING_USER.getCode().equals(errorCode)) {
throw Utils.handleClientException(Constants.ErrorMessage.CLIENT_INVALID_USER_ID, userId);
}
throw Utils.handleServerException(Constants.ErrorMessage.SERVER_USER_STORE_MANAGER_ERROR,
String.format("Error while retrieving user for the ID : %s.", userId), e);
}
}

private Map<String, String> getUserClaimValues(User user, String[] claims) throws SMSOTPServerException {

try {
AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) SMSOTPServiceDataHolder
.getInstance().getRealmService().getTenantUserRealm(getTenantId()).getUserStoreManager();
return userStoreManager.getUserClaimValues(user.getDomainQualifiedUsername(), claims,
UserCoreConstants.DEFAULT_PROFILE);
} catch (UserStoreException e) {
log.error("Error while reading user claims.", e);
throw Utils.handleServerException(Constants.ErrorMessage.SERVER_USER_STORE_MANAGER_ERROR,
String.format("Failed to read user claims for user ID : %s.", user.getUserID()), e);
}
}

private void setUserClaimValues(User user, Map<String, String> updatedClaims) throws SMSOTPServerException {

try {
AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) SMSOTPServiceDataHolder
.getInstance().getRealmService().getTenantUserRealm(getTenantId()).getUserStoreManager();
userStoreManager.setUserClaimValues(user.getDomainQualifiedUsername(), updatedClaims,
UserCoreConstants.DEFAULT_PROFILE);
} catch (UserStoreException e) {
log.error("Error while updating user claims", e);
throw Utils.handleServerException(Constants.ErrorMessage.SERVER_USER_STORE_MANAGER_ERROR,
String.format("Failed to update user claims for user ID: %s.", user.getUserID()), e);
}
}

/**
* Reset OTP Failed Attempts count upon successful completion of the OTP verification.
*
* @param userId The ID of the user.
* @throws SMSOTPException If an error occurred.
*/
private void resetOtpFailedAttempts(String userId) throws SMSOTPException {

if (!SMSOTPServiceDataHolder.getConfigs().isLockAccountOnFailedAttempts()) {
return;
}

User user = getUserById(userId);
Property[] connectorConfigs = Utils.getAccountLockConnectorConfigs(user.getTenantDomain());
// Return if account lock handler is not enabled.
for (Property connectorConfig : connectorConfigs) {
if ((Constants.PROPERTY_ACCOUNT_LOCK_ON_FAILURE.equals(connectorConfig.getName())) &&
!Boolean.parseBoolean(connectorConfig.getValue())) {
return;
}
}

try {
AbstractUserStoreManager userStoreManager = (AbstractUserStoreManager) SMSOTPServiceDataHolder
.getInstance().getRealmService().getTenantUserRealm(getTenantId()).getUserStoreManager();

String[] claimsToCheck = {Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM, Constants.ACCOUNT_LOCKED_CLAIM};
Map<String, String> userClaims = userStoreManager.getUserClaimValues(user.getDomainQualifiedUsername(),
claimsToCheck, UserCoreConstants.DEFAULT_PROFILE);
String failedEmailOtpAttemptsClaimValue = userClaims.get(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM);
String accountLockClaimValue = userClaims.get(Constants.ACCOUNT_LOCKED_CLAIM);

Map<String, String> updatedClaims = new HashMap<>();
if (NumberUtils.isNumber(failedEmailOtpAttemptsClaimValue) &&
Integer.parseInt(failedEmailOtpAttemptsClaimValue) > 0) {
updatedClaims.put(Constants.SMS_OTP_FAILED_ATTEMPTS_CLAIM, "0");
}
if (Boolean.parseBoolean(accountLockClaimValue)) {
updatedClaims.put(Constants.ACCOUNT_LOCKED_CLAIM, Boolean.FALSE.toString());
updatedClaims.put(Constants.ACCOUNT_UNLOCK_TIME_CLAIM, "0");
}
if (!updatedClaims.isEmpty()) {
userStoreManager.setUserClaimValues(user.getDomainQualifiedUsername(), updatedClaims,
UserCoreConstants.DEFAULT_PROFILE);
}
} catch (UserStoreException e) {
String errorMessage = String.format("Failed to reset failed attempts count for user ID : %s.",
user.getUserID());
log.error(errorMessage, e);
throw Utils.handleServerException(Constants.ErrorMessage.SERVER_USER_STORE_MANAGER_ERROR,
errorMessage, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ public class Constants {
public static final String SMS_OTP_RESEND_THROTTLE_INTERVAL = "smsOtp.resendThrottleInterval";
public static final String SMS_OTP_MAX_VALIDATION_ATTEMPTS_ALLOWED = "smsOtp.maxValidationAttemptsAllowed";
public static final String SMS_OTP_SHOW_FAILURE_REASON = "smsOtp.showValidationFailureReason";
public static final String SMS_OTP_LOCK_ACCOUNT_ON_FAILED_ATTEMPTS = "smsOtp.lockAccountOnFailedAttempts";

public static final String PROPERTY_LOGIN_FAIL_TIMEOUT_RATIO = "account.lock.handler.login.fail.timeout.ratio";
public static final String PROPERTY_ACCOUNT_LOCK_ON_FAILURE = "account.lock.handler.enable";
public static final String PROPERTY_ACCOUNT_LOCK_ON_FAILURE_MAX = "account.lock.handler.On.Failure.Max.Attempts";
public static final String PROPERTY_ACCOUNT_LOCK_TIME = "account.lock.handler.Time";
public static final String SMS_OTP_FAILED_ATTEMPTS_CLAIM = "http://wso2.org/claims/identity/failedSmsOtpAttempts";
public static final String FAILED_LOGIN_LOCKOUT_COUNT_CLAIM =
"http://wso2.org/claims/identity/failedLoginLockoutCount";
public static final String ACCOUNT_LOCKED_CLAIM = "http://wso2.org/claims/identity/accountLocked";
public static final String ACCOUNT_UNLOCK_TIME_CLAIM = "http://wso2.org/claims/identity/unlockTime";
public static final String ACCOUNT_LOCKED_REASON_CLAIM_URI = "http://wso2.org/claims/identity/lockedReason";
public static final String MAX_SMS_OTP_ATTEMPTS_EXCEEDED = "MAX_SMS_OTP_ATTEMPTS_EXCEEDED";
public static final String ADMIN_INITIATED = "AdminInitiated";

public static final String CORRELATION_ID_MDC = "Correlation-ID";
public static final String CORRELATION_ID = "correlation-id";
Expand All @@ -72,12 +86,13 @@ public enum ErrorMessage {
"Mandatory parameters not found : %s."),
CLIENT_OTP_VALIDATION_FAILED("SMS-60008", "Provided OTP is invalid.",
"Provided OTP is invalid. User id : %s."),
CLIENT_NO_OTP_FOR_USER("SMS-60009", "No OTP fround for the user.",
CLIENT_NO_OTP_FOR_USER("SMS-60009", "No OTP found for the user.",
"No OTP found for the user Id : %s."),
CLIENT_SLOW_DOWN_RESEND("SMS-60010", "Slow down.",
"Please wait %s seconds before retrying."),
CLIENT_OTP_VALIDATION_BLOCKED("SMS-60011", "Maximum allowed failed validation attempts exceeded.",
"Maximum allowed failed validation attempts exceeded for user id : %s."),
CLIENT_ACCOUNT_LOCKED("SMS-60012", "Account locked.", "Account is locked for the user ID: %s."),

// Server error codes.
SERVER_USER_STORE_MANAGER_ERROR("SMS-65001", "User store manager error.",
Expand All @@ -99,7 +114,11 @@ public enum ErrorMessage {
SERVER_INVALID_RENEWAL_INTERVAL_ERROR("SMS-65009", "Invalid renewal interval value.",
"Renewal interval should be smaller than the OTP validity period. Renewal interval: %s."),
SERVER_UNEXPECTED_ERROR("SMS-65010", "An unexpected server error occurred.",
"An unexpected server error occurred.");
"An unexpected server error occurred."),
SERVER_ERROR_VALIDATING_ACCOUNT_LOCK_STATUS("SMS-65011", "Error validating account lock status.",
"Server encountered an error while validating account lock status for the user ID : %s."),
SERVER_ERROR_RETRIEVING_ACCOUNT_LOCK_CONFIGS("SMS-65012", "Can't retrieve account lock connector " +
"configurations.", "Server encountered an error while retrieving account lock connector configurations.");

private final String code;
private final String message;
Expand Down
Loading

0 comments on commit 1134c29

Please sign in to comment.