Files
outline/server/utils/VerificationCode.ts
T
Tom Moor 347bdb10d4 fix: Ensure OTP is bound to workspace (#12096)
* fix: Ensure OTP is bound to teamId

* fix: Address review feedback on OTP tenant scoping

- Trim whitespace in VerificationCode Redis keys to match DB lookup
  normalization.
- Redirect with invalid-code (rather than leaking a backend error)
  when no user exists for the email in the resolved team.
- Correct retrieve() JSDoc to state undefined instead of null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:22:58 -04:00

151 lines
4.2 KiB
TypeScript

import { randomInt } from "node:crypto";
import { Minute } from "@shared/utils/time";
import Redis from "@server/storage/redis";
import { safeEqual } from "./crypto";
/**
* This class manages verification codes for email authentication.
* It stores and retrieves 6-digit codes in Redis with a 10-minute TTL.
*/
export class VerificationCode {
/**
* Redis client instance (lazy initialized)
*/
private static get redis() {
return Redis.defaultClient;
}
/**
* TTL for verification codes in milliseconds (10 minutes)
*/
private static readonly TTL = Minute.ms * 10;
/**
* Maximum number of verification attempts before the code is deleted
*/
private static readonly MAX_ATTEMPTS = 10;
/**
* Prefix for Redis keys
*/
private static readonly KEY_PREFIX = "email_verification_code:";
/**
* Prefix for Redis attempt counter keys
*/
private static readonly ATTEMPTS_PREFIX = "email_verification_attempts:";
/**
* Generate a random 6-digit code
*
* @returns A string representing a 6-digit code
*/
public static generate(): string {
// Generate a random integer between 100000 and 999999 (6 digits)
return randomInt(100000, 1000000).toString().padStart(6, "0");
}
/**
* Store a verification code in Redis with a 10-minute TTL
*
* @param teamId The team the code is being issued for
* @param email The email address associated with the code
* @param code The 6-digit verification code
* @returns Promise resolving to true if successful
*/
public static async store(
teamId: string,
email: string,
code: string
): Promise<boolean> {
const key = this.getKey(teamId, email);
await this.redis.set(key, code, "PX", this.TTL);
return true;
}
/**
* Retrieve a verification code from Redis
*
* @param teamId The team the code was issued for
* @param email The email address associated with the code
* @returns Promise resolving to the code or undefined if not found
*/
public static async retrieve(
teamId: string,
email: string
): Promise<string | undefined> {
const key = this.getKey(teamId, email);
return (await this.redis.get(key)) ?? undefined;
}
/**
* Verify if a given code matches the stored code for an email within a team.
*
* @param teamId The team the code was issued for
* @param email The email address associated with the code
* @param code The code to verify
* @returns Promise resolving to true if the code matches, false otherwise
*/
public static async verify(
teamId: string,
email: string,
code: string
): Promise<boolean> {
const storedCode = await this.retrieve(teamId, email);
if (!storedCode) {
return false;
}
const attemptsKey = this.getAttemptsKey(teamId, email);
const attempts = await this.redis.incr(attemptsKey);
if (attempts === 1) {
await this.redis.pexpire(attemptsKey, this.TTL);
}
if (attempts > this.MAX_ATTEMPTS) {
await this.delete(teamId, email);
return false;
}
return safeEqual(storedCode, code);
}
/**
* Delete a verification code from Redis
*
* @param teamId The team the code was issued for
* @param email The email address associated with the code
* @returns Promise resolving to true if successful
*/
public static async delete(teamId: string, email: string): Promise<boolean> {
const key = this.getKey(teamId, email);
const attemptsKey = this.getAttemptsKey(teamId, email);
await this.redis.del(key, attemptsKey);
return true;
}
/**
* Get the Redis key for a code scoped to a team and email address.
*
* @param teamId The team the code was issued for
* @param email The email address
* @returns The Redis key
*/
private static getKey(teamId: string, email: string): string {
return `${this.KEY_PREFIX}${teamId}:${email.trim().toLowerCase()}`;
}
/**
* Get the Redis key for tracking verification attempts.
*
* @param teamId The team the code was issued for
* @param email The email address.
* @returns the Redis key for attempts.
*/
private static getAttemptsKey(teamId: string, email: string): string {
return `${this.ATTEMPTS_PREFIX}${teamId}:${email.trim().toLowerCase()}`;
}
}