A Passwordless Authentication Architecture Based on a One-Time Code Approach

ThunderOTP — an architectural approach implemented by lightweight cloud-native microservices using Kotlin

Sergio Sánchez Sánchez
Better Programming

--

Foto de Towfiqu barbhuiya en Unsplash

Passwordless authentication has been gaining traction, and the main reason is the lack of security that passwords offer today, as passwords are reused and stolen more and more frequently. The second reason is that passwords have to be increasingly complex, which degrades the user experience.

Security and user experience are among any digital company’s top priorities and are usually in direct conflict. Hence the interest in passwordless authentication, with its promises to offer more security and a better user experience simultaneously.

In this article, I would like to explain the architectural approach applied to implement a passwordless solution that could be used as a standalone authentication mechanism or as part of a more complex MFA solution. I will emphasize the horizontal scaling model applied using Redis Cluster and the native image approach with GraalVM to implement a lightweight and efficient services layer.

What Is Passwordless Authentication?

Passwordless authentication is a means to verify a user’s identity without using a password. Instead, passwordless uses more secure alternatives like possession factors (one-time passwords OTP, registered smartphones) or biometrics (fingerprint, retina scans).

Passwords haven’t been safe for a long time. They are hard to remember and easy to misplace. They are also the number one target of cybercriminals. So much so that 81 percent of breaches involve weak or stolen passwords.

It is important to distinguish clearly between the different methods used to deliver passwordless authentication. Some are more secure, and some provide a better user experience. This architecture has been implemented using the one-time codes (OTPs) solution. They are best known for multi-factor authentication processes, but one-time passwords or codes can also be used as a standalone authentication method.

What Are the Types of Passwordless Authentication?

Passwordless authentication can be achieved in many ways. Here are a few:

  • Biometrics: Physical traits, like fingerprint or retina scans, and behavioral traits, like typing and touch screen dynamics, are used to identify a person uniquely.
  • Possession factors: Authentication via something a user owns or carries. For example, the code generated by a smartphone authenticator app, OTPs received via SMS or a hardware token.
  • Magic links: The user enters their email address, and the system sends them an email. The email contains a link, which grants the user access when clicked.

How Do OTPs Work in Passwordless Authentication?

One-time passwords (or OTPs) are numeric codes linked to a reference. These codes are sent to the user, so only the server and the user can know this code. When the user enters the code in the platform, they are granted access and authenticated.

These codes will be sent to the user’s phone via SMS, Push Notification, or email.

Furthermore, one-time codes are always linked to a unique reference, so there aren’t any chances that different uses overtake the code. OTPs can be limited in time too, which limits the time validity of the code.

MFA vs Passwordless Authentication

This architecture approach can be used as a standalone passwordless authentication service, replacing regular passwords with a more suitable authentication factor. On the other hand, it could also be used as a part of a multi-factor authentication system and another authentication factor to verify a user’s identity.

For example, an MFA system may use fingerprint scanning as the primary authentication factor and SMS OTPs as the secondary.

People sometimes confuse passwordless with MFA or use the two interchangeably. That’s because many traditional, password-based login systems have started using a passwordless technique as their secondary authentication factor.

Passwordless Authentication Benefits

Passwordless authentication provides a variety of functional and business benefits. Specifically, it helps with the following:

  • Improve user experiences — by eliminating password and secrets fatigue and providing unified access to all applications and services.
  • Strengthen security — by eliminating risky password management techniques and reducing credential theft and impersonation
  • Simplify IT operations — by eliminating the need to issue, secure, rotate, reset, and manage passwords.

Main Technologies of Architecture

Before going into more detail about how the project works, I would like to show you a brief review of the technologies applied in this architecture:

Redis Cluster

Redis is an open-source, in-memory data structure store that builds caches and key-value NoSQL databases. Redis Cluster is a special version of Redis that helps improve the scalability and availability of your Redis database. More specifically, it is a distributed implementation of Redis that automatically shards (i.e., partitions) data across multiple Redis nodes.

Redis Cluster helps improve the scalability, availability, and fault-tolerance of Redis databases beyond the base version of Redis. The features of Redis Cluster include:

  • Scalability: Redis Cluster can scale to a maximum limit of 1,000 nodes.
  • Availability: There are two conditions for a Redis cluster to continue operating — most primary nodes must be reachable, and any unreachable primary node must have a backup secondary node. This is a generous policy that helps improve the availability of your Redis database.
  • Write safety: Redis Cluster attempts to behave in a write-safe manner. It will try to preserve the writes from any client connected to the majority of primary nodes in the cluster.

HAProxy

HAProxy (High Availability Proxy) is a TCP/HTTP load balancer and proxy server that allows a webserver to spread incoming requests across multiple endpoints. This is useful in cases where too many concurrent connections over-saturate the capability of a single server. Instead of a client connecting to a single server that processes all of the requests, the client will connect to an HAProxy instance, which will use a reverse proxy to forward the request to one of the available endpoints based on a load-balancing algorithm.

Ktor Framework

Ktor is an asynchronous framework for creating microservices, web applications, and more. Written in Kotlin from the ground up.

GraalVM High-Performance JDK Distribution

GraalVM is a Java VM and JDK based on HotSpot/OpenJDK, implemented in Java. It supports additional programming languages and execution modes, like ahead-of-time compilation of Java applications for fast startup and low memory footprint.

Netty Server

Netty is an NIO client server framework that enables quick and easy development of network applications such as protocol servers and clients. It greatly simplifies and streamlines network programming, such as TCP and UDP socket servers.

Twilio SMS API

Twilio’s Programmable SMS API helps you add robust messaging capabilities to your applications.

Using this REST API, you can send and receive SMS messages, track the delivery of sent messages, Schedule SMS messages to send later, and retrieve and modify message history.

Firebase Cloud Messaging

Firebase Cloud Messaging (FCM) is a cross-platform messaging solution that lets you reliably send messages at no cost.

SendGrid

SendGrid is a cloud-based SMTP provider that allows you to send emails without maintaining email servers. SendGrid manages all of the technical details, from scaling the infrastructure to ISP outreach and reputation monitoring to whitelist services and real-time analytics.

Foto de Yura Fresh en Unsplash

Architecture Overview

In this section, I would like to go into more detail about the operation of architecture, although, first, it would be interesting to know the main objectives that I had in mind when I proposed this design:

  • Faster startup time: Building ahead-of-time compiled microservices that start in milliseconds and deliver peak performance with no warmup.
  • Low resource usage: Building ahead-of-time compiled microservices that use only a fraction of the resources required by the JVM means they cost less to run and improve utilization.
  • Small container image: Trying to compact native executables in lightweight container images for more secure, faster, and efficient deployments.
  • Minimize vulnerability: Trying to reduce the attack surface area using a native image by removing all unused classes, methods, and fields from your application and libraries while making reverse engineering difficult by converting Java bytecode into native machine code.
  • Using a versatile and efficient storage system based on RAM memory with the possibility of persisting data in secondary storage for recovery from failures offers high availability and scalability.
  • Implement a centralized configuration repository. The microservices will download the latest version of the stored configuration.
  • Including a load balancer based on the Round Robin algorithm to minimize response times, improve service performance and avoid saturation.

Taking into account all the above, the architecture design is as follows:

Passwordless architecture

This architecture can be used as a standalone authentication service or as part of a more complex MFA solution. Clients will request a one-time code or password to verify their identity. They will indicate the delivery service by which they wish to receive the token (email, SMS, and push notifications are the options currently available).

The system will generate an OTP token applying rules related to the specified delivery service. The generated token will persist in the Redis cluster with the TTL associated with the type of service and will return to the client a unique operation identifier that can be used for subsequent validation operations — cancel or resend.

The system allows up to three resubmissions for an operation identifier. If the client provides an incorrect OTP at the validation time, it will be eliminated, and a new OTP must be requested. The system carries out various checks to prevent misuse of the service.

As you can see in the picture above, in this architecture, we can highlight several differentiated parts:

High-Performance TCP/HTTP Load Balancer

Load balancing ensures our services’ availability, uptime, and performance during traffic spikes. Load balancing aims to achieve optimal resource usage, maximize service stability, and prevent individual components from overloading. It divides the amount of work a service has to do between two or more services, allowing more work to be done in the same amount of time.

Below we will show you how HAProxy has been implemented in this architecture approach using Round Robin. First, though, let’s go over the Round Robin algorithm that HAProxy offers.

The Round Robin algorithm is the most commonly implemented. It uses each service behind the load balancer in turns, according to their weights. It’s also probably the smoothest and most fair algorithm, as the server’s processing time stays equally distributed. As a dynamic algorithm, Round Robin allows server weights to be adjusted on the go.

The load balancer configuration is done through the haproxy.cfg file where we can highlight two differentiated sections: backends and frontends.

A backend is a set of services that receive forwarded requests. Backends are defined in the backend section of the HAProxy configuration. In its most basic form, a backend can be defined by:

  • Which load balance algorithm to use
  • A list of servers and ports

A backend can contain one or many servers in it. Generally speaking, adding more servers to your backend will increase your potential load capacity by spreading the load over multiple servers. Increased reliability is also achieved in case some of your backend servers become unavailable.

backend otp_services
mode http
option forwardfor
balance roundrobin
server otp_service_1 otp_service_1:8080 check
server otp_service_2 otp_service_2:8080 check
server otp_service_3 otp_service_3:8080 check
server otp_service_4 otp_service_4:8080 check
server otp_service_5 otp_service_5:8080 check
server otp_service_6 otp_service_6:8080 check

balance roundrobin line specifies the load balancing algorithm

mode http specifies that layer 7 proxying will be used

The check option at the end of the server directives specifies that health checks should be performed on those backend servers.

A frontend defines how requests should be forwarded to backends. Frontends are defined in the frontend section of the HAProxy configuration. Their definitions are composed of the following components:

  • A set of IP addresses and ports (e.g., 10.1.1.7:80, *:443, etc.)
  • ACLs
  • use_backend rules, which define which backends to use depending on which ACL conditions are matched, and/or a default_backend rule that handles every other case
frontend balancer
bind 0.0.0.0:9090
mode http
default_backend otp_services

HAProxy uses health checks to determine if a backend server can process requests. This avoids manually removing a server from the backend if it becomes unavailable. The default health check is to try to establish a TCP connection to the server.

If a server fails a health check and is unable to serve requests, it is automatically disabled in the backend, and traffic will not be forwarded to it until it becomes healthy again. If all servers in a backend fail, the service will become unavailable until at least one of those backend servers becomes healthy again.

Below you can view the complete configuration file:

global
stats socket /var/run/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
log stdout format raw local0 info
defaults
mode http
timeout client 10s
timeout connect 5s
timeout server 10s
timeout http-request 10s
log global
frontend stats
bind *:8404
stats enable
stats uri /
stats refresh 10s
frontend balancer
bind 0.0.0.0:9090
mode http
default_backend otp_services
backend otp_services
mode http
option forwardfor
balance roundrobin
server otp_service_1 otp_service_1:8080 check
server otp_service_2 otp_service_2:8080 check
server otp_service_3 otp_service_3:8080 check
server otp_service_4 otp_service_4:8080 check
server otp_service_5 otp_service_5:8080 check
server otp_service_6 otp_service_6:8080 check

We can see a graphic representation of server status and uptime by visiting /haproxy?status as shown in the image below:

HAProxy load balance stats

Lightweight Cloud-Native Microservices Powered by GraalVM Native Images

New modern applications are being built for the cloud as distributed systems developed using microservices. The event-driven, asynchronous, and reactive design should scale quickly and efficiently. HotSpot JVM and JIT compiler is not an ideal environment to support such use cases and develop cloud-native solutions because of how the compiler works. It takes a lot of memory and CPU.

For the customers to take advantage of the full potential of the cloud’s pay-per-use model, it is important to build applications with a smaller footprint so that they don’t consume too much RAM and CPU. A small footprint means fewer resources and, therefore, less cost to run such applications, which is an important metric for many organizations.

Along with a smaller footprint, it is also important for applications to boot up to start handling incoming requests quickly. Otherwise, a good amount of traffic will be lost when the application boots up.

Microservices implemented by Java HotSpot VM guarantees that only certain sections of the code executed frequently will be compile to machine code, therefore the performance of an application depends primarily on how fast those sections of code are executed. These critical sections are known as the hot spots of the application; Hence the name Java HotSpot VM.

While the JVM interprets bytecode, the JIT analyses execution and dynamically compiles frequently executed bytecode to machine code. This prevents the JVM from having to interpret the same bytecode over and over.

The Graal VM implementation provides a better JIT compiler implementation, with further optimizations. The Graal compiler also provides an Ahead Of Time (AOT) Graal AOT compilation option to build native images that can run standalone with embedded VMs.

Using GraalVM Native Image technology we can compile the services to native code ahead of time in a way in which the resulting binary does not depend on the JVM for the execution. This executable can be placed as a standalone application in a container and started really, really fast.

As you can see in the following image, a multi-stage docker build is used to generate a lightweight distroless-based image that will only execute the service binary generated by GraalVM Native image.

# ---- Building phase ----
FROM ghcr.io/graalvm/native-image:22.2.0 AS builder
RUN mkdir -p /tmp/export/lib64 \
&& cp /usr/lib64/libstdc++.so.6.0.25 /tmp/export/lib64/libstdc++.so.6 \
&& cp /usr/lib64/libz.so.1 /tmp/export/lib64/libz.so.1
COPY --chown=gradle:gradle .. /home/gradle/src
WORKDIR /home/gradle/src
RUN ./gradlew clean nativeCompile --no-daemon --debug
# ---- Release ----
FROM gcr.io/distroless/base AS release
COPY --from=builder /tmp/export/lib64 /lib64
COPY --from=builder /home/gradle/src/build/native/nativeCompile/otp_graalvm_service app
ENV LD_LIBRARY_PATH /lib64
EXPOSE 8080
ENTRYPOINT ["/app"]

It’s necessary take into account that native image tries to resolve the target elements ( such us methods, class …) through a static analysis that detects all these calls. Nevertheless, this analysis couldn’t detect all elements that are necessary at runtime. Therefore, we must specify them using a manual configuration.

In order to make preparing these configuration files easier and more convenient, GraalVM provides an agent that tracks all usages of dynamic features of an execution on a regular Java VM. I used it during the development phase through this command:

java -agentlib:native-image-agent=config-merge-dir=./config -jar otp_service.jar

During execution, the agent interfaces with the Java VM to intercept all calls that look up classes, methods, fields, resources, or request proxy accesses. The agent then generates the files jni-config.json, reflect-config.json, proxy-config.json, and resource-config.json in the specified output directory. The generated files are standalone configuration files in JSON format which contain all intercepted dynamic accesses.

It can be necessary to run the target application more than once with different inputs to trigger separate execution paths for a better coverage of dynamic accesses. The agent supports this with the config-merge-dir option which adds the intercepted accesses to an existing set of configuration files

As it is necessary to run the target application more than once with different inputs to trigger separate execution paths for a better coverage of dynamic accesses. The agent supports this with the config-merge-dir option which adds the intercepted accesses to an existing set of configuration files as I used in the command above.

These files need to be provided at native Gradle image build time via the ReflectionConfigurationFile and ResourceConfigurationFiles options.

As a result of this we have light services that only consume about 20M of RAM compared to 130M using a HotSpot-based image as you can see in the following image of the container statistics:

Native Microservice Stats
HotSpot Microservice Stats

Asynchronous Services Based on Ktor Framework

Ktor is an asynchronous web framework written in and designed for Kotlin, leveraging coroutines and allowing you to write asynchronous code without having to manage any threads yourself.

Here is a bit more background information on Ktor. It is backed by Jetbrains, who are also the creators of Kotlin itself. Who better to make a Kotlin web framework than the people that work on the language.

I have taken into account several factors in selecting Ktor as the preferred alternative over other frameworks some of them are summarized below:

Dependency injection by Koin

Ktor is considered a microframework, and therefore, it lacks several functionalities and utilities that we would find right out of the box in more sophisticated frameworks such as Spring Framework. One of them is not having an IoC container. Despite this, we can integrate very simply external libraries such as Kodein or Koin. In this case, we have opted for koin, the smart Kotlin dependency injection library

Defining module dependencies is really easy in Koin, here is an excerpt from the Jedis client declaration that allows us to communicate with the Redis Cluster:

val redisModule = module {
single {
JedisCluster(hashSetOf(*get<RedisClusterConfig>().nodes.map {
HostAndPort(it.host, it.port) }.toTypedArray()) )
}
}

Plugin architecture

Ktor allows you to expand its capabilities by connecting new plugins that allow us to have more features when processing information or have support for other response formats.

Plugins such as ktor-server-request-validation have been used to validate the data received in the requests, the plugin ktor-server-status-pages-jvm to adequately manage the internal errors generated during the processing of the requests.

Easily Deployed

A Ktor server application could be easily delivery as a self-contained package, we only need to create a server first. Server configuration can include different settings: a server engine (such as Netty, Jetty, etc.), various engine-specific options, host and port values, and so on.

The embeddedServer function is a simple way to configure server parameters in code and quickly run an application.

fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureKoin()
configureAdministration()
configureSerialization()
configureValidation()
configureMonitoring()
configureAuthentication()
configureRouting()
}.start(wait = true)
}

Easy To Expand and Cover a Wide Range of Needs

Ktor is a microframework with extension possibilities, it is possible to add extra functionalities that we cannot cover with the plugins belonging to the framework ecosystem.

It is really easy to create an additional AuthenticationProvider to validate the clients that consume the services of the platform.

Redis Cluster for Scalability and High Availability

Redis Cluster is a built-in Redis feature that offers automatic sharding, replication, and high availability. It has the ability to automatically split our OTPs dataset among multiple nodes and to continue operations when a subset of the nodes is experiencing failures or are unable to communicate with the rest of the cluster.

Our Redis cluster will be made up of eight redis nodes, four nodes will act as primarys and the other four will be secondarys. With the Redis Insight tool we can explore the configuration of our architecture.

Redis cluster configuration overview

Using this feature I’ve been able to accomplish the following goals:

  • High performance and linear scalability
  • An acceptable degree of write safety
  • It is able to survive partitions where the majority of the primary nodes are reachable and there is at least one reachable secondary for every primary node that is no longer reachable

For each of the nodes in the configuration, we will create a redis.conf file, enabling the configuration in cluster mode, apart from other additional configurations, which are also necessary.

port 6379
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 192.168.1.39
cluster-announce-port 6379
cluster-announce-bus-port 16379
appendonly yes
loadmodule /usr/lib/redis/modules/rejson.so

To enable cluster mode, it is necessary setcluster-enabled directive to yes. Every instance also contains the path of a file where the configuration for this node is stored, which by default is nodes.conf.

All Redis nodes will be based on the redislabs/rejson image provided by the rejson module to store and operate on content in native JSON format. in the configuration file, it will be necessary to explicitly load that module using the loadmodule directive.

Apart from that it will be necessary to configure the Redis TCP port and the Cluster Bus Port to allow communications inside and outside the cluster configuration.

# Redis Node 1
redis-node-1:
image: 'redislabs/rejson:latest'
container_name: redis-node-1
command: redis-server /usr/local/etc/redis/redis.conf
volumes:
- ./data:/var/lib/redis
- ./conf/node_1/redis.conf:/usr/local/etc/redis/redis.conf
ports:
- 6379:6379
- 16379:16379
networks:
redis_cluster_network:
ipv4_address: 192.168.0.30

Every Redis Cluster node requires two open TCP connections: a Redis TCP port used to serve clients, e.g., 6379, and second port known as the cluster bus port. By default, the cluster bus port is set by adding 10000 to the data port (e.g., 16379). However, you can override this in the cluster-port configuration.

Cluster bus is a node-to-node communication channel that uses a binary protocol, which is more suited to exchanging information between nodes due to little bandwidth and processing time. Nodes use the cluster bus for failure detection, configuration updates, failover authorization, and so forth. Clients should never try to communicate with the cluster bus port, but rather use the Redis command port.

In order to facilitate Redis Cluster deployment, I have implemented a Ruby Rake task to unify the process of getting the Docker Compose deployment up and running and creating the cluster using the Redis CLI.

desc "Start and configure Cluster Containers"
task :start => [ :check_docker_task, :login, :check_deployment_file ] do
puts "Start Cluster Containers"
puts `docker-compose -f ./redis_cluster/docker-compose.yml up -d`
puts `docker run -it --rm --network=redis_cluster_redis_cluster_network redislabs/rejson:latest redis-cli --cluster create 192.168.0.30:6379 192.168.0.35:6380 192.168.0.40:6381 192.168.0.45:6382 192.168.0.50:6383 192.168.0.55:6384 192.168.0.60:6385 192.168.0.65:6386 --cluster-replicas 1 --cluster-yes`
end

The command used here is create, since we want to create a new cluster. The option --cluster-replicas 1 means that we want a replica for every primary created.

The other arguments are the list of addresses of the instances I want to use to create the new cluster.

redis-cli will propose a configuration. Accept the proposed configuration by typing yes. The cluster will be configured and joined, which means that instances will be bootstrapped into talking with each other. Finally, if everything has gone well, you'll see a message like this:

[OK] All 16384 slots covered

This means that there is at least one primary instance serving each of the 16384 available slots.

Through the real-time view of RedisInsight, we can check the information associated with each key and even manipulate it. The following image shows the JSON representation of a generated OTP model linked to the operation code 2dc15cf8–1761–48c9-b15d-cd348460a218.

JSON representation of a generated OTP model

Other native redis data structures are also used to implement access control lists. The following image shows the content of the authorized_clients key, which contains the identifiers of the clients authorized to use the services.

Authorized clients

OTP Delivery Services

We have reached the last point of the architecture, at this point I would like to detail the services used to deliver the OTPs safely and effectively. Each service has different configurations, the OTPs that will be delivered via email have a greater complexity and duration than those delivered via SMS, the generator is flexible in this aspect, all these aspects can be adjusted in the application.yml file shown below:

It is also possible to customize the message sent in each one of the senders. In each sender, it will be necessary to configure the service keys to be able to communicate with the corresponding third-party APIs.

The choice of the sender is made at the time of generation of the OTP token. The generate endpoint call supports the field type and destination.

You also could pass a bunch of properties if you want to customize the text message through the “properties” property.

curl --location --request POST 'http://localhost:9090/otp/v1/generate' \--header 'ClientId: /0GiNd8HKN3PKjOedxi9g3+7oz14gLLLg4fIRGHHSTc=' \--header 'Content-Type: application/json' \--data-raw '{
"type": "SMS",
"destination": "+34677112233",
"properties": {}
}'

In the example above we try to generate an OTP token that will be delivered via SMS to the destination. It is necessary to take into account the use of the ClientId header that allows us to identify ourselves as an authorized client to use the services.

The previous request will activate the execution of the OTP SMS Sender shown below:

OTP SMS sender

This Sender will use the Twilio library to send the SMS with a customized text message along with the OTP code that we have previously generated. If an error occurs during the send, we will report an OTPSenderFailedException exception which will discard the OTP code and abort the process.

The approach would be similar if we wanted to use the email service to deliver the OTP code, simply, we would have to configure the type of sender to “MAIL” and indicate the email address in the destination.

curl --location --request POST 'http://localhost:9090/otp/v1/generate' \--header 'ClientId: /0GiNd8HKN3PKjOedxi9g3+7oz14gLLLg4fIRGHHSTc=' \--header 'Content-Type: application/json' \--data-raw '{
"type": "MAIL",
"destination": "testmail@yopmail.com",
"properties": {}
}'

In this way, we will activate the execution of the OTP Mail Sender that will use the SendGrid library to build and send the email to the indicated destination. During the construction of the email, it will indicate the desired template to represent the content of the message, these templates can be made using the WYSWYG tools provided by SendGrid.

OTP mail sender

Used Technology

  • Redis Cluster Architecture (rejson module enabled)
  • HAProxy Load Balancer
  • Ktor Framework
  • Netty Server
  • GraalVM high-performance JDK distribution
  • Twilio Java Helper Library
  • Sendgrid Java Helper Library
  • Firebase Cloud Messaging
  • Jedis (A redis Java client designed for performance and ease of use )
  • Hoplite (A boilerplate-free Kotlin config library for loading configuration files as data classes )

This is it. I have really enjoyed developing and documenting this little project. Thanks for reading it. I hope this is the first of many.

If you are interested in the complete code, here is the link to the public repository:

--

--

Mobile Developer (Android, IOS, Flutter, Ionic) and Backend Developer (Spring, J2EE, Laravel, NodeJS). Computer Security Enthusiast.