Introduction
User authentication is a critical component of securing applications and safeguarding sensitive information. One of the most widely adopted methods for enhancing login security is using One-Time Passwords (OTP). OTPs provide an additional layer of security by requiring users to enter a unique, temporary code in addition to their regular password. However, implementing OTP verification in a robust and fail-safe manner can be challenging, especially when dealing with server downtime, network failures, or high traffic loads.
Problem
A particular challenge with OTP verification systems is managing the process efficiently when response times are prolonged. This can occur due to high user traffic or other operational delays, resulting in users experiencing uncertainty about the success of their OTP submission. To address this issue, a queue-based architecture can be implemented to handle OTP requests systematically. By leveraging queues, we can ensure that OTP requests are processed in a reliable and orderly fashion, even under high load conditions.
Another critical aspect is handling errors and ensuring that failed OTP requests are retried appropriately without affecting the user experience. Implementing a robust retry mechanism and monitoring the queue can help maintain system reliability and provide insights into the system's health.
How to Solve the Problem
To address the challenges associated with implementing a fail-safe OTP verification system, we use RabbitMQ with the Dead Letter Queue (DLQ) pattern. This solution will ensure efficient handling of OTP requests, systematic retries of failed requests, and robust monitoring capabilities.
Key Components of the Solution:
1. RabbitMQ for Queue Management:
- OTP Request Queue: A primary queue to handle incoming OTP verification requests.
- Processing Queue: A secondary queue where OTP requests are processed.
- Dead Letter Queue (DLQ): A queue to handle failed OTP requests that need to be retried.
2. Error Handling and Retry Mechanism:
- Automatic Retry: Configure RabbitMQ to automatically retry failed OTP requests a specified number of times.
- DLQ for Failed Requests: If an OTP request fails after the maximum number of retries, it is moved to the DLQ for further inspection and handling.
3. Monitoring:
- Junk Queue: Create a new queue called Junk Queue to capture undelivered or error messages
Benefits of the Proposed Solution:
- Reliability: Ensures that OTP requests are processed reliably, even under high load conditions.
- Fault Tolerance: The DLQ pattern provides a robust mechanism for handling failures and ensuring that no requests are lost.
- Scalability: RabbitMQ can handle a large volume of requests, making the system scalable as user traffic increases.
The RabbitMQ Basic
RabbitMQ
RabbitMQ is a messaging broker, essentially a software platform that enables different applications or components within an application to communicate and share data with each other.
Messaging
Messaging is the process of sending and receiving messages between different software components. It's a fundamental concept in distributed computing and facilitates asynchronous communication between systems.
Exchange
An exchange in RabbitMQ is a key intermediary component responsible for receiving messages from producers and then routing them to one or more queues. Exchanges use rules, known as bindings, to determine how messages should be routed.
Route
Routing in RabbitMQ refers to the process of directing messages from exchanges to queues based on predefined criteria, such as message attributes or routing keys. This ensures that messages are delivered to the appropriate destination.
Queue
A queue in RabbitMQ is a buffer that holds messages until they are consumed by a consumer application or component. Queues provide a way to decouple producers and consumers, allowing messages to be processed asynchronously and providing resilience to system failures.
How It Works retry queue using DLQ
Source image: https://www.cloudamqp.com/blog/when-and-how-to-use-the-rabbitmq-dead-letter-exchange.html
This section details the step-by-step process of how the OTP verification system operates using RabbitMQ with the Dead Letter Queue (DLQ) pattern. The architecture ensures efficient handling of OTP requests, systematic retries of failed requests, and robust monitoring capabilities.
1. OTP Request Submission
- User Action: A user initiates the login process and requests an OTP.
- Producer / Backend: The application acts as a producer, receiving the OTP request and sending it to the RabbitMQ OTP Request Queue.
2. OTP Request Queue
- Queueing: The OTP Request Queue receives and holds all incoming OTP requests.
- Message Forwarding: Requests are forwarded from the OTP Request Queue to the Processing Queue for handling.
3. Processing Queue
- Consumer: A worker service acts as a consumer, picking up OTP requests from the Processing Queue.
- Request Processing: The consumer processes the OTP request, handling OTP generation and verification.
- Success Handling: If the OTP verification is successful, the consumer sends a response back to the user through the application, confirming the successful login.
4. Error Handling and Retry Mechanism
- Error Detection: If an error occurs during OTP processing (e.g., network issues, server errors), the request is not immediately discarded.
- Retry Logic: RabbitMQ is configured to retry the failed OTP request a specified number of times. This is done by re-queuing the message into the Processing Queue with a delay.
- Maximum Retries: If the OTP request fails after the maximum number of retries, it is moved to the Dead Letter Queue (DLQ).
5. Dead Letter Queue (DLQ)
- Failed Requests: The DLQ holds all OTP requests that have failed after the maximum retry attempts.
Implementation
- Install RabbitMQ.
- Install the RabbitMQ Management Plugin for monitoring.
- Install the necessary Go packages:
go get github.com/rabbitmq/amqp091-go
RabbitMQ Queue Setup
- Direct Queue: The primary queue to handle incoming OTP requests.
- DLQ: A queue to handle failed OTP requests that need to be retried.
- Junk Queue: A queue to store OTP requests that fail after the maximum retry attempts.
Golang Code
Direct Queue (Sending OTP Request)
Producer
Below code demonstrates how to establish a connection to RabbitMQ, declare a queue, and publish a message to it. The code sets up a connection to RabbitMQ server running locally, opens a channel, declares a queue named 'direct_queue' with properties for dead-lettering, and publishes an OTP request payload to the queue.
Function to connect Rabbit mq
- Before we code the implementation, make sure you already install rabbitMq in your local machine
- Then we can implement the code to establish a connection to RabbitMQ server running locally (amqp://guest:guest@localhost:5672/).
If an error occurs during connection establishment, it is handled using the failOnError function.
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
failOnError(err, "Failed to connect to RabbitMQ")
defer conn.Close()
ch, err := conn.Channel()
failOnError(err, "Failed to open a channel")
defer ch.Close()
amqp.Dial accepts a string in the AMQP URI format and returns a new Connection.
conn.Channel opens a unique, concurrent server channel to process the bulk of AMQP messages. Any error from methods on this receiver will render the receiver invalid and a new Channel should be opened.
Function to declare direct queue
To declare a queue, we can call the QueueDeclare method of the channel that we created previously.
q, err := ch.QueueDeclare(
"direct_queue",
true,
false,
false,
false,
amqp.Table{
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": "dlq",
},
)
failOnError(err, "Failed to declare a queue")
- Declares a queue named "direct_queue" using ch.QueueDeclare method with the following parameters:
- Queue name, this for attribute for queue name, in the code we add queue name as "direct_queue"
- Durable queue, this is an attribute to define should rabbitmq persist the queue even if RabbitMQ restarts. We can declare as true if we want to set queue persistent
- Auto deletion, this is an attribute to define the queue will be deleted when it's no longer in use. We set it to false because we don't want to lose the queue if it’s not consumed
- Exclusive queue. Exclusive queues are only accessible by the connection that declares them and will be deleted when the connection closes
- Nowait: Queue without message length limit.
- amqp.Table{...}: Optional arguments for the queue, such as dead-lettering configuration.
Helper Function failOnError:
- Defines a helper function failOnError that checks for errors and logs them.
- If an error is present, it logs the error message along with a custom message and exits the program using log.Fatalf.
func failOnError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
Below are the all code that we implement in main function
package main
import (
"log"
amqp "github.com/rabbitmq/amqp091-go"
)
func main() {
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
failOnError(err, "Failed to connect to RabbitMQ")
defer conn.Close()
ch, err := conn.Channel()
failOnError(err, "Failed to open a channel")
defer ch.Close()
q, err := ch.QueueDeclare(
"direct_queue",
true,
false,
false,
false,
amqp.Table{
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": "dlq",
},
)
failOnError(err, "Failed to declare a queue")
}
func failOnError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}
Publishing Message
After we declare a queue, we will create publisher to perform create new message to queue. To publish a new message, we can call the Publish method of the channel that we created previously.
body := "OTP request payload"
err = ch.Publish(
"",
q.Name,
false,
false,
amqp.Publishing{
ContentType: "text/plain",
Body: []byte(body),
})
failOnError(err, "Failed to publish a message")
log.Printf(" [x] Sent %s", body)
Publish method have 5 parameter:
- Exchange name, this is an attribute to declare the exchange name, we will represent it as empty string ““
- Queue name, this is a queue name that have we declare in the previous section
- Mandatory Flag, if true, the message must be routed to a queue
- Immediate Flag, if true, the message must be delivered immediately or return an error
- Message property, such as content type and payload.
Consumer
This bellow code perform to consume message from a specified queue, processes OTP requests, and handles message acknowledgment and rejection based on processing outcomes
Get the messages
To get the messages, we can call the Consume method of the channel that we created previously.
msgs, err := ch.Consume(
q.Name,
"",
false,
false,
false,
false,
nil,
)
failOnError(err, "Failed to register a consumer")
- Queue name, this is a queue name that have we declare in the previous section
- Consumer name, this is identified by a string that is unique and scoped for all
- consumers on this channel
- Auto Ack, When autoAck (also known as noAck) is true, the server will acknowledge
- deliveries to this consumer prior to writing the delivery to the network
- Exclusive, When exclusive is true, the server will ensure that this is the sole consumer
- from this queue. When exclusive is false, the server will fairly distribute
- deliveries across multiple consumers
- NoLocal, this is not supported by RabbitMQ, It's advisable to use separate connections for
- Channel.Publish and Channel.Consume so not to have TCP pushback on publishing
- affect the ability to consume messages, so this parameter is here mostly for
- completeness
- NoWait, When noWait is true, do not wait for the server to confirm the request and
- immediately begin deliveries
- Optional Arguments, this can be provided that have specific semantics for the queue
- or server
Processing the messages and send the otp request
This bellow code loop through the captured message and send the otp by message body.
forever := make(chan bool)
Create an unbuffered channel named forever of type bool. This channel will be used to keep the program running indefinitely.
go func() {
for d := range msgs {
log.Printf("Received a message: %s", d.Body)
count := utils.CheckLimitRetry(message)
// check retry limit
if count >= 3 {
_ := sendToJunk(d)
d.Ack(false)
return nil
}
err := processOTPRequest(string(d.Body))
if err != nil {
log.Printf("Error processing message: %s", err)
d.Reject(false)
} else {
d.Ack(false) // Acknowledge the message
}
}
}()
- go func() starts a new goroutine, which is a lightweight thread managed by the Go runtime. The code inside the func() block will be executed concurrently.
- for d := range msgs starts a for loop that reads messages from the msgs channel. The loop will continue to run as long as there are messages coming through the msgs channel.
- sendToJunk(d) if the message retried more than 3 times, the function will publish a new junk queue and pass the message as argument. this mechanism useful for monitor unprocessed queue.
- count := utils.CheckLimitRetry(message) check how many times the message has been retried
- err := processOTPRequest(string(d.Body)) call a function processOTPRequest, passing the message body as a string. This function processes the OTP request and returns an error if something goes wrong
DLQ (Retry a failed OTP)
Queue Declaration
We will create a new queue as done above, but we put a difference in the queue name. in the optional argument we will set x-dead-letter-routing-key to direct_queue
q, err := ch.QueueDeclare(
"dlq",
true,
false,
false,
false,
amqp.Table{
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": "direct_queue",
},
)
failOnError(err, "Failed to declare a queue")
if any error on consuming the message, the message will send back to direct_queue by defining "x-dead-letter-routing-key": "direct_queue"
Consumer
we need to consume the message and put a reject in it so that the message can return to the direct message to be processed again
forever := make(chan bool)
go func() {
for d := range msgs {
log.Printf("Received a message in DLQ: %s", d.Body)
d.Reject(false) // Reject the message to send it back to direct queue
}
}()
log.Printf(" [*] Waiting for messages in DLQ. To exit press CTRL+C")
<-forever
- d.Reject rejects the message, the message will be requeued or sent back to the original queue (direct queue) depending on the configuration of the message broker.
Junk Queue (Monitor Unprocessed Message)
just like the declaration above with a different name, in the optional argument section we set it to nil
q, err := ch.QueueDeclare(
"junk_queue",
true,
false,
false,
false,
nil,
)
failOnError(err, "Failed to declare a queue")
Complete of source code can be found here https://github.com/macgatron/go-otp-queue
Conclusion
In this documentation, we explored the implementation of a fail-safe OTP verification system using RabbitMQ with the Dead Letter Queue (DLQ) pattern. By leveraging three queues—Direct Queue, DLQ, and Junk Queue—we've built a robust system capable of handling OTP requests efficiently, ensuring reliability and fault tolerance.
By implementing the fail-safe OTP verification system with RabbitMQ and leveraging the DLQ pattern, we've developed a robust solution that ensures reliable OTP verification for user logins. With efficient queue management, automatic retries, and comprehensive error handling, the system provides a seamless user experience while maintaining scalability and reliability. Continuous monitoring and future enhancements will further strengthen the system's capabilities, ensuring it remains secure and efficient in handling OTP verification for various applications and use cases.
Key Takeaways
- Efficient OTP Handling: The Direct Queue efficiently manages incoming OTP requests, ensuring they are processed promptly.
- Retry Mechanism: The DLQ captures failed OTP requests and retries them by sending them back to the Direct Queue, providing a mechanism for automatic recovery.
- Error Monitoring: The Junk Queue stores OTP requests that have exceeded the maximum retry attempts, enabling administrators to monitor and analyze errors for system improvement.
Top comments (0)