Mobile number-based login is now a common authentication method for mobile applications. This way of user handling is mostly used in chat and financial apps.
In this blog we are looking into implementing a simple mobile number OTP based authentication using REST APIs and Json Web Tokens where the user will be able to login only using their mobile number. We will demonstrate the implementation using .NET Core Web Api and C#. However, it's important to note that the insights provided in this blog can be applied to any programming language or framework.
Understanding the flow.
In a typical mobile phone authenticated app, the user flow look like this. User will type in their phone number, gets OTP via SMS, type in or auto detect OTP and boom. verified🎉.
Now let's include server into the story and take a look at the flow.
The diagram explains it well I guess but for clarity let's take a look. Here, everything starts with the user request for the OTP, request body will be having a phone number, In the server after validating the phone number, we will generate a random OTP and ask our SMS provider to send it to the given phone. After a confirmation from our provider, we can prepare a token (we will get to it. yes, it is JWE😄). This token will then be returned to the user. The token will contain phone number and the assigned OTP but it will be encrypted.
Now the user will be waiting for the OTP SMS. After receiving the SMS, user will type the OTP or use autofill and ask the server to verify. User has to send the typed in OTP along with the Token issued earlier. Server can then read OTP from the token and match it with the provided OTP. Now the user proved his phone number to the server.
Server will take the phone number from the token and issues an Access Token to the user. Using this access token, user will be able to prove their identity to the server in the future.
About those tokens.
NB: If you know about JWT, you can skip this part.
Tokens are like concert tickets with your name on it. Only they can make or verify it, but everyone can see that you have a concert ticket with your name. So, when you show up at the concert with the ticket, they will know it is you.
For our tokens we use Json Web Token, JWT in short. Like their name, it can store Json data. A server can create and verify it, with a key. But the Json data can be read by anyone.
JWT - Json Web Token
They are called 'jot token' by the way...
A JWT token is a set of 3 strings separated by dots. Just like above diagram. It has 3 parts obviously... a header part where we have details about encryption algorithm. then a payload section where we can write our Json data like the username and id, then the 3rd part another random string we call signature. This is generated according to our algorithm and payload using a secret key. With that secret key, we can verify the signature and there by the data in the token.
You can check out more about Jots in this cool website: JSON Web Tokens - jwt.io
JWE - Json Web Encryption
JWE tokens are encrypted jot tokens. No one can read your data from a JWE token. The idea is that the a website or an app can use tokens issued by someone else. So when these token has personal data, they can encrypt it to hide those information. We will be using JWE for OTP token and JWT for Access token.
Implementation
Let's create the controller first and see the endpoints.
Controllers - Endpoints.
[Route("Auth")]
public class AuthenticationController : ControllerBase
{
[HttpPost]
[Route("RequestOtp")]
public string RequestOtp([FromBody] OtpRequestDto otpRequestDto)
{
// Get the phone number from DTO
// Validate the phone
// Create a random otp
// Send SMS
// Create OTP-Token
// Return OTP-Token
}
[HttpPost]
[Route("VerifyOtp")]
public string VerifyOtp([FromBody] OtpVerifyDto otpVerifyDto)
{
// Get the otp and token from the DTO
// Validate the otp using OTP-Token
// optional - you can use user id for identity from this point
// so for that, get user id from db or create a user and use that id
// we will continue with phone number for simplicity.
// create an AccessToken for the user with phone number
// return the AccessToken.
}
}
We will finish them later. Now let's take a look at the services.
Services Implementation
JwtService.
using System.IdentityModel.Tokens.Jwt;
using System.Security.Authentication;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using OtpAuth.Interfaces;
namespace OtpAuth.Services;
public class JwtService(IConfiguration configuration) : IJwtService
{
private readonly string _key = configuration?["JwtSettings:Key"] ?? throw new ArgumentNullException();
private readonly string _issuer = configuration?["JwtSettings:Issuer"] ?? throw new ArgumentNullException();
private readonly string _audience = configuration?["JwtSettings:Audience"] ?? throw new ArgumentNullException();
/// <inheritdoc />
public string GenerateToken(IEnumerable<Claim> claims, TimeSpan expiry, bool useJwe = false)
{
var securityKey = new SymmetricSecurityKey(Convert.FromBase64String(_key));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
SigningCredentials = signingCredentials,
Issuer = _issuer,
Audience = _audience,
Expires = DateTime.UtcNow + expiry
};
if (useJwe)
{
tokenDescriptor.EncryptingCredentials = new EncryptingCredentials(securityKey, SecurityAlgorithms.Aes256KW, SecurityAlgorithms.Aes256CbcHmacSha512);
}
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
/// <inheritdoc />
public ClaimsPrincipal GetPrincipalFromToken(string token, bool isJwe = false)
{
if (string.IsNullOrWhiteSpace(token))
{
throw new ArgumentNullException(nameof(token));
}
var securityKey = new SymmetricSecurityKey(Convert.FromBase64String(_key));
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey,
ValidIssuer = _issuer,
ValidAudience = _audience,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
RequireExpirationTime = true
};
if (isJwe)
{
validationParameters.TokenDecryptionKey = securityKey;
}
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var claimsPrincipal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
return claimsPrincipal;
}
catch
{
throw new AuthenticationException("Authentication failed.");
}
}
}
OtpService
using OtpAuth.Interfaces;
namespace OtpAuth.Services;
public class OtpService : IOtpService
{
public string GenerateOtp()
{
var random = new Random();
var otp = random.Next(100000, 999999).ToString();
return otp;
}
public void SendOtp(string phone, string otp)
{
// USE YOUR SMS PROVIDER HERE
Console.WriteLine($"Otp for {phone} is {otp}");
}
}
Now we can complete the Controller
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using OtpAuth.Data;
using OtpAuth.DTOs;
using OtpAuth.Interfaces;
using OtpAuth.Models;
namespace OtpAuth.Controllers;
[Route("Auth")]
public class AuthenticationController : ControllerBase
{
private const int PhoneNumberLength = 10;
private const int OtpExpiryMinutes = 5;
private const int AccessTokenExpiryInDays = 30 * 6; // 6 months
private readonly IOtpService _otpService;
private readonly IJwtService _jwtService;
private readonly DataContext _dataContext;
public AuthenticationController(IOtpService otpService, IJwtService jwtService, DataContext dataContext)
{
_otpService = otpService ?? throw new ArgumentNullException(nameof(otpService));
_jwtService = jwtService ?? throw new ArgumentNullException(nameof(jwtService));
_dataContext = dataContext ?? throw new ArgumentNullException(nameof(dataContext));
}
[HttpPost]
[Route("RequestOtp")]
public string RequestOtp([FromBody] OtpRequestDto otpRequestDto)
{
ValidatePhone(otpRequestDto.Phone);
var otp = _otpService.GenerateOtp();
_otpService.SendOtp(otpRequestDto.Phone, otp);
var claims = new[]
{
new Claim(ClaimTypes.Authentication, otp),
new Claim(ClaimTypes.MobilePhone, otpRequestDto.Phone)
};
var jwe = _jwtService.GenerateToken(claims, TimeSpan.FromMinutes(OtpExpiryMinutes), true);
return jwe;
}
[HttpPost]
[Route("VerifyOtp")]
public string VerifyOtp([FromBody] OtpVerifyDto otpVerifyDto)
{
if (string.IsNullOrWhiteSpace(otpVerifyDto.Otp) || string.IsNullOrWhiteSpace(otpVerifyDto.OtpToken))
{
throw new ValidationException("Otp or token should not be empty");
}
var claims = _jwtService.GetPrincipalFromToken(otpVerifyDto.OtpToken, true);
var generatedOtp = claims.FindFirst(ClaimTypes.Authentication)?.Value;
var phone = claims.FindFirst(ClaimTypes.MobilePhone)?.Value;
if (string.IsNullOrWhiteSpace(phone) || string.IsNullOrWhiteSpace(generatedOtp))
{
throw new AuthenticationFailureException("Invalid Otp token");
}
if (otpVerifyDto.Otp != generatedOtp)
{
throw new AuthenticationFailureException("Incorrect Otp");
}
var user = _dataContext.Users.FirstOrDefault(user => user.PhoneNumber == phone);
if (user == null)
{
user = new User()
{
PhoneNumber = phone,
ReferenceId = Guid.NewGuid()
};
_dataContext.Add(user);
_dataContext.SaveChanges();
}
var accessToken = CreateAccessToken(user.ReferenceId);
return accessToken;
}
private void ValidatePhone(string phone)
{
if (string.IsNullOrWhiteSpace(phone))
{
throw new ValidationException("Phone should not be empty.");
}
if (!phone.All(char.IsDigit))
{
throw new ValidationException("Phone should be all digits.");
}
if (phone.Length != PhoneNumberLength)
{
throw new ValidationException($"Phone should be {PhoneNumberLength} digits long.");
}
}
private string CreateAccessToken(Guid referenceId)
{
var accessTokenClaims = new[]
{
new Claim(ClaimTypes.NameIdentifier, referenceId.ToString())
};
var accessToken = _jwtService.GenerateToken(accessTokenClaims, TimeSpan.FromDays(AccessTokenExpiryInDays));
return accessToken;
}
}
For full source code, check my git repo: https://github.com/mhdsbq/OtpAuth
If You have any suggestions or improvements, please add a comment.