Translated from Simplified Chinese by AI.Read the original
Recently I've been trying to use Cloudflare Pages and its Functions to implement a simple unified authentication system similar to CAS. Because of the stateless limitations of Serverless itself, and because I don't want to bring in another Serverless Redis service (like Upstash), in the email verification code scenario, the traditional Redis / Session-based approach is unusable (;´д`)ゞ
TOTP
When talking about stateless verification codes, I have to mention TOTP, which is also a common two-factor authentication code. The idea behind TOTP generation is roughly to divide the current timestamp by a validity period, then sign it using a signing algorithm like HMAC-SHA1 / HMAC-SHA256, and finally truncate the signature to a fixed number of digits as the verification code. Performing the same computation on the server verifies the TOTP's validity.
So, to implement a similar stateless email verification code, we can also apply this idea: concatenate the email address and the time window number (I guess that's what we can call it?), truncate the first few characters as the email verification code, and the server can perform the same computation to verify the email address's validity.
At the same time, to prevent "boundary expiration" similar to TOTP:
For example, if the current timestamp is
1721284009, the validity period is set to 10 seconds, and the calculated time window number is1721284009/ 10 =172128400One second later, the time window number becomes
172128401, the TOTP expires, so a TOTP generated at that moment only has a 1-second validity period.
When the server verifies validity, it can calculate one more time window backwards, producing two verification codes; if either one matches, it's valid. This ensures the verification code's validity period is always greater than the length of one time window.
Verification Flow
In summary, here is the stateless email verification flow I envision:
- After the user enters their email address, call the server's email verification endpoint
- In the email verification endpoint:
- Calculate the current timestamp (milliseconds) / 600000 (10 minutes), and call it
Time Window - Concatenate the email address and
Time Window, and call itMsg - Use the configured
Server Secretto HMAC-SHA256 signMsg, convert the result to a hexadecimal string, and call itFull Code - Truncate the first 6 characters of
Full Codeto use as the verification code - Send the verification code to the email address
- Calculate the current timestamp (milliseconds) / 600000 (10 minutes), and call it
- After the user enters the email verification code, call the registration endpoint
- In the registration endpoint:
- Perform the computation described above to get the verification codes corresponding to
Time WindowandTime Window- 1 - Check whether either one matches
- Perform the computation described above to get the verification codes corresponding to
TypeScript Implementation
Cloudflare's Edge Runtime cannot use the Node.js crypto library, but fortunately it supports the Web Crypto API, which can be used to do the HMAC-SHA256 signing described above and convert the result to a hexadecimal string:
export async function hmacSha256String(msg: string, key: string) {
const encoder = new TextEncoder()
const keyBuffer = encoder.encode(key)
const msgBuffer = encoder.encode(msg)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign'],
)
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, msgBuffer)
const signatureArray = new Uint8Array(signatureBuffer)
const hexString = Array.from(signatureArray)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return hexString
}
Then, following the flow above, we can write generation and verification functions like this:
function getCurrentTimeWindow() {
const timestamp = new Date().getTime()
return Math.floor(timestamp / CODE_TIME_WINDOW_SIZE)
}
async function genCodeWithTimeWindow(payload: string[], timeWindow: number) {
const msg = payload.join('-') + timeWindow.toString()
const fullCode = await hmacSha256String(msg, SERVER_SECRET)
return fullCode.substring(0, 6)
}
export async function genCode(
payload: string[],
): Promise<{ code: string; expiration: number }> {
const timeWindow = getCurrentTimeWindow()
const code = await genCodeWithTimeWindow(payload, timeWindow)
const expiration = (timeWindow + 2) * CODE_TIME_WINDOW_SIZE
return {
code,
expiration,
}
}
export async function checkCode(payload: string[], code: string) {
const timeWindow = getCurrentTimeWindow()
const codes = await Promise.all([
genCodeWithTimeWindow(payload, timeWindow),
genCodeWithTimeWindow(payload, timeWindow - 1),
])
if (codes.includes(code)) {
return true
}
return false
}
If you just want to verify an email address, passing only the email address as the Payload is enough. But if you want to distinguish different operations (for example, distinguishing email verification before deleting the database from email verification before registration), the Payload needs to include a string identifying the current operation, so the verification codes won't be mixed up.
Limitations
- Once a verification code is generated, there's no way to revoke it; it remains valid for the entire validity period. That's probably a common flaw of all stateless solutions XD
- It's easy to get email-spammed; this can be mitigated by adding a CAPTCHA (such as Cloudflare Turnstile or Google reCAPTCHA)
- ...additions welcome (?)
This stateless email verification code is just a very preliminary idea of mine, and there may be other potential security risks. If there are no other constraints in a production environment, it's better to just use the traditional Redis / Session solution.
Off-topic
I found out Cloudflare's official Chinese name is 科赋锐, so funny 2333333