Building an OTP Authorizer

February 12, 2023

Why did the password go to the therapist?
It was feeling insecure.

Introduction

This article explores the implementation of a Simple OTP microservice, a secure method for user authorization. The Simple OTP microservice verifies a user's email or phone number through the use of One-Time Passcodes (OTP) and then generates a crypto-signed JSON Web Token (JWT) upon successful verification. The JWT token can then be used to access authorized API endpoints in a secure manner.

In this article, we delve into the details of the Simple OTP microservice and its implementation, covering the storage of OTP secrets, generation of OTP codes, and usage of JWT tokens. Additionally, we will discuss potential improvements to enhance the security and functionality of the Simple OTP microservice.

Here you can find a live OTP demo and source code of OTP service to follow along the article.

How Simple OTP Works

OTP, or One-Time Passwords, are a simple and secure way of authorizing users. In this section, we will walk through the process of how Simple OTP, our demo microservice, works.

Simple OTP generates and validates unique one-time passwords for each user, based on a secret key stored on the server. This key is associated with the user's email or phone number, and generates a sequence of codes. Each time a user attempts to log in, the server generates the next code in the sequence and compares it to the code entered by the user. If the codes match, the user is authorized.

To ensure the security of the process, the secret key is never stored as plaintext. Instead, it is hashed and stored in a secure database, such as AWS DynamoDB. Additionally, the server generates a new code for each login attempt, invalidating any previous codes in the process. This makes it nearly impossible for anyone to predict or reuse the codes.

When a user successfully verifies their email or phone number using the OTP, the Simple OTP microservice generates a JSON Web Token (JWT) with a sub claim pointing to the verified email or phone. This token can then be securely stored on the client side and used to access authorized API endpoints.

In our demo, we use the HS256 algorithm for signing the JWT. However, this is just a simple example and is only suitable for accessing our own API, as the signature can only be verified by knowing the HS256 secret. A potential improvement would be to switch to RS256 and expose public keys, allowing the Simple OTP microservice to become a third-party authorizer.

In the next section, we will go into more detail about the code snippets and the algorithms used to build the Simple OTP microservice.

Technical Implementation Details

In this section, we will delve into the technical details of the Simple OTP microservice and explain how it works. We'll start by discussing the generation of OTP secrets and codes.

Generation of OTP Secrets and Codes

The first step in the Simple OTP microservice is the generation of OTP secrets and codes. An OTP secret is a random string of characters that is used as a seed for generating OTP codes. Each OTP secret is associated with a unique email or phone number.

The OTP codes are generated by incrementing a counter and using it as an input to a secure one-time password (OTP) algorithm. This means that each OTP code is unique and can only be used once. The OTP codes generated for a given OTP secret are in a sequence, so that each code is invalidated by the next code generated for the same OTP secret.

We will store the OTP secrets a secure location, such as AWS DynamoDB, which automatically encrypts data at rest. This ensures that the OTP secrets are protected from unauthorized access. Additionally, it automatically deletes expired OTP secrets, which prevents them from being used to generate OTP codes.

Explanation of the Algorithm Used for Generating OTP Codes

The Simple OTP microservice uses pyotp – a python library for generating OTP codes. Specifically, we'll be using HOTP – HMAC-based One-Time Password. This algorithm is specified in RFC 4226, is widely used for generating OTP codes and is pretty secure, fast and efficient, which makes it ideal for use in a microservice.

In order to generate a unique OTP code it takes a secret (kinda random password for this account) and a counter, that is automatically incremented on every request. Once code matches, user is authorized, and record deleted from database (alternatively we could just increment the counter, but for the sake of this demo I didn't want to store stale records in DB).

This is enough to guarantee that it's impossible:

  • to reuse the same OTP code
  • predict next one
  • use previous unused one

Code Snippets Showing How the OTP Secrets and Codes are Generated

Here is a code snippet in Python that shows how the OTP secrets and codes are generated in the Simple OTP microservice:

import pyotp

def generate_otp_secret():
    return pyotp.random_base32()

def generate_otp_code(secret, counter):
    return pyotp.hotp.HOTP(secret).at(counter)

def verify_otp_code(secret, counter, code):
    return pyotp.hotp.HOTP(secret).verify(code, counter)

In this code snippet, the generate_otp_code function takes an OTP secret and a counter as inputs and outputs an OTP code. The generate_otp_secret function generates a new OTP secret.

These code snippets give a brief overview of how the OTP secrets and codes are generated in the Simple OTP microservice. In the next section, we'll discuss how the OTP codes are verified and used to authorize users.

Storing OTP Secrets:

The OTP secrets are stored in a DynamoDB table, which is a managed NoSQL database provided by AWS. The secrets are stored as plaintext, with the pynamo ORM providing a convenient interface for storing and retrieving them.

Explanation of how OTP secrets are stored in DynamoDB

We'll store OTP secrets in AWS DynamoDB, Amazon's managed database service. For each email or phone number we'll need to store their own secret string and current counter value. We'll also specify expiration timestamp, this will instruct DynamoDB to automatically delete expired records. For the sake of elegance, we'll use pynamo – a python ORM for DynamoDB.

from pynamodb.models import Model
from pynamodb.attributes import UnicodeAttribute, NumberAttribute

class OTP(Model):
    class Meta:
        table_name = "my-dynamodb-table-name"
    id = UnicodeAttribute(hash_key=True)  # email or phone number
    otp_secret = UnicodeAttribute()
    counter = NumberAttribute()
    expires = NumberAttribute()

# create a new OTP secret
otp = OTP(
    id="[email protected]",
    otp_secret=pyotp.random_base32(),
    counter=0,  # todo: use a random number?
    expires=int(time.time() + 300)  # expires in 5 minutes
)
otp.save()

# retrieve an OTP secret
otp = OTP.get("[email protected]")
otp_secret = otp.otp_secret
counter = otp.counter

# increment the counter
otp.counter += 1
otp.save()

Verifying OTP Codes

The verification process involves several steps:

  1. Retrieving the OTP secret from the database based on the user's email or phone number.
  2. Incrementing the counter stored in the database.
  3. Using the pyotp.HOTP class to verify the code provided by the user against the OTP secret and the incremented counter.
  4. If the code is valid, deleting the OTP record from the database.

Here's a code snippet that demonstrates the OTP code verification process:

def verify_otp_code(email_or_phone, code):
    try:
        otp = OTP.get(email_or_phone)
        hotp = pyotp.HOTP(otp.otp_secret)
        if hotp.verify(code, otp.counter):
            otp.delete()
            return True
        return False
    except OTP.DoesNotExist:
        return False

JWT Tokens

We'll be giving a cryptographically signed JWT token to a user who successfully verifies their OTP code. It's a secure way of letting the user prove their identity and permissions to the API. JSON Web Tokens (JWT) are a compact and self-contained way of transmitting information between parties. They are typically used for authentication and authorization purposes. JWT tokens are composed of three parts: header, payload, and signature. The header describes the token's structure and algorithm used for signing. The payload contains the claims, which are statements about an the user and additional data. The signature is used to verify that the sender of the JWT is who it claims to be and to ensure that the message wasn't changed along the way.

In Simple OTP, JWT tokens are generated and used as follows:

  • When an OTP code is successfully verified, a JWT token is generated and sent back to the client.
  • The JWT token is generated using the PyJWT library in Python and is signed with an HS256 algorithm.
  • The sub claim in the payload of the JWT token contains the verified email or phone number.
  • The client can then use the JWT token to access authorized API endpoints.

Here's a code snippet showing how the JWT token is generated:

import jwt

def generate_jwt_token(email_or_phone):
    payload = {
        "iss": "https://otp.potapov.dev/",  # the party who issued the token
        "aud": "https://api.potapov.dev/",  # the party where token will be used
        "sub": email_or_phone, # identity of the user
        'iat': datetime.datetime.utcnow(),  # issue timestamp
        "exp": (
            datetime.datetime.utcnow() + 
            datetime.timedelta(days=1)
        )  # expiration timestamp
    }
    jwt_token = jwt.encode(payload, JWT_SECRET, algorithm="HS256").decode("utf-8")
    return jwt_token

And here's an example showing how the JWT token can be used to guard a hypothetical authorized API endpoint:

def validate_jwt_token(token):
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
        return payload
    except jwt.PyJWTError:
        return None

    
@app.route("/api/authorized", methods=["GET"])
def authorized_api():
    jwt_token = request.headers.get("Authorization", None)
    if not jwt_token:
        return "Unauthorized", 401

    jwt_payload = validate_jwt_token(jwt_token)
    if not jwt_payload:
        return "Unauthorized", 401
    return f"Authorized for user: {jwt_payload['sub']}", 200

At the same time the token can be decoded and used on the client side, so it's not a good idea to store sensitive information in the payload.

import jwt_decode from "jwt-decode";

const userId = jwt_decode(token).sub;

Potential Improvements

One of the ways to improve the security of the Simple OTP system is by switching from the current HS256 algorithm to the RS256 algorithm. HS256 (HMAC with SHA-256) is a symmetric algorithm, which means that the same secret key is used for both signing and verifying the JWT token. Thus, we can verify the token only on the server-side, which knows the JWT_SECRET string.

RS256 (RSA with SHA-256), on the other hand, is an asymmetric algorithm that uses two keys: a private key for signing and a public key for verification. This effectively makes this Simple OTP microservice a third-party authorizer, it could be used by other applications for authentication purposes.

This would allow for more secure communication between the applications, as the private key would be kept secure on the Simple OTP server.

Here is an example of how the JWT token generation code could look like with RS256:

import jwt
from jwt.algorithms import RSAAlgorithm

private_key = open("private.key", "r").read()
public_key = open("public.key", "r").read()

# return JWT token
token = jwt.encode(
    {
        'iss': 'https://otp.potapov.dev/',
        'aud': 'https://api.potapov.dev/',
        'sub': sub,
        'iat': datetime.datetime.utcnow(),
        'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1)
    },
    private_key,
    algorithm=RSAAlgorithm.RS256
)

And here is an example of how the JWT token verification code could look like:

import jwt

def verify_jwt(token):
    try:
        decoded = jwt.decode(token, public_key, algorithms=["RS256"])
        return decoded
    except jwt.exceptions.InvalidSignatureError:
        return None

Another potential improvement is to increase the security of the JWT tokens. This can be achieved by adding more information to the claims, such as the client IP address and user agent, which could then be verified during the token verification process.

By incorporating these potential improvements, the Simple OTP system can become more secure and suitable for use in a wider range of applications.

Conclusion

Simple OTP is a basic implementation of a one-time password (OTP) system that helps secure user authorization by using time-based or counter-based OTP codes. It uses the pyotp library to generate and verify OTP codes, pynamo as an ORM to store OTP secrets in Amazon DynamoDB, and JSON Web Tokens (JWT) to securely transmit authentication information between the client and the server.

The technical implementation details, including the generation of OTP secrets and codes, the storage of OTP secrets, and the verification of OTP codes, have been explained and demonstrated with code snippets. Additionally, the article discussed potential improvements that could be made to Simple OTP, including switching from HS256 to RS256 for JWT signing and increasing the security of JWT tokens.

In conclusion, Simple OTP is an important tool for secure user authorization and authentication. It helps to ensure that only authorized users can access sensitive information, and helps prevent unauthorized access and malicious attacks. We hope that this article has provided a helpful overview of Simple OTP and its implementation, and that it will serve as a valuable resource for those looking to improve the security of their user authorization systems. For those looking for more information on OTP systems and secure user authentication, we recommend exploring the references and resources provided in this article.


© 2023, built by Arseniy Potapov with Gatsby