auth
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { InMemoryMagicLinkRepository } from './InMemoryMagicLinkRepository';
|
||||
|
||||
const mockLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
describe('InMemoryMagicLinkRepository', () => {
|
||||
let repository: InMemoryMagicLinkRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryMagicLinkRepository(mockLogger as any);
|
||||
});
|
||||
|
||||
describe('createPasswordResetRequest', () => {
|
||||
it('should create a password reset request', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
|
||||
const found = await repository.findByToken('abc123');
|
||||
expect(found).toEqual(request);
|
||||
});
|
||||
|
||||
it('should enforce rate limiting', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'token1',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Create 3 requests for same email
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
|
||||
|
||||
// 4th should fail
|
||||
const result = await repository.checkRateLimit('test@example.com');
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow requests after time window expires', async () => {
|
||||
const now = Date.now();
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'token1',
|
||||
expiresAt: new Date(now + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Mock Date.now to return time after rate limit window
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => now + (16 * 60 * 1000); // 16 minutes later
|
||||
|
||||
try {
|
||||
await repository.createPasswordResetRequest(request);
|
||||
const found = await repository.findByToken('token1');
|
||||
expect(found).toBeDefined();
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByToken', () => {
|
||||
it('should find existing token', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
const found = await repository.findByToken('abc123');
|
||||
|
||||
expect(found).toEqual(request);
|
||||
});
|
||||
|
||||
it('should return null for non-existent token', async () => {
|
||||
const found = await repository.findByToken('nonexistent');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsUsed', () => {
|
||||
it('should mark token as used', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
await repository.markAsUsed('abc123');
|
||||
|
||||
const found = await repository.findByToken('abc123');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle non-existent token gracefully', async () => {
|
||||
await expect(repository.markAsUsed('nonexistent')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow requests under limit', async () => {
|
||||
const result = await repository.checkRateLimit('test@example.com');
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject requests over limit', async () => {
|
||||
const email = 'test@example.com';
|
||||
const request = {
|
||||
email,
|
||||
token: 'token',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Create 3 requests
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
|
||||
|
||||
const result = await repository.checkRateLimit(email);
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryMagicLinkRepository implements IMagicLinkRepository {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user