In diesem Artikel zeige ich euch wie ihr die Erstellung und Anmeldung mit Passkeys in einem Java-Backend mit einer nativen Android-App umsetzen könnt. Der Schwerpunkt liegt dabei auf einer möglichst lose gekoppelten Integration im Backend mit der Verwendung der Library webauthn4j.
Einführung ins Thema
Hier eine nützliche Einführung in das Thema, mit der ich gestartet habe. Ist zwar für Android – erklärt es aber auch generell ganz gut:
Der Lösungsansatz im Backend
Während man im Frontend einen ziemlich klaren Weg vorgegeben bekommt, wie die Passkey Implementierung mit dem Credential Manager funktioniert => Google Developer Guide ist die Backend Seite viel weniger klar geregelt. Es gibt die Möglichtkeit 3rd Party Plattformen (Okta, Firebase,..) einzubinden. Wenn man diese ohnehin schon verwendet ist der Weg wohl klar.
Will man eine solche Plattform nicht einbinden dann muss man selbst Hand anlegen. Hier findet man schon einige nützliche Ressourcen.
In der Java Welt kommt man relativ schnell zur Erkenntnis dass die Lib webauthn4j die weit verbreitetste ist, welche auch von vielen großen Anbietern verwendet wird. Hier ist dann noch die Entscheidung zu treffen, ob man die Spring Security Erweiterung verwendet oder nur die Core Lib verwendet. Da ich in mein bestehendes Spring Security Setup nicht eingreifen wollte, und das Risiko dass hier meine bestehenden Mechanismen beeinflusst werden nicht eingehen wollte, habe ich mich dafür entschieden nur die Core Lib für die Umsetzung zu verwenden.
Entscheidungshilfe
Hier ein paar Links zur Enscheidungshilfe, welche Libraries es gibt:
Developer Guides for Backend
Github Repo mit vielen nützlichen Links
Webauthn(4j) in a nutshell
Ich werde hier nicht auf den kompletten Fido/Webauthn Standard eingehen, da dass eine komplexe Thematik ist. Ich verweise hier lieber auf den Deep-Dive. Vielmehr will ich nur kurz skizzieren was am Backend umgesetzt werden muss um Passkeys zu unterstützen.
Prinzipiell sind zwei Szenarien zu unterscheiden:
- Registrierung / Erstellung eines Passkeys (mit eingeloggtem User)
- Login mit einem Passkey
Ich gehe beim Erstellungs-Szenario davon aus, dass der Passkey zusätzlich zur klassischen Passwort Anmeldung angeboten wird und der User ihn nur erstellen kann, wenn er schon in seinem Konto angemeldet ist.
Beide Szenarien unterteilen sich wiederum in zwei Phasen:
- Initialisierung der Aktion (Request Challenge)
- Abschluss der Aktion
Diese Schritte erfolgen immer in Zusammenspiel des Backends/der API und der SDK am Frontend. Im Fall von Android dem CredentialManager.
Folgender Ablauf ist also zu Implementieren:
- Request an die API –> Initialisierung
- Weitergabe des JSONs aus dem Response an den CredentialManager
- Warten auf Ergebnis des CredentialManagers
- Forwarden des Results an das Backend/ die API um den Vorgang abzuschließen –> Finish
Erstellung eines Passkeys Walkthrough
Wir definiert zwei Endpunkte auf der Server-Seite die nur im eingloggten Zustand abrufbar sind. Wir starten mit dem init call:
@RequestMapping(value = "/registration/init", method = RequestMethod.POST)
public CredentialCreationOptions start(@Context HttpServletRequest request) {
return webAuthnService.startRegistration(getLoggedInUser(), getClientType());
}
Dieser wird vom Client ohne Parameter aufgerufen sobald ein Passkey erstellt werden soll. Folgendes Objekt wird von diesem Call erzeugt und kann am Client 1:1 an den CredentialManager weitergegeben werden.
public class CredentialCreationOptions {
private String origin;
private String challenge;
private RpEntity rp;
private UserEntity user;
private List<PubKeyCredParam> pubKeyCredParams;
private long timeout;
private String attestation;
private List<PublicKeyCredentialDescriptor> excludeCredentials;
private AuthenticatorSelection authenticatorSelection;
}
origin | Die FacetID des APKs (siehe weiter unten) |
challenge | Ein zufällig generierter einmaliger generierter Wert |
RpEntity | Die Origin der API (hier muss auch das .well-known File zur Verfügung stehen) |
UserEntity | ID des eingeloggten Users |
pubKeyCredParams | Eine Liste der erlaubten Algorithmen |
timeout | Wie lange die Aktion am Client dauern darf |
attestation | Verschiedene Modi werden unterstützt. Z.Bsp „direct“ |
excludeCredentials | Bereits erstellte Credentials |
authenticatorSelection | Parameter für die Erstellung des Passkeys |
public CredentialCreationOptions startRegistration(User user, ClientType clientType) {
// should be login or identification instead
FidoRegistration fidoRegistration = fidoRegistrationRepository.findByUserId(kmUser.getId());
List<FidoCredential> fidoCredentials = kmUserFidoCredentialRepository.findAllByFidoRegistrationId(fidoRegistration.getId());
List<PublicKeyCredentialDescriptor> excludeCredentials = fidoCredentials.stream()
.map(c -> PublicKeyCredentialDescriptor.builder().type(PublicKeyCredentialType.PUBLIC_KEY.toString())
.id(c.getId().toString()).build()).collect(Collectors.toList());
RpEntity relyingParty = RpEntity.builder()
.name(clientType.equals(ClientType.IOS) ? iosRpId : androidRpId)
.id(clientType.equals(ClientType.IOS) ? iosRpId : androidRpId)
.build();
UserEntity user = UserEntity.builder()
.displayName(user.getMail())
.name(user.getName())
.id(Base64.getUrlEncoder().withoutPadding().encodeToString(fidoRegistration.getFidoId().getBytes()))
.build();
List<PubKeyCredParam> pubkey = Arrays.asList(
PubKeyCredParam.builder()
.type(PublicKeyCredentialType.PUBLIC_KEY.toString()).alg(-7).build(),
PubKeyCredParam.builder()
.type(PublicKeyCredentialType.PUBLIC_KEY.toString()).alg(-257).build()
);
byte[] challengeRaw = challenge(32);
String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeRaw);
AuthenticatorSelection authenticatorSelection = new AuthenticatorSelection();
authenticatorSelection.setAuthenticatorAttachment("platform");
authenticatorSelection.setRequireResidentKey(true);
authenticatorSelection.setResidentKey("required");
authenticatorSelection.setUserVerification("required");
// create response to send back
CredentialCreationOptions publicKeyCredentialCreationOptions = CredentialCreationOptions
.builder()
.origin(clientType.equals(ClientType.IOS) ? iosOrigin : androidOrigin)
.attestation("direct")
.challenge(challenge)
.rp(relyingParty)
.pubKeyCredParams(pubkey)
.user(user)
.excludeCredentials(excludeCredentials)
.authenticatorSelection(authenticatorSelection)
.timeout(60000)
.build();
log.info("Returning credential creation options {}", publicKeyCredentialCreationOptions);
WebAuthnSession session = new WebAuthnSession(challenge, fidoRegistration);
redisWebauthnSessionService.addRegistrationSession(session);
return publicKeyCredentialCreationOptions;
}
Hier sieht man wie webauthn4j genutzt werden kann um die Create-Options für die Passkey-Erstellung zu erzeugen. Dieses Objekt wird an den Client zurückgegeben um den CredentialManager zu starten:
webauthnRestService.initRegistration().subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Observer<>() {
@Override
public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {
}
@Override
public void onNext(@io.reactivex.annotations.NonNull CredentialCreationOptions credentialCreationOptions) {
String publicKeyCredentialRequestJson = gson.toJson(credentialCreationOptions);
CreatePublicKeyCredentialRequest createPublicKeyCredentialRequest = new CreatePublicKeyCredentialRequest(publicKeyCredentialRequestJson);
// Sende die Anforderung
credentialManager.createCredentialAsync(context,
createPublicKeyCredentialRequest,
null,
executor,
new CredentialManagerCallback<>() {
@Override
public void onResult(CreateCredentialResponse createCredentialResponse) {
// CONTINUE HERE TO SEND SUCCESSFUL RESPONSE TO API
}
@Override
public void onError(@NotNull androidx.credentials.exceptions.CreateCredentialException e) {
webauthnRegisterCallback.onError(e);
}
});
}
@Override
public void onError(@io.reactivex.annotations.NonNull Throwable e) {
webauthnRegisterCallback.onError(e);
}
@Override
public void onComplete() {
}
});
To be continued
FacetId in Android herausfinden
In Android muss die FacetId des APKs für die Festlegung der erlaubten Origin verwendet werden.
Diese hat folgende Form
android:apk-key-hash:<base64_encoded_sha256_hash-of-apk-signing-cert>
Der hintere Teil errechnet sich aus dem SHA-256 Hash des verwendeten Signing Keys mit entferntem Padding:
keytool -list -v -keystore <your.keystore> | grep "SHA256: " | cut -d " " -f 3 | xxd -r -p | openssl base64 | sed 's/=//g' | tr '/+' '_-'
Damit wird sichergestellt, das man im Besitz des Signing Keys ist um Passkey Requests des Clients zu beantworten.