mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
347bdb10d4
* 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>
151 lines
4.2 KiB
TypeScript
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()}`;
|
|
}
|
|
}
|