Behind the Scenes: Custom SMS Authenticator with Keycloak

Behind the Scenes: Custom SMS Authenticator with Keycloak
Andrzej Chybicki: projekty związane z wykorzystaniem sztucznej inteligencji to znacząca część naszych projektów

Behind the Scenes: Custom SMS Authenticator with Keycloak

 

With the increasing number of cyber threats, multi-factor authentication (MFA) has become a standard security measure. MFA enhances protection by requiring users to verify their identity through multiple methods. In today’s digital world MFA has become a standard security practice, adding an extra layer of protection by requiring users to provide multiple forms of verification. 

Among the various advanced authentication methods, SMS-based authentication stands out for its balance of security and user convenience. However, sometimes creating a custom SMS authenticator within an identity provider like Keycloak can be a complex and nuanced process, demanding an understanding of its architecture and extensibility.

 

Service Provider Interface (SPI)

Keycloak aims to address the majority of use cases without forcing you to create custom code. However, it also offers flexibility for customization. To support this, Keycloak provides several SPIs that allow you to implement your own solutions. We are going to implement an authenticator that requires a valid SMS code. To create this feature, we must at least implement the org.keycloak.authentication.AuthenticatorFactory and Authenticator interfaces. The AuthenticatorFactory is responsible for creating instances of an Authenticator. They both extend a more generic Provider and ProviderFactory set of interfaces that other Keycloak components do.

 

Packaging classes

We will package our classes within a single project. It must contain a file named org.keycloak.authentication.AuthenticatorFactory and must be contained in the META-INF/services/ directory. This file must list the fully qualified class name of each AuthenticatorFactory implementation you have in the jar. For example:

pl.inero.keycloakext.authenticator.sms.SmsAuthenticatorFactory
pl.inero.keycloakext.authenticator.custom.CustomUsernamePasswordFormFactory
pl.inero.keycloakext.authenticator.custom.CustomCookieAuthenticatorFactory

This services/ file is used by Keycloak to scan the providers it has to load into the system.

 

CredentialModel and CredentialProvider

The first step is to configure our Credential related classes since the user’s phone number should be stored as credential record. As you can see below, the Sms2faCredentialData class is a straightforward data container for storing the phone number associated with the user.

public class Sms2faCredentialData {

private String phoneNumber;

@SuppressWarnings("unused") //used for credentials deserialization
public Sms2faCredentialData() {
}

public Sms2faCredentialData(String phoneNumber) {
     this.phoneNumber = phoneNumber;
}

public String getPhoneNumber() {
     return phoneNumber;
}

@SuppressWarnings("unused") //used for credentials deserialization
public void setPhoneNumber(String phoneNumber) {
     this.phoneNumber = phoneNumber;
}
}

The next step is to extend the CredentialModel class that can generate the correct format of the credential in the database. For the Sms2faCredentialModel objects to be fully functional, they need to encompass not only the raw JSON data inherited from their parent but also encapsulate the unmarshalled objects within their own attributes. This ensures wide accessibility and utilization of the credentials, providing easy integration and handling of authentication processes.

public class Sms2faCredentialModel extends CredentialModel {

public static final String TYPE = "sms2fa";
private Sms2faCredentialData smsCredentials;

public Sms2faCredentialModel(Sms2faCredentialData smsCredentials) {
     try {
         this.smsCredentials = smsCredentials;
         setCredentialData(JsonSerialization.writeValueAsString(smsCredentials));
         setUserLabel("tel: " + smsCredentials.getPhoneNumber());

         setType(TYPE);
     } catch (IOException e) {
         throw new RuntimeException(e);
     }
}

public String getPhoneNumber() {
     return smsCredentials.getPhoneNumber();
}
}

Similar to other providers within Keycloak, the creation of the CredentialProvider requires the presence of a corresponding CredentialsProviderFactory. In meeting this requirement, we implement the Sms2faCredentialProviderFactory requirement, we implement the Sms2faCredentialProviderFactor

public class Sms2faCredentialProviderFactory  implements 
CredentialProviderFactory<Sms2faCredentialProvider> {

public static final String PROVIDER_ID = "keycloak-ext-sms2fa";

@Override
public String getId() {
     return PROVIDER_ID;
}

@Override
public CredentialProvider<Sms2faCredentialModel> create(KeycloakSession session) {
     return new Sms2faCredentialProvider(session);
}
}

The CredentialProvider interface is structured with a generic parameter that extends a CredentialModel, ensuring compatibility across a spectrum of credential types. Additionally, we need to implement the CredentialInputValidator interface, indicating to Keycloak that this provider is equipped to authenticate credentials for our custom Authenticator. Although we won’t dive into the full architecture here, Keycloak documentation covers additional methods.

Our implementation includes functionalities to create and delete credentials. These functionalities use the credential manager, responsible for the storage and retrieval of credentials, whether they’re stored locally or within federated storage systems.

@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, Sms2faCredentialModel credentialModel) {
if (credentialModel.getCreatedDate() == null) {
     credentialModel.setCreatedDate(Time.currentTimeMillis());
return user.credentialManager().createStoredCredential(credentialModel);
}

@Override
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
logger.debugv("Delete Sms2fa credential. username = {0}, credentialId = {1}", user.getUsername(), credentialId);
return user.credentialManager().removeStoredCredentialById(credentialId);
}

For the CredentialInputValidator, the main method to implement is the isValid, which tests whether a credential is valid for a given user in a given realm. This is the method that is called by the Authenticator when it seeks to validate the user’s input.

@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
final Sms2faCredentialModel credentialModel = getDefaultCredential(session, realm, user);
final String secretData = credentialModel.getSecretData();
return secretData != null && secretData.equals(credentialInput.getChallengeResponse());

Now we should have everything to be able to move on to implementing the authenticator itself.

 

AuthenticatorFactory and Authenticator

The SmsAuthenticatorFactory class encapsulated the logic needed to configure and create instances of the SmsAuthenticator, which performs SMS-based OTP validation. It supports customization through several configurable properties.

supports customization through several configurable properties.
public class SmsAuthenticatorFactory implements AuthenticatorFactory {

@Override
public String getId() {
     return "sms-authenticator";
}

@Override
public String getDisplayType() {
     return "SMS Authentication";
}
@Override
public String getHelpText() {
     return "Validates an OTP sent via SMS to the users mobile phone.";
}
     (…)
}

Now let’s dive into the Authenticator itself. The primary method to focus is sendChallenge(). When the flow is initially triggered, this method is invoked. It’s important to notice that it doesn’t handle the processing of the SMS code form. Rather, its role is to either render the page or continue the flow.

The HTML page requesting the received code is presented to the user, who then inputs the code and submits it. Then an HTTP request is sent to the flow via the action URL specified in the HTML form. This triggers the action() method within our Authenticator implementation. If the provided code is invalid, we reconstruct the HTML Form, appending an error message. Following this, we utilize failureChallenge(), passing the reason for the failure. It operated similarly to challenge(), but additionally logs the error, helping to detect any attack possibility.

private void sendChallenge(AuthenticationFlowContext context) {
(…)
credentialModel.setSecretData(code);
user.credentialManager().updateStoredCredential(credentialModel);
AuthenticationSessionModel authSession = context.getAuthenticationSession();
authSession.setAuthNote("ttl", Long.toString(System.currentTimeMillis() + (ttl * 1000L)));

try {
     /* sending SMS */
     context.challenge(context.form().setAttribute("realm", context.getRealm()).createForm(TPL_CODE));
} catch (Exception e) {
     context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
             context.form().setError("smsAuthSmsNotSent", e.getMessage())
                     .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
}
}

@Override
public void action(AuthenticationFlowContext context) {
(…)
final Sms2faCredentialModel credentialModel = getCredentialProvider(session).getDefaultCredential(session, context.getRealm(), user);
boolean isValid = getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(),
       new UserCredentialModel(credentialModel.getId(), getCredentialProvider(context.getSession()).getType(), enteredCode));
if (isValid) {
     if (Long.parseLong(ttl) < System.currentTimeMillis()) {
         // expired
         context.failureChallenge(AuthenticationFlowError.EXPIRED_CODE,

                 context.form().setError("smsAuthCodeExpired").createErrorPage(Response.Status.BAD_REQUEST));
     } else {
         // valid
         context.success();
     }
} else {
     context.getEvent().user(user).error(Errors.INVALID_USER_CREDENTIALS);
     context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
             context.form().setAttribute("realm", context.getRealm())
                     .setError("smsAuthCodeInvalid").createForm(TPL_CODE));
}
}

Authentication Flow

To add an Authenticator into a flow, administrators must navigate to the Console. By accessing the Authentication section and navigating to the Flow tabs, they should see existing flows. Built-in flows cannot be directly modified, so to integrate the newly created Authenticator, we need to either duplicate an existing flow or create a new one from scratch.

Required actions

If the phone is not set up, we should trigger a custom required action. Again, we should add the fully qualified class name to the META-INF/services directory and implement RequiredActionProvider interface. Method requiredActionChallenge() is responsible for rendering the HTML that will drive the required action.

@Override
public void requiredActionChallenge(RequiredActionContext context) {
LoginFormsProvider form = context.form();
if (getSmsAuthenticatorConfig(context) == null) {
     form.setError("smsAuthMissingAuthenticatorConfig");
}
final Response response = form.createForm("sms-2fa-register.ftl");
context.challenge(response);
}

This part is responsible for processing input from the HTML form of the required action. After entering the received SMS code, the phone number should be saved in the database as a credential. The next time we log in, we will be able to use this form of OTP.

@Override
public void processAction(RequiredActionContext context) {
    (…)
final String phoneNumber = params.getFirst("phoneNumber");
final String code = params.getFirst("code");

if(StringUtils.isBlank(phoneNumber) && StringUtils.isBlank(authSession.getAuthNote("phone_number"))) {
     //if no phone number is set, redirect to the first page
     requiredActionChallenge(context);
     return;
}
if (phoneNumber != null) {
     sendPhoneNumberVerificationChallenge(context, authSession, phoneNumber, smsAuthenticatorConfig.getConfig());
     return;
}
if (code != null) {
     /* verify provided SMS code */ 
    }
}

The final thing you have to do is go into the Admin Console and Required Actions tab. Your new action should now be displayed and enabled in the required actions list.

If the user hasn’t provided a phone number before and SMS authenticator is set up as required in the Authentication Flow, a new view should appear.

In summary. configuring SMS MFA involves setting up custom required actions and authenticator providers, but of course, there are other things to cover like Keycloak <-> SMS gateway communication. What we looked at in this post is of course only part of the possible configuration, but the most important and Keycloak-specific one.

One of the significant advantages of SMS MFA implementation is its widespread accessibility, as most users already have mobile phones capable of receiving SMS messages. Additionally, it provides a straightforward user experience, requiring minimal setup and familiarity for users. However, this mechanism does have its drawbacks, including potential vulnerabilities such as SIM swapping attacks or interception of SMS codes. Moreover, SMS delivery can sometimes be unreliable, leading to delays or failed delivery, impacting the user experience. We should be aware of this before we decide on such a method, especially in the era of newer solutions like mobile-app OTP.

Related Posts