Skip to content

Commit

Permalink
Send email with OTPCode
Browse files Browse the repository at this point in the history
  • Loading branch information
nagarwal4 committed Mar 27, 2024
1 parent 1afcd71 commit 6272f56
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 72 deletions.
2 changes: 1 addition & 1 deletion backend/core/src/Core.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@

app.ConfigureCustomAuthenticationMiddleware();
app.ConfigureCustomExceptionMiddleware();
app.ConfigureSignatureVerificationMiddleware();

app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();

app.ConfigureSignatureVerificationMiddleware();
app.UseMiddleware<PublicKeyLinkedMiddleware>();

app.MapControllers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private static bool SkipEndpoint(HttpContext context)
var endpoint = context.GetEndpoint();
var endpointName = endpoint?.Metadata.GetMetadata<EndpointNameMetadata>()?.EndpointName;

var excludeList = new[] { "DeviceAuthentication" };
var excludeList = new[] { "DeviceAuthentication", "SendOTPCodeEmail" };

return context.Request.Path.StartsWithSegments("/health")
|| excludeList.Contains(endpointName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,76 +42,80 @@ private enum SignatureAlgorithmHeader

public async Task Invoke(HttpContext context)
{
// Retrieve headers from the request
string? signatureHeader = context.Request.Headers["x-signature"];
string? algorithmHeader = context.Request.Headers["x-algorithm"];
string? publicKeyHeader = context.Request.Headers["x-public-key"];
string? timestampHeader = context.Request.Headers["x-timestamp"];

// Make sure the headers are present
if (!Enum.TryParse<SignatureAlgorithmHeader>(algorithmHeader, ignoreCase: true, out _))
if (!SkipEndpoint(context))
{
_logger.LogError("Invalid algorithm header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid Header", "x-algorithm"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(signatureHeader))
{
_logger.LogError("Missing signature header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-signature"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(publicKeyHeader))
{
_logger.LogError("Missing publicKey header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-public-key"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(timestampHeader)
|| !long.TryParse(timestampHeader, out var timestampHeaderLong))
{
_logger.LogError("Missing timestamp header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-timestamp"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

// Check if the timestamp is within the allowed time
if (!IsWithinAllowedTime(timestampHeaderLong))
{
_logger.LogError("Timestamp outdated");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "x-timestamp"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

// TODO: Check if the public key is valid according to algorithm

var payloadSigningStream = await GetPayloadStream(context, timestampHeader);

// Parse the public key
var publicKeyBytes = Convert.FromBase64String(publicKeyHeader);

// Decode the signature header from Base64
var signatureBytes = Convert.FromBase64String(signatureHeader);

if (VerifySignature(publicKeyBytes, payloadSigningStream.ToArray(), signatureBytes))
{
// Signature is valid, continue with the request
await _next(context);
}
else
{
_logger.LogError("Invalid signature");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
// Retrieve headers from the request
string? signatureHeader = context.Request.Headers["x-signature"];
string? algorithmHeader = context.Request.Headers["x-algorithm"];
string? publicKeyHeader = context.Request.Headers["x-public-key"];
string? timestampHeader = context.Request.Headers["x-timestamp"];

// Make sure the headers are present
if (!Enum.TryParse<SignatureAlgorithmHeader>(algorithmHeader, ignoreCase: true, out _))
{
_logger.LogError("Invalid algorithm header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid Header", "x-algorithm"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(signatureHeader))
{
_logger.LogError("Missing signature header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-signature"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(publicKeyHeader))
{
_logger.LogError("Missing publicKey header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-public-key"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

if (string.IsNullOrWhiteSpace(timestampHeader)
|| !long.TryParse(timestampHeader, out var timestampHeaderLong))
{
_logger.LogError("Missing timestamp header");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Missing Header", "x-timestamp"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

// Check if the timestamp is within the allowed time
if (!IsWithinAllowedTime(timestampHeaderLong))
{
_logger.LogError("Timestamp outdated");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid timestamp", "x-timestamp"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
return;
}

// TODO: Check if the public key is valid according to algorithm

var payloadSigningStream = await GetPayloadStream(context, timestampHeader);

// Parse the public key
var publicKeyBytes = Convert.FromBase64String(publicKeyHeader);

// Decode the signature header from Base64
var signatureBytes = Convert.FromBase64String(signatureHeader);

if (VerifySignature(publicKeyBytes, payloadSigningStream.ToArray(), signatureBytes))
{
// Signature is valid, continue with the request
await _next(context);
}
else
{
_logger.LogError("Invalid signature");
var customErrors = new CustomErrors(new CustomError("Forbidden", "Invalid signature", "x-signature"));
await WriteCustomErrors(context.Response, customErrors, (int)HttpStatusCode.Forbidden);
}
}
await _next(context);
}

private static async Task<MemoryStream> GetPayloadStream(HttpContext context, string timestampHeader)
Expand Down Expand Up @@ -166,6 +170,20 @@ public static bool VerifySignature(byte[] publicKey, byte[] payload, byte[] sign
return false;
}
}

/// <summary>
/// Skip the middleware for specific endpoints
/// </summary>
private static bool SkipEndpoint(HttpContext context)
{
var endpoint = context.GetEndpoint();
var endpointName = endpoint?.Metadata.GetMetadata<EndpointNameMetadata>()?.EndpointName;

var excludeList = new[] { "SendOTPCodeEmail" };

return context.Request.Path.StartsWithSegments("/health")
|| excludeList.Contains(endpointName);
}
}

public static class SignatureVerificationMiddlewareExtensions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed
// under the Apache License, Version 2.0. See the NOTICE file at the root
// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0

using Core.Domain;
using Core.Domain.Abstractions;
using Core.Domain.Exceptions;
using Core.Domain.Repositories;
using MediatR;

namespace Core.Application.Commands.CustomerCommands
{
public class OTPCodeByEmailCommand : IRequest
{
public string CustomerCode { get; set; }

public string IP { get; set; }

public OTPCodeByEmailCommand(string customerCode, string ip)
{
CustomerCode = customerCode;
IP = ip;
}
}

public class OTPCodeByEmailCommandHandler : IRequestHandler<OTPCodeByEmailCommand>
{
private readonly ICustomerRepository _customerRepository;
private readonly ICustomerDeviceRepository _customerDeviceRepository;
private readonly ICustomerOTPGenerator _otpGenerator;
private readonly ISendGridMailService _sendGridMailService;
private readonly IUnitOfWork _unitOfWork;

public OTPCodeByEmailCommandHandler(
ICustomerRepository customerRepository,
ICustomerDeviceRepository customerDeviceRepository,
ICustomerOTPGenerator otpGenerator,
ISendGridMailService sendGridMailService,
IUnitOfWork unitOfWork)
{
_customerRepository = customerRepository;
_customerDeviceRepository = customerDeviceRepository;
_otpGenerator = otpGenerator;
_sendGridMailService = sendGridMailService;
_unitOfWork = unitOfWork;
}

public async Task Handle(OTPCodeByEmailCommand request, CancellationToken cancellationToken)
{
var customer = await _customerRepository.GetAsync(request.CustomerCode, cancellationToken);

if (customer == null)
{
throw new CustomErrorsException(DomainErrorCode.CustomerNotFoundError.ToString(), request.CustomerCode, "Customer not found.");
}

if (customer.Status != CustomerStatus.ACTIVE.ToString())
{
throw new CustomErrorsException(DomainErrorCode.CustomerNotActiveError.ToString(), request.CustomerCode, "Customer is not ACTIVE.");
}

var customerDevice = await _customerDeviceRepository.GetAsync(request.CustomerCode, cancellationToken);

// rare case of exception
if (customerDevice == null || string.IsNullOrEmpty(customerDevice.OTPKey))
{
throw new CustomErrorsException(DomainErrorCode.CustomerNotFoundError.ToString(), request.CustomerCode, "Customer not found.");
}

// Generate OTPCode
var otpCode = _otpGenerator.GenerateOTPCode(customerDevice.OTPKey);

// Send OTPCode to customer email
await _sendGridMailService.SendOTPCodeMailAsync(customer, otpCode);

await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ namespace Core.Domain.Abstractions
public interface ISendGridMailService
{
public Task SendMailAsync(Mail mail, Customer customer, Transaction transaction);

public Task SendOTPCodeMailAsync(Customer customer, string otpCode);
}
}
4 changes: 3 additions & 1 deletion backend/core/src/Core.Domain/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ public enum DomainErrorCode
InvalidStatusError,
InvalidPropertyError,
ExistingKeyError,
SecurityCheckError
SecurityCheckError,
CustomerNotFoundError,
CustomerNotActiveError,
}

public enum PaymentRequestStatus
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,13 @@ public class MailTemplate
[JsonProperty("finishedDate")]
public string? FinishedDate { get; set; }
}

public class OTPCodeMailTemplate
{
[JsonProperty("customerFullName")]
public string? CustomerFullName { get; set; }

[JsonProperty("otpCode")]
public string? OTPCode { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,33 @@ public async Task SendMailAsync(Mail mail, Customer customer, Transaction transa
throw new CustomErrorsException("MailService", "mail", "An error occured while sending mail.");
}
}

public async Task SendOTPCodeMailAsync(Customer customer, string otpCode)
{
var from = new EmailAddress(_mailOptions.Sender);
var to = new EmailAddress(customer.Email);
var msg = new SendGridMessage();

msg.SetFrom(new EmailAddress(from.Email, from.Name));
msg.AddTo(new EmailAddress(to.Email, to.Name));

msg.SetTemplateId(_mailOptions.Templates.OTPCodeTemplateID);

// Fill in the dynamic template fields
var templateData = new OTPCodeMailTemplate()
{
CustomerFullName = customer?.GetName(),
OTPCode = otpCode
};

msg.SetTemplateData(templateData);

var response = await _sendGridClient.SendEmailAsync(msg);

if (response.StatusCode != HttpStatusCode.Accepted)
{
throw new CustomErrorsException("MailService", "mail", "An error occured while sending mail.");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@ public class Templates

[Required]
public required string FundingtemplateID { get; set; }

[Required]
public required string OTPCodeTemplateID { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public string GenerateOTPCode(string otpKey)
var keyBytes = Base32Encoding.ToBytes(otpKey);

// Create a TOTP generator with the key and time step (default is 30 seconds)
var totp = new Totp(keyBytes);
var totp = new Totp(keyBytes, step: 120);

// Generate the OTP code for the current time
var otpCode = totp.ComputeTotp();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public ProcessEmailsJob(

public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Process emails job");
var mails = _mailsRepository.GetMailsAsync(MailStatus.ReadyToSend.ToString(), context.CancellationToken).Result;

if (mails != null && mails.Any())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0

using Asp.Versioning;
using Core.Application.Commands;
using Core.Application.Commands.CustomerCommands;
using Core.Application.Queries.CustomerQueries;
using Core.Presentation.Models;
using Core.Presentation.Models.Requests.CustomerRequests;
Expand Down Expand Up @@ -100,5 +102,18 @@ public async Task<IActionResult> DeviceAuthenticationAsync([FromBody] CreateDevi
var response = ConstructCustomResponse(result, DeviceAuthenticationResponse.FromOTPKey);
return Ok(response);
}

[HttpPost("otp/email", Name = "SendOTPCodeEmail")]
[ProducesResponseType(typeof(CustomResponse<EmptyCustomResponse>), 201)]
[ProducesResponseType(typeof(CustomErrorsResponse), 400)]
[ProducesResponseType(typeof(CustomErrorsResponse), 404)]
[ProducesResponseType(typeof(CustomErrorsResponse), 500)]
[RequiredScope("Customer.Create")]
public async Task<IActionResult> SendOTPCodeEmailAsync()
{
var command = new OTPCodeByEmailCommand(GetUserId(), GetIP());
await _sender.Send(command);
return CreatedAtRoute("SendOTPCodeEmail", null, new EmptyCustomResponse());
}
}
}

0 comments on commit 6272f56

Please sign in to comment.