118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import type { MagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/MagicLinkRepository';
|
|
import type { Logger } from '@core/shared/domain/Logger';
|
|
import { Result } from '@core/shared/domain/Result';
|
|
|
|
export class InMemoryMagicLinkRepository implements MagicLinkRepository {
|
|
private resetRequests: Map<string, PasswordResetRequest> = new Map();
|
|
private rateLimitStore: Map<string, { count: number; lastRequest: Date }> = new Map();
|
|
|
|
// Rate limit: max 3 requests per 15 minutes
|
|
private readonly RATE_LIMIT_MAX = 3;
|
|
private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
|
|
|
constructor(private readonly logger: Logger) {}
|
|
|
|
async createPasswordResetRequest(request: PasswordResetRequest): Promise<void> {
|
|
this.logger.debug('[InMemoryMagicLinkRepository] Creating password reset request', {
|
|
email: request.email,
|
|
token: request.token.substring(0, 10) + '...',
|
|
expiresAt: request.expiresAt,
|
|
});
|
|
|
|
this.resetRequests.set(request.token, request);
|
|
|
|
// Update rate limit tracking
|
|
const now = new Date();
|
|
const existing = this.rateLimitStore.get(request.email);
|
|
|
|
if (existing) {
|
|
// Reset count if window has passed
|
|
if (now.getTime() - existing.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
|
|
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
|
|
} else {
|
|
this.rateLimitStore.set(request.email, {
|
|
count: existing.count + 1,
|
|
lastRequest: now
|
|
});
|
|
}
|
|
} else {
|
|
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
|
|
}
|
|
}
|
|
|
|
async findByToken(token: string): Promise<PasswordResetRequest | null> {
|
|
const request = this.resetRequests.get(token);
|
|
|
|
if (!request) {
|
|
return null;
|
|
}
|
|
|
|
// Check if expired
|
|
if (request.expiresAt < new Date()) {
|
|
this.resetRequests.delete(token);
|
|
return null;
|
|
}
|
|
|
|
// Check if already used
|
|
if (request.used) {
|
|
return null;
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
async markAsUsed(token: string): Promise<void> {
|
|
const request = this.resetRequests.get(token);
|
|
if (request) {
|
|
request.used = true;
|
|
this.logger.debug('[InMemoryMagicLinkRepository] Marked token as used', {
|
|
token: token.substring(0, 10) + '...',
|
|
});
|
|
}
|
|
}
|
|
|
|
async checkRateLimit(email: string): Promise<Result<void, { message: string }>> {
|
|
const now = new Date();
|
|
const tracking = this.rateLimitStore.get(email);
|
|
|
|
if (!tracking) {
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
// Check if window has passed
|
|
if (now.getTime() - tracking.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
// Check if exceeded limit
|
|
if (tracking.count >= this.RATE_LIMIT_MAX) {
|
|
const timeRemaining = Math.ceil(
|
|
(this.RATE_LIMIT_WINDOW - (now.getTime() - tracking.lastRequest.getTime())) / 60000
|
|
);
|
|
return Result.err({
|
|
message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
|
|
});
|
|
}
|
|
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
async cleanupExpired(): Promise<void> {
|
|
const now = new Date();
|
|
const toDelete: string[] = [];
|
|
|
|
for (const [token, request] of this.resetRequests.entries()) {
|
|
if (request.expiresAt < now || request.used) {
|
|
toDelete.push(token);
|
|
}
|
|
}
|
|
|
|
toDelete.forEach(token => this.resetRequests.delete(token));
|
|
|
|
if (toDelete.length > 0) {
|
|
this.logger.debug('[InMemoryMagicLinkRepository] Cleaned up expired tokens', {
|
|
count: toDelete.length,
|
|
});
|
|
}
|
|
}
|
|
} |