Home | Send Feedback

Creating a password-less sign-in with WebAuthn, Spring and Ionic/Angular

Published: August 25, 2019  •  javascript, java, spring, ionic4

WebAuthn (Web Authentication) is a web standard for authenticating users to web-based applications using public/private key cryptography. Strictly speaking, WebAuthn is just the name of the browser API and is part of FIDO2. FIDO2 is the overarching term of a set of specifications, including WebAuthn and CTAP. FIDO2 is the successor of the FIDO Universal 2nd Factor (U2F) legacy protocol.

As an application developer, we don't deal with CTAP (Client-to-Authenticator Protocol), which is the protocol that the browser uses to speak with an authenticator like a FIDO security key.

FIDO2 works with public/private keys. The user has an authenticator which creates public/private key pairs. These key pairs are different for each site. The public key is transferred to the server and stored in the user's account. The private key never leaves the authenticator. To login, the server first creates a random challenge (a random sequence of bytes), sends it to the authenticator. The authenticator signs the challenge with his private key and sends the signature back to the server. The server verifies the signature with the stored public key and grants access if the signature is valid.

Traditionally this technology needs a hardware security token like a Yubico key or a key from Feitian to name two brand names.

FIDO2 still supports these hardware keys, but the technology also supports alternatives. If you have an Android 7+ phone or a Windows 10 system, you don't need to buy a FIDO2 security key if you want to play with WebAuthn.
In April 2019, Google announced that any phone running Android 7+ can function as a FIDO2 security key. In November 2018, Microsoft announced that you can use Windows Hello as a security key for FIDO2.

I've successfully tested the demo application I wrote for this blog post with an Android 9 smartphone, and on my Windows 10 laptop, I used a 4-year old Yubico Edge USB key and the Chrome browser.

WebAuthn is currently (August 2019) implemented in Edge, Firefox and Chrome. Visit caniuse.com to check out the current state of implementations: https://caniuse.com/#search=webauthn

In this blog post, I'm going to show you how you can integrate WebAuthn into a web application (Ionic/Angular) and a Spring Boot back end. I created a trivial password-less authentication demo, where the user only has to enter his user name to login.


This blog post does not go deep into the WebAuthn protocol and does not explain or show every feature. There are excellent resources on the web if you want to dive deeper into the standard and learn more.

Check out the WebAuthn website from Auth0. The demo on the website shows you all the data that flows between the components.

Also, check out the Yubico website about WebAuthn and the project page of the Java library the demo application uses.

WebAuthn API

The Web Authentication API is an extension of the Credential Management API. In my previous blog post I use this API to interact with the password manager in the browser. WebAuthn extends the two functions from the Credential Management API navigator.credentials.create() and navigator.credentials.get() so they accept a publicKey parameter. We are going to use the create() method for registration and get() for the sign-in process.

An easy way to check if a browser supports WebAuthn is to check the presence of the PublicKeyCredential interface.

if (window.PublicKeyCredential) {
    // WebAuthn supported
}

Demo application

For this blog post, I wrote an Ionic 4 / Angular TypeScript web application and a Spring Boot backend with Java.

The web application consists of a register and a sign-in page. To keep it simple the registration page only asks for the mandatory user name.

register1 register2

To login the user enters his username, and the WebAuthn request prompts a system dialog.

login1 login2

On an Android device, the user experience looks a bit different.

Registration

register1 register2 register3

Login

login1 login2

Note that WebAuthn has nothing to do with biometric authentication. Android 7+ can play the role of a security key thanks to the TPM (Trusted Platform Module) chip built into the devices. To allow WebAuthn access to this chip, the user needs to give permission. In my case with the fingerprint, but it could also be a pin or a password.


Source Code

The source code for the demo application is stored in this GitHub repository:
https://github.com/ralscha/webauthn-demo

Online demo: https://demo.rasc.ch/webauthn/


The Spring Boot application is located in the server folder. You can start it either from inside an IDE or from a shell with mvnw spring-boot:run. The server listens on port 8080.

You find the web application in the client folder, and you start it with ionic serve. The application is served from port 8100.

Because the two applications run on different ports, you have to either enable CORS (Cross-Origin Resource Sharing) in the Spring Boot application or create a proxy file in the client application that instructs ionic serve (and ng serve) to redirect requests. For this application, I implemented the latter approach and wrote this proxy file.

If you use a proxy file, you have to enable it in the angular.json file. Add the proxyConfig option to the serve command.

        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "options": {
            "browserTarget": "app:build",
            "proxyConfig": "proxy.conf.json"
          },

angular.json


Libraries

To handle the FIDO2/WebAuthn requests and create the correct responses on the server, I use the Yubico java-webauthn-server library. You add it with the following coordinates to your Java project.

      <groupId>com.yubico</groupId>
      <artifactId>webauthn-server-core</artifactId>
      <version>1.3.0</version>

pom.xml

On the client-side, apart from the usual Ionic and Angular libraries, I only installed one extra library base64-js which takes care of converting Base64 encoded strings into Uint8Array objects and vice versa.
npm install base64-js


Implementation details

The Yubico java-webauthn-server library requires an implementation of the CredentialRepository interface. You find my implementation here: JooqCredentialRepository.java
java-webauthn-server uses this implementation to read the credentials stored in the database.

The library also needs an instance of RelyingParty. The application creates the instance as a Spring-managed bean, that will be injected into the other Spring beans.

The two important properties we have to set are RelyingPartyIdentity.id and RelyingParty.origins.
The id must be set to the origin of your web application URL. If you test this with https://www.example.com, id must contain the value www.example.com. The id is checked on the client and if this value does not match with the URL of your application the navigator.credentials.create() and navigator.credentials.get()
methods fail with an error.
The second important property origins must contain the full URL of our application. With the URL above it has to be set to https://www.example.com. The origins property is checked on the server, and the java-webauthn-server library throws an exception if the URL of the web application is not listed in the origins collection.

  @Bean
  public RelyingParty relyingParty(JooqCredentialRepository credentialRepository,
      AppProperties appProperties) {

    RelyingPartyIdentity rpIdentity = RelyingPartyIdentity.builder()
        .id(appProperties.getRelyingPartyId()).name(appProperties.getRelyingPartyName())
        .icon(Optional.ofNullable(appProperties.getRelyingPartyIcon())).build();

    return RelyingParty.builder().identity(rpIdentity)
        .credentialRepository(credentialRepository)
        .origins(appProperties.getRelyingPartyOrigins()).build();
  }

Application.java

I externalized these settings with a @ConfigurationProperties annotated POJO, AppProperties.java, so I can easily change the values in the application.properties file or from the command line.

For testing on localhost, I use these values.

app.relying-party-id=localhost
app.relying-party-name=Example Application
app.relying-party-icon=http://localhost:8100/assets/logo.png
app.relying-party-origins=http://localhost:8100

application.properties


Entities

The application stores the user and credentials (public key) in a SQL database. The application uses these two tables.

er

For WebAuthn we only need the app_user.id and app_user.username fields. We discuss the purpose of the other fields a bit later. Each user can add multiple authenticators to his account. Therefore we have a one-to-many relation between the app_user and credentials tables. In the credentials table, we store the credential id, which is an authenticator generated identification, the public key, and a signature counter to thwart replay attacks.

The application uses jOOQ and Flyway to access and create the tables. If you are interested in this setup check, out my blog post about this topic.

Registration

Overview of the registration process.
registration

Registration requires two round trips from the client to the server.

First, the user enters his username (1), and the client sends a POST request to /registration/start (2). The server checks if the user name is already taken (3) and sends back an error message if that is the case (4). Otherwise, the application inserts the user into the app_user table (5).

  @PostMapping("/registration/start")
  public RegistrationStartResponse registrationStart(
      @RequestParam(name = "username", required = false) String username,
      @RequestParam(name = "registrationAddToken",
          required = false) String registrationAddToken,
      @RequestParam(name = "recoveryToken", required = false) String recoveryToken) {

    long userId = -1;
    String name = null;
    Mode mode = null;

    if (username != null && !username.isEmpty()) {
      // cancel if the user is already registered
      int count = this.dsl.selectCount().from(APP_USER)
          .where(APP_USER.USERNAME.equalIgnoreCase(username)).fetchOne(0, int.class);
      if (count > 0) {
        return new RegistrationStartResponse(
            RegistrationStartResponse.Status.USERNAME_TAKEN);
      }

AuthController.java

Next, the handler calls the startRegistration() method from the RelyingParty singleton we created earlier.
This call requires a UserIdentity instance which we build with the user name and user id. The method creates a PublicKeyCredentialCreationOptions instance with a random challenge. Our endpoint sends this object back to the client (7).

      PublicKeyCredentialCreationOptions credentialCreation = this.relyingParty
          .startRegistration(StartRegistrationOptions.builder()
              .user(UserIdentity.builder().name(name).displayName(name)
                  .id(new ByteArray(BytesUtil.longToBytes(userId))).build())
              .build());

      byte[] registrationId = new byte[16];
      this.random.nextBytes(registrationId);
      RegistrationStartResponse startResponse = new RegistrationStartResponse(mode,
          Base64.getEncoder().encodeToString(registrationId), credentialCreation);

      this.registrationCache.put(startResponse.getRegistrationId(), startResponse);

      return startResponse;

AuthController.java

We must store the PublicKeyCredentialCreationOptions instance in a cache because in step 11, we need to pass this object to the finishRegistration() method as an argument. I run only a single instance of this application, so I store it in a Caffeine in-memory cache.

    this.registrationCache = Caffeine.newBuilder().maximumSize(1000)
        .expireAfterAccess(5, TimeUnit.MINUTES).build();

AuthController.java

This does not work if you run multiple instances of your server application. For this scenario, you have to store the information into a database or in an in-memory clustered cache like Hazelcast.


The client receives the PublicKeyCredentialCreationOptions object as response and passes it to the navigator.credentials.create() (8) method.

    const credential = await navigator.credentials.create({
      publicKey: createOptions
    });

registration.page.ts

The browser interacts with the security key and gets back a PublicKeyCredential object (9). This object contains an AuthenticatorAttestationResponse object with the public key and the signed challenge.

The web application sends this object in a POST request to the /registration/finish endpoint (10).

    const credentialResponse = {
      registrationId: response.registrationId,
      credential: {
        id: credential.id,
        type: credential.type,
        clientExtensionResults,
        response: {
          attestationObject: uint8ArrayTobase64(credential.response.attestationObject),
          clientDataJSON: uint8ArrayTobase64(credential.response.clientDataJSON)
        }
      }
    };

    const loading = await this.messagesService.showLoading('Finishing registration ...');
    await loading.present();

    this.httpClient.post('registration/finish', credentialResponse, {responseType: 'text'})

registration.page.ts


The finish endpoint retrieves the response object of the first request from the Caffeine cache, calls the finishRegistration() method and passes the PublicKeyCredential response from the client (9) and the PublicKeyCredentialCreationOptions from step 7 as the arguments.

  public String registrationFinish(@RequestBody RegistrationFinishRequest finishRequest) {

    RegistrationStartResponse startResponse = this.registrationCache
        .getIfPresent(finishRequest.getRegistrationId());
    this.registrationCache.invalidate(finishRequest.getRegistrationId());

    if (startResponse != null) {
      try {
        RegistrationResult registrationResult = this.relyingParty
            .finishRegistration(FinishRegistrationOptions.builder()
                .request(startResponse.getPublicKeyCredentialCreationOptions())
                .response(finishRequest.getCredential()).build());

AuthController.java

The finishRegistration() method validates the signature and public key and throws a RegistrationFailedException if that fails. If the call succeeds the application stores the new credential into the credentials table (13) (addCredential()) and sends back a success message to the server.

        UserIdentity userIdentity = startResponse.getPublicKeyCredentialCreationOptions()
            .getUser();

        long userId = BytesUtil.bytesToLong(userIdentity.getId().getBytes());
        this.credentialRepository.addCredential(userId,
            registrationResult.getKeyId().getId().getBytes(),
            registrationResult.getPublicKeyCose().getBytes(),
            finishRequest.getCredential().getResponse().getParsedAuthenticatorData()
                .getSignatureCounter());

AuthController.java


There is one small issue we have to solve.

We see that registration requires two round trips from the client to the server (steps 2 and 10), and we insert the user during the first request (step 5). The application does this to get a unique key for the user, but at this point, we don't know if the client successfully finishes the registration process. The client may never send the second POST request (step 10). Maybe the client crashes, or he loses the network connection.

If that happens, we end up with a user in the database that is not correctly set up. For this purpose, I added the field app_user.registration_start. When the application inserts the user, it sets this field to the current date and time and in the second POST request (10) when the registration was successfully validated the application sets this field to NULL.

To complete the picture, I added a cleanup job that regularly deletes users with a registration_start value not equal to NULL and with a timestamp older than 10 minutes.

  @Scheduled(cron = "0 0 * * * *")
  public void doCleanup() {
    // Delete all users with a pending registration older than 10 minutes
    this.dsl.delete(APP_USER)
        .where(APP_USER.REGISTRATION_START.le(LocalDateTime.now().minusMinutes(10)))
        .execute();

CleanupJob.java

Sign-In

The sign-in process looks very similar to the registration process.

sign in

The user enters his username (1), web application sends the user name in a POST request to /assertion/start (2). The server calls the startAssertion() method (3) of the RelyingParty object to create an instance of PublicKeyCredentialRequestOptions. To create this object the startAssertion() method fetches the credentials of the given user from the database and creates a random challenge. Like in the registration process before we have to store this object in a cache because we need to pass it to the finishAssertion() method in step 8.

  @PostMapping("/assertion/start")
  public AssertionStartResponse start(@RequestBody String username) {
    byte[] assertionId = new byte[16];
    this.random.nextBytes(assertionId);

    AssertionStartResponse response = new AssertionStartResponse(
        Base64.getEncoder().encodeToString(assertionId), this.relyingParty
            .startAssertion(StartAssertionOptions.builder().username(username).build()));
    this.assertionCache.put(response.getAssertionId(), response);
    return response;
  }

AuthController.java

The server sends back the object (4), and the client passes it to the navigator.credentials.get() method (5).

    const assertion = await navigator.credentials.get({
      publicKey: getOptions
    });

login.page.ts

The message goes to the authenticator, which signs the random challenge from the server with his private key and sends back a Credential object (6). Unlike the PublicKeyCredential object we get back from the create() method, Credential only contains the challenge and signature without the public key.

The Ionic application sends this object in a POST request to the /assertion/finish endpoint (7). The server then validates and verifies the signature by reading the corresponding public key from the credentials table. If the validation succeeds the application updates the signature counter (credentials.count) in the database. The counter serves as a protection against replay attacks.

  @PostMapping("/assertion/finish")
  public boolean finish(@RequestBody AssertionFinishRequest finishRequest) {

    AssertionStartResponse startResponse = this.assertionCache
        .getIfPresent(finishRequest.getAssertionId());
    this.assertionCache.invalidate(finishRequest.getAssertionId());

    try {
      AssertionResult result = this.relyingParty.finishAssertion(
          FinishAssertionOptions.builder().request(startResponse.getAssertionRequest())
              .response(finishRequest.getCredential()).build());

      if (result.isSuccess()) {
        if (!this.credentialRepository.updateSignatureCount(result)) {
          Application.log.error(
              "Failed to update signature count for user \"{}\", credential \"{}\"",
              result.getUsername(), finishRequest.getCredential().getId());
        }

        long userId = BytesUtil.bytesToLong(result.getUserHandle().getBytes());
        Authentication auth = new UsernamePasswordAuthenticationToken(userId,
            Collections.singleton(new SimpleGrantedAuthority("USER")));
        SecurityContextHolder.getContext().setAuthentication(auth);

        return true;
      }
    }
    catch (AssertionFailedException e) {
      Application.log.error("Assertion failed", e);
    }

    return false;
  }

AuthController.java

Spring Security

In the /assertion/finish handler, we also see how the demo application interacts with Spring Security to log in the user after the signature was successfully validated. For this purpose the application creates a UsernamePasswordAuthenticationToken instance and puts it into the thread-local variable managed by SecurityContextHolder.

        long userId = BytesUtil.bytesToLong(result.getUserHandle().getBytes());
        Authentication auth = new UsernamePasswordAuthenticationToken(userId,
            Collections.singleton(new SimpleGrantedAuthority("USER")));
        SecurityContextHolder.getContext().setAuthentication(auth);

AuthController.java

To complete the Spring Security integration, we need some additional objects and configuration.

First, we need a UserDetails and UserDetailsService implementation, that holds the logged in user and loads it from the database.

Next, I had to create a custom AuthenticationProvider. The default DaoAuthenticationProvider checks the password which we don't have in this scenario.

I copied the code from DaoAuthenticationProvider and removed all the password checks so that this custom implementation only loads the user from the database with the UserDetailsService and checks if the UserDetails contains some credentials. These credentials are not related to the WebAuthn credentials, Spring Security credentials are user roles like administrator or operator.

The final piece of the Spring Security integration is the configuration.

The application uses a session cookie (JSESSIONID) to store the login information. You could certainly also use a token like JWT instead of cookies. The architecture of this part is not affected by WebAuthn, and you can use any technology you already used before.

The configuration disables form and basic login. The /logout endpoint returns a status code instead of the default redirect response. The /registration/* and /assertion/* endpoints we need for registration and sign-in are public, and all other endpoints are protected. When somebody calls a protected endpoint the application sends back the HTTP status code 401 instead of the default redirect response

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // @formatter:off
       http
    .csrf().disable()
      .formLogin().disable()
      .httpBasic().disable()
      .logout()
        .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
        .deleteCookies("JSESSIONID")
      .and()
        .authorizeRequests()
          .antMatchers("/registration/*").permitAll()
          .antMatchers("/assertion/*").permitAll()
          .anyRequest().authenticated()
          .and()
            .exceptionHandling()
              .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
    // @formatter:on
  }

SecurityConfig.java

We don't have to configure our custom CustomAuthenticationProvider and UserDetailsService. Spring Boot automatically registers them as long as these objects are marked as Spring beans with an annotation (for example @Component or @Service).

Testing

WebAuthn, like a lot of the modern browser APIs, is only accessible over a secure context (HTTPS). The only exception is localhost which can access WebAuthn over unencrypted HTTP.

My smartphone can't access the server running on my development computer with localhost. I needed a proper https address.

There are different ways to set this up. My preferred solution is ngrok. To use ngrok, you have to download the ngrok client and start it from a shell with this command

ngrok http -host-header=rewrite 8100

ngrok opens a tunnel from the local computer to their servers and gives you an HTTP and HTTPS URL. These URLs are publicly accessible from the Internet, and all traffic you send to these URLs is tunneled to your computer.

There was a little problem with the ionic serve (and ng serve) commands and ngrok. They did not work properly with the default settings I had to disable host checking.

ng serve --disable-host-check=true --port=8100

You also have to change the relying party options on the server. The id and origins properties have to match the ngrok URL. If for example, you get the ngrok URL https://9a2d5371.ngrok.io the configuration must use the following values.

app.relying-party-id=9a2d5371.ngrok.io
app.relying-party-name=Example Application
app.relying-party-icon=https://9a2d5371.ngrok.io/assets/logo.png
app.relying-party-origins=https://9a2d5371.ngrok.io

With this setup in place, you should be able to test the application from another device.

Note that ngrok has a rate limit in place for unauthenticated users of 20 requests per minute. This was not enough for my Angular application, which did initially about 25-30 requests. So I signed up for a free ngrok account. The free account increases the rate limit to 40 requests per minute, which is sufficient for my tests.

Recovery

One thing you need to think about when you build such a system is a way for the user to recover an account. Because we use physical devices as security key users can lose them or they can break, and without these devices, the user can no longer sign in.

I've implemented a very trivial recovery workflow. After the registration request was successfully validated, the server creates a 16-byte random byte array and stores that in the field app_user.recovery. The server then converts this into a Base58 encoded string and sends it back as a response to a successful registration process.

recover1 recover2

When the user loses his authenticator, he opens the registration recover menu and types in this code. The server then deletes all stored credentials and starts a new registration process. During this workflow, no new user will be created. Instead, the application associates the newly registered authenticator with the existing user.

Add additional authenticators

This system supports multiple authenticators for one user account. This is also a way for the user to prevent losing access to his account if he sets up multiple authenticators. If he loses one security key, he can still log in with another.

I had the additional problem that my Yubico key only speaks USB, and I can't use it on my phone. Because in this system, I don't have an alternative way to log in, I needed a simple way to associated an authenticator to an existing account.

I came up with this solution. Similar to the recovery process, a user can request a special token that allows him to add more authenticators to his account. In the secure area, I added a function that generates a token on the server. The token is stored in the database in the column app_user.registration_add_token and the creation timestamp in the column app_user.registration_add_start. I needed a timestamp because I wanted to limit the lifespan of the token to 10 minutes. The server sends back the token and the web application shows it to the user.

add1 add2

On the other device, the user opens the web application, navigates to the register add authenticator page, enters this code, and the standard registration workflow starts.

Like in the recovery workflow, this does not create a new user; instead, it associates the new authenticator with the existing account, but it does not delete the other credentials as the recovery process does.

Simplify WebAuthn programming

GitHub recently announced WebAuthn support. You can now use security keys for two-factor authentication on GitHub. Together with this announcement they also open-sourced a JavaScript library that simplifies the WebAuthn programming: @github/webauthn-json

As you might have seen in the code before the navigator.credentials.create() and navigator.credentials.get() methods expect certain properties as Uint8array objects. The problem is that we can't send binary data over JSON. So the server sends these values as Base64 encoded strings, and on the client, we have to write code that converts these values into Uint8array objects.

Here the code from the registration process. Notice the base64ToUint8Array method that converts the Base64 encoded strings into Uint8array objects.

  private async createCredentials(response) {
    const createOptions = response.publicKeyCredentialCreationOptions;

    createOptions.challenge = base64ToUint8Array(createOptions.challenge);
    createOptions.user.id = base64ToUint8Array(createOptions.user.id);

    if (createOptions.excludeCredentials) {
      for (const excludeCredential of createOptions.excludeCredentials) {
        excludeCredential.id = base64ToUint8Array(excludeCredential.id);
      }
    }
    // @ts-ignore
    const credential = await navigator.credentials.create({
      publicKey: createOptions
    });

registration.page.ts

The same happens in the other direction when we get back the response from the create() and get() calls. We have to write code that converts the Uint8array objects into Base64 encoded string so that we can send them to the server as JSON.

Because every WebAuthn programmer faces the same problem, GitHub developed @github/webauthn-json, a library that wraps the the create() and get() methods so they can take an object with Base64 encoded properties. The library then converts these values into Uint8array objects and calls the corresponding navigator.credentials method, and it does the reverse conversion with the response.

You install the library with

npm install @github/webauthn-json

The library replaces navigator.credentials.create() with create() and navigator.credentials.get() with get().

Here our code example from before but simplified with the @github/webauthn-json library.

import {create} from '@github/webauthn-json';

registration.page.ts

  private async createCredentials(response) {
    const credential = await create({
      publicKey: response.publicKeyCredentialCreationOptions
    });

registration.page.ts

You find the complete source code of this version of the application in the simplified branch.


You've reached the end of this blog post. I hope you find this information helpful. The source code for the demo application is hosted on GitHub:
https://github.com/ralscha/webauthn-demo/

Online demo: https://demo.rasc.ch/webauthn/

If you find bugs or have a question open an issue or send me a message.