smeeb
smeeb

Reputation: 29497

Generating one-time-only security tokens from UUID or HMAC/JWT/hash?

I'm build the backend for a web app. When a new user goes to the site and clicks the Sign Up button, they'll fill out a super simple form asking them for their username + password and they'll submit. This prompts the server to send a verification email to that email address. They'll then check their email, click a link (which verifies their email) and then be routed to the login page so they can sign in if they choose.

In order to verify their email, when the server generates the email it will need to create (and store) a verification token (likely a UUID) and attach it to this link in the email, so that the link looks something like:

"https://api.myapp.example.com/v1/users/verify?vt=12345"

Where vt=12345 is the "verification token" (again likely a UUID). So the user clicks this link and my GET v1/users/verify endpoint looks at the token, somehow confirms its valid, and makes some DB updates to "activate" the user. They can now log in.

Similar scenarios for when a user wants to unsubscribe from receiving email, or when they can't remember their password and need to recover it so that they can log in.

Unsubscribe

User wants to stop receiving emails but still wants to use the app. They click an "Unsubscribe" link in a weekly newsletter we send them. This link needs to contain some kind of similar "unsubscribe token" that, like the verification token above, is generated + stored on the server, and is used to authenticate the user's request to unsubscribe from email.

Recover Password

Here the user has forgotten their password and needs to recover it. So at the login screen they click the "Forgot my password" link, and are presented with a form where they must fill out their email address. Server sends an email to that address. They check this email and it contains a link to a form where they can enter their new password. This link needs to contain a "reset password token" that -- like the verification token above -- is generated + stored on the server, and is used to authenticate the user's request to change their password.

So here we have three very similar problems to solve, all requiring the use of what I'm calling "one-time only (OTO) security tokens". These OTO tokens:

My question

The solution I came up was simple...almost too simple.

For the tokens I am just generating random UUIDs (36-char) and storing them to a security_tokens table that has the following fields:

[security_tokens]
---
id (PK)
user_id (FK to [users] table)
token (the token itself)
status (UNCLAIMED or CLAIMED)
generated_on (DATETIME when created)

When the server creates them they are "UNCLAIMED". When the user clicks a link inside the table they are "CLAIMED". A background worker job will run periodically to clean up any CLAIMED tokens or to delte any UNCLAIMED tokens that have "expired" (based on their generated_on fields). The app will also ignore any tokens that have been previously CLAIMED (and have just not yet been cleaned up).

I think this solution would work, but I'm not a super security guy and I'm worried that this approach:

  1. Possibly leaves my app open to some type of attack/exploit; and
  2. Possibly reinvents the wheel when some other solution might work just as well

Like for the 2nd one above I'm wondering if I should be using a hash/HMAC/JWT-related mechanism instead of a dead simple UUID. Maybe there's some smart crypto/security folks who found a way to make these tokens contain CLAIM status and expiration date themselves in a secure/immutable fashion, etc.

Upvotes: 3

Views: 5269

Answers (1)

Horkrine
Horkrine

Reputation: 1407

You're on the right lines

I have a very similar method in my application based on what I want it to do. I have a table containing each user (a Users table) which I can use to reference each individual account and perform actions based on their identity. There are a lot of security threats to mitigate by adding in user accounts and self-management options. Here's how I combat a few of these vulnerabilities.

Verifying your email

When a user signs up, the server should use the RNGCryptoServiceProvider() class to generate a random salt with sufficient length that it could never realistically be guessed. Then, I hash the salt (on it's own) and apply base64 encoding to it so that it can be added to a Url. Send the completed link to the user via email, and be sure to store that hash against the relevant UserId in the Users table.

The user sees a nice and neat "Click here to validate your email address" in their inbox and can click on the link. It should redirect to a page that accepts an optional url parameter (such as mywebsite.com/account/verifyemail/myhash and then check the hash server-side. The site can then check the hash against the activation hashes it has stored in the database. If it matches a record, then you should mark the Users.EmailVerified column to true and commit to the table. Then, you can delete that Verification record entry from the table.

Well done, you've successfully verified a user's email address is real!

Reset password

Here, we implement a similar method. But instead of a Verification record, we're better off storing our record in a PasswordResetRequest table, and do not delete records - this allows you to see whether or not a password was reset and when. Each time the user requests a password reset, you should display an anonymous message such as "An email was sent to your primary email address containing further instructions". Even if one was not sent or the account doesn't exist, it stops a potential attacker from enumerating usernames or email addresses to see if they are registered with your service. Again, if they are real, send a link using the same method as before.

The users opens their email address and clicks on the link. They are then redirected to a reset page such as mywebsite.com/account/resetpassword/myhash. The server then runs the hash in the url against the database and returns a result if it is real. Now, this is the tricky part - you shouldn't keep these active for long. I'd recommend a column linking the hash to the Users.UserId, one called ExpiraryDateTime which contains something like Datetime.Now.AddMinutes(15) (which makes it easier to work with later), and one called IsUsed as a boolean (false by default).

On clicking a link, you should check to see if a link exists. If not, give them them to the default "There was a problem with that link. Please request a new one" text. However, if the link is valid, you should check that Used == false because you don't want people using the same link more than once. If it's not used, great! Let's check to see if it's still valid. The easiest way would be a simple if (PasswordResetRequest.ExpiraryDateTime < DateTime.Now) - if the link is still valid, then you can proceed with the password reset. If not, it means it was generated a while ago and you shouldn't allow it to be used anymore. Seriously, some sites will still allow you to generate a link today and if your email is hacked 1 month from now, you can still use the reset links!

I should also mention that each time the user requests a password reset, you should check the existing records in the table for a valid link. If one is valid (meaning it can still be used) then you should instantly invalidate it. Replace the hash with some assistive text like "Invalid: User requested new reset link". This also lets you know they've requested more than one link whilst also invalidating their link. You could also mark it as Used if you really wanted to just to prevent people from trying to use expired links by being smart and sneaking the whole "Invalid: User requested new reset link" as an encoded URL into their browsers. You should never have more than one reset link active for the same account - ever!

Unsubscribing

For this, I'd have a simple flag in the database that determines whether or not a user can receive promotional offers and newsletters etc. So a Users.SubscribedToNewsletter would suffice. They should be able to log in and change this in their Email Settings or Communication Preferences etc.

Some code examples

This is my RNGCryptoServiceProvider code in C#

public static string GenerateRandomString(RNGCryptoServiceProvider rng, int size)
{
    var bytes = new Byte[size];

    rng.GetBytes(bytes);

    return Convert.ToBase64String(bytes);
}

var rng = new RNGCryptoServiceProvider();
var randomString = GenerateRandomSalt(rng, 47); // This will end up being a string of almost entirely random bytes

Why do I use RNGCryptoServiceProvider?

The RNGCryptoServiceProvider() (which is a C# class in their Security library) allows you to generate a seemingly random string of bytes based on entirely random and non-reproducable events. Classes like the Random() still need to use some sort of internal data to generate a number based on predictable algorithmic events such as current date and time. The RNGCryptoServiceProvider() uses things like cpu temperatures, number of running processes, etc. all to create something random that can't be reproduced. This allows for the final byte array to be as random as possible.

Why do I Base64 encode it?

Base64 encoding will result in a string containing only numbers and letters. This means there will be no symbols or encoded characters within the text and therefore it is safe to use in a URL. This isn't so much a security feature, but it does allow you to only allow numbers and letters within the parameters of the method, and filter out or reject any input that doesn't match this standard. For example, filtering out any inputs that contain the chevrons < and > should allow you to prevent XSS.

Something to keep in mind

You should ALWAYS assume that the link containing your hash is invalid until you perform each check on it to ensure it passes requirements. So you can do your various if statements but unless you pass every single one, you leave your default next action to some form of error for the user. To clarify, I should check that the password reset link is valid, then not used, then still within the time window, and then perform my reset actions. Should it fail to pass any of these requirements, the default action should be to give the user an error saying that it is an invalid link.

Notes for others

Since I'm pretty confident this isn't the only way to do this, I'd just like to declare that this is how I've done it for years which has never failed me and has gotten my company through several extensive pentests. But if someone has a better / more secure way of doing so, please do shed some light as I'd be happy to learn more. If you have any further questions or need clarification on a particular part I mentioned, just let me know and I'll try my best to help

Upvotes: 5

Related Questions