Tworzenie niestandardowego uwierzytelniania SMS w Keycloak

Andrzej Chybicki: projekty związane z wykorzystaniem sztucznej inteligencji to znacząca część naszych projektów
Tworzenie niestandardowego uwierzytelniania SMS w Keycloak
Slider

 

Wraz ze wzrostem liczby zagrożeń cybernetycznych, uwierzytelnianie wieloskładnikowe (MFA) stało się dla wielu firm standardem w politykach bezpieczeństwa.  MFA zwiększa ochronę, wymagając od użytkowników weryfikacji tożsamości za pomocą wielu metod. Takie uwierzytelnianie stało się więc standardową praktyk, dodając dodatkową warstwę ochrony.

Wśród różnych metod, uwierzytelnianie oparte na SMS wyróżnia się równowagą między bezpieczeństwem a wygodą użytkownika. Jednak tworzenie niestandardowego uwierzytelnienia SMS w ramach dostawcy tożsamości, takiego jak Keycloak, może być skomplikowanym i złożonym procesem, wymagającym zrozumienia jego architektury i możliwości rozszerzania.

Interfejs Dostawcy Usług (SPI)

Keycloak ma na celu obsługę większości przypadków użycia bez konieczności tworzenia niestandardowego kodu. Oferuje także elastyczność w zakresie dostosowywania. W tym celu Keycloak udostępnia kilka SPI, które pozwalają na wdrażanie własnych rozwiązań. Zamierzamy wdrożyć uwierzytelnianie, które wymaga ważnego kodu SMS. Aby stworzyć tę funkcję, musimy  zaimplementować interfejsy org.keycloak.authentication.AuthenticatorFactory i Authenticator. AuthenticatorFactory jest odpowiedzialny za tworzenie instancji Authenticatora. Oba interfejsy rozszerzają ogólny zestaw interfejsów Provider i ProviderFactory, które są używane przez inne komponenty Keycloak.

Pakietowanie klas

Będziemy pakietować nasze klasy do jednego projektu. Musi on zawierać plik o nazwie org.keycloak.authentication.AuthenticatorFactory, który powinien znajdować się w katalogu META-INF/services/. Plik ten musi zawierać pełne kwalifikowane nazwy klas każdej implementacji AuthenticatorFactory, którą masz w pliku JAR. Na przykład:

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

Plik services/ jest używany przez Keycloak do skanowania dostawców, których musi załadować do systemu.

 

CredentialModel i CredentialProvider

Pierwszym krokiem jest skonfigurowanie naszych klas związanych z poświadczeniami, ponieważ numer telefonu użytkownika powinien być przechowywany jako rekord poświadczeń. Jak widać poniżej, klasa Sms2faCredentialData jest prostym kontenerem danych do przechowywania numeru telefonu powiązanego z użytkownikiem.

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;
}
}

Kolejnym krokiem jest rozszerzenie klasy CredentialModel, która może generować prawidłowy format poświadczeń w bazie danych. Aby obiekty Sms2faCredentialModel były w pełni funkcjonalne, muszą zawierać nie tylko surowe dane JSON odziedziczone po klasie bazowej, ale także odmarshallowane obiekty wewnątrz swoich własnych atrybutów. Zapewnia to szeroką dostępność i wykorzystanie poświadczeń, umożliwiając łatwą integrację i obsługę procesów uwierzytelniania.

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();
}
}

Podobnie jak w przypadku innych dostawców w Keycloak, utworzenie CredentialProvider wymaga obecności odpowiadającej mu CredentialsProviderFactory. Aby spełnić ten wymóg, implementujemy Sms2faCredentialProviderFactory.

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);
}
}

Interfejs CredentialProvider jest zbudowany z parametrem generycznym, który rozszerza CredentialModel, zapewniając kompatybilność z różnymi typami poświadczeń. Dodatkowo musimy zaimplementować interfejs CredentialInputValidator, co wskazuje Keycloak, że ten dostawca jest przygotowany do uwierzytelniania poświadczeń dla naszego niestandardowego Authenticatora. Chociaż nie będziemy tu omawiać pełnej architektury, dokumentacja Keycloak obejmuje dodatkowe metody.

Nasza implementacja obejmuje funkcje tworzenia i usuwania poświadczeń. Funkcje te wykorzystują menedżera poświadczeń, odpowiedzialnego za przechowywanie i pobieranie poświadczeń, niezależnie od tego, czy są one przechowywane lokalnie, czy w systemach magazynowania federacyjnego.

@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);
}

Dla interfejsu CredentialInputValidator główną metodą do zaimplementowania jest isValid, która sprawdza, czy dane poświadczenie jest ważne dla danego użytkownika w danej domenie (realm). Jest to metoda wywoływana przez Authenticator, gdy chce zweryfikować dane wprowadzone przez użytkownika.

@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());

Teraz powinniśmy mieć wszystko, aby móc przejść do implementacji samego Authenticatora.

 

AuthenticatorFactory i Authenticator

Klasa SmsAuthenticatorFactory zawiera logikę potrzebną do skonfigurowania i tworzenia instancji SmsAuthenticator, który wykonuje walidację OTP opartą na SMS. Obsługuje dostosowywanie poprzez kilka konfigurowalnych właściwości.

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.";
}
     (…)
}

Teraz przejdźmy do samego Authenticatora. Główna metoda, na której się skupimy, to sendChallenge(). Kiedy przepływ jest początkowo wyzwalany, ta metoda jest wywoływana. Ważne jest, aby zauważyć, że nie obsługuje ona przetwarzania formularza kodu SMS. Jej rola polega na renderowaniu strony lub kontynuowaniu przepływu.

Strona HTML, która prosi o wprowadzenie otrzymanego kodu, jest prezentowana użytkownikowi, który następnie wprowadza kod i przesyła go. Wówczas wysyłane jest żądanie HTTP do przepływu za pomocą adresu URL akcji określonego w formularzu HTML. To wyzwala metodę action() w naszej implementacji Authenticatora. Jeśli podany kod jest nieprawidłowy, rekonstruujemy formularz HTML, dodając komunikat o błędzie. Następnie używamy metody failureChallenge(), przekazując powód niepowodzenia. Działa ona podobnie do challenge(), ale dodatkowo loguje błąd, co pomaga wykryć ewentualne możliwości ataku.

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

Aby dodać Authenticator do przepływu, administrator musi przejść do Konsoli. W sekcji Uwierzytelnianie (Authentication) i zakładce Przepływy (Flows) powinien zobaczyć istniejące przepływy. Wbudowane przepływy nie mogą być bezpośrednio modyfikowane, więc aby zintegrować nowo utworzony Authenticator, musimy albo zduplikować istniejący przepływ, albo stworzyć nowy od podstaw.

Required actions

Jeśli telefon nie jest skonfigurowany, powinniśmy wywołać niestandardowe wymagane działanie. Ponownie, powinniśmy dodać pełną kwalifikowaną nazwę klasy do katalogu META-INF/services i zaimplementować interfejs RequiredActionProvider. Metoda requiredActionChallenge() jest odpowiedzialna za renderowanie HTML, który poprowadzi wymagane działanie.

@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);
}

Ta część jest odpowiedzialna za przetwarzanie danych wejściowych z formularza HTML wymaganego działania. Po wprowadzeniu otrzymanego kodu SMS, numer telefonu powinien zostać zapisany w bazie danych jako poświadczenie. Przy następnym logowaniu będziemy mogli skorzystać z tej formy 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 */ 
    }
}

Ostatnią rzeczą, którą musisz zrobić, jest przejście do Konsoli Administratora i zakładki Wymagane Działania (Required Actions). Twoje nowe działanie powinno teraz być wyświetlone i włączone na liście wymaganych działań.

Jeśli użytkownik nie podał wcześniej numeru telefonu, a uwierzytelnianie SMS jest ustawione jako wymagane w przepływie uwierzytelniania, powinna pojawić się nowa widok.

Podsumowując, konfiguracja SMS MFA obejmuje ustawienie niestandardowych wymaganych działań oraz dostawców uwierzytelniania, ale oczywiście istnieją inne kwestie do uwzględnienia, takie jak komunikacja między Keycloak a bramką SMS. Zagadnienia, które omówiliśmy w tym poście, to oczywiście tylko część możliwej konfiguracji, ale najważniejsza i specyficzna dla Keycloak.

Jedną z istotnych zalet wdrożenia SMS MFA jest jego powszechna dostępność, ponieważ większość użytkowników posiada telefony komórkowe zdolne do odbierania wiadomości SMS. Dodatkowo, zapewnia to prostą obsługę dla użytkowników, wymagając minimalnej konfiguracji i znajomości. Jednak mechanizm ten ma swoje wady, w tym potencjalne zagrożenia, takie jak ataki polegające na zamianie kart SIM lub przechwytywaniu kodów SMS. Ponadto, dostarczanie wiadomości SMS może czasami być zawodnym procesem, co prowadzi do opóźnień lub nieudanej dostawy, wpływając na doświadczenie użytkownika. Powinniśmy być tego świadomi przed podjęciem decyzji o zastosowaniu tej metody, szczególnie w erze nowszych rozwiązań, takich jak mobilne aplikacje OTP.

Related Posts