diff --git a/README.md b/README.md index 739c92c..2c20bc6 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ You should have your migrations in the migrations folder. - [x] reset password - [x] rate-limit login - [x] rate-limit email verification - - [ ] rate-limit forgot password + - [x] rate-limit forgot password - [ ] rate-limit reset password - [x] ~~rate limit register~~ (rate-limit ask email verification) - [ ] error message strategy (email already taken, etc) diff --git a/src/runtime/core/core.ts b/src/runtime/core/core.ts index 4efedb6..6cd971e 100644 --- a/src/runtime/core/core.ts +++ b/src/runtime/core/core.ts @@ -10,7 +10,7 @@ import { EmailVerificationCodesRepository } from "./repositories/EmailVerificati import { ResetPasswordTokensRepository } from "./repositories/ResetPasswordTokensRepository"; import type { SlipAuthPublicSession } from "../types"; import { defaultIdGenerationMethod, isValidEmail, defaultEmailVerificationCodeGenerationMethod, defaultHashPasswordMethod, defaultVerifyPasswordMethod, defaultResetPasswordTokenIdMethod, defaultResetPasswordTokenHashMethod } from "./email-and-password-utils"; -import { EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitAskEmailVerificationError, RateLimitLoginError, RateLimitVerifyEmailVerificationError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js"; +import { EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitAskEmailVerificationError, RateLimitAskResetPasswordError, RateLimitLoginError, RateLimitVerifyEmailVerificationError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js"; import type { Database } from "db0"; import { createDate, isWithinExpirationDate, TimeSpan } from "oslo"; import type { H3Event } from "h3"; @@ -304,7 +304,7 @@ export class SlipAuthCore { } await this.#repos.users.updateEmailVerifiedByUserId({ userId: databaseCode.user_id, value: true }); - // All sessions should be invalidated when the email is verified (and create a new one for the current user so they stay signed in). + // TODO: All sessions should be invalidated when the email is verified (and create a new one for the current user so they stay signed in). return true; } @@ -313,6 +313,15 @@ export class SlipAuthCore { * SHA-256 can be used here since the token is long and random, unlike user passwords. */ public async askPasswordReset(h3Event: H3Event, params: { userId: string }) { + // rate limit any function that leads to send email + const [isNotRateLimited, rateLimitResult] = await this.#rateLimiters.askResetPassword.check(params.userId); + if (!isNotRateLimited) { + throw new RateLimitAskResetPasswordError({ + msBeforeNext: (rateLimitResult.updatedAt + rateLimitResult.timeout * 1000) - Date.now(), + }); + } + + await this.#rateLimiters.askResetPassword.increment(params.userId); // optionally invalidate all existing tokens // this.#repos.resetPasswordTokens.deleteAllByUserId(userId); const tokenId = defaultResetPasswordTokenIdMethod(); @@ -331,6 +340,10 @@ export class SlipAuthCore { throw new InvalidUserIdToResetPasswordError(); } + if (error instanceof RateLimitAskResetPasswordError) { + throw error; + } + throw new UnhandledError(); } } @@ -412,6 +425,9 @@ export class SlipAuthCore { setVerifyEmailRateLimiter: (fn: () => Storage) => { this.#rateLimiters.verifyEmailVerification.storage = fn(); }, + setAskResetPasswordRateLimiter: (fn: () => Storage) => { + this.#rateLimiters.askResetPassword.storage = fn(); + }, }; public getUser({ userId }: { userId: string }) { diff --git a/src/runtime/core/errors/SlipAuthError.ts b/src/runtime/core/errors/SlipAuthError.ts index 308bedb..7d618f8 100644 --- a/src/runtime/core/errors/SlipAuthError.ts +++ b/src/runtime/core/errors/SlipAuthError.ts @@ -76,3 +76,7 @@ export class RateLimitVerifyEmailVerificationError extends SlipAuthRateLimiterEr override slipErrorName = "RateLimitVerifyEmailVerificationError"; override slipErrorCode = SlipAuthErrorsCode.RateLimitVerifyEmailVerification; } +export class RateLimitAskResetPasswordError extends SlipAuthRateLimiterError { + override slipErrorName = "RateLimitAskResetPasswordError"; + override slipErrorCode = SlipAuthErrorsCode.RateLimitAskResetPassword; +} diff --git a/src/runtime/core/errors/SlipAuthErrorsCode.ts b/src/runtime/core/errors/SlipAuthErrorsCode.ts index 834c053..49f0b55 100644 --- a/src/runtime/core/errors/SlipAuthErrorsCode.ts +++ b/src/runtime/core/errors/SlipAuthErrorsCode.ts @@ -10,4 +10,5 @@ export enum SlipAuthErrorsCode { RateLimitLogin = "RateLimitLogin", RateLimitAskEmailVerification = "RateLimitAskEmailVerification", RateLimitVerifyEmailVerification = "RateLimitVerifyEmailVerification", + RateLimitAskResetPassword = "RateLimitAskResetPassword", } diff --git a/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts b/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts index dc6850c..daf13ee 100644 --- a/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts +++ b/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts @@ -5,6 +5,7 @@ export class SlipAuthRateLimiters { login: Throttler; askEmailVerification: Throttler; verifyEmailVerification: Throttler; + askResetPassword: Throttler; constructor() { this.login = new Throttler({ @@ -16,6 +17,10 @@ export class SlipAuthRateLimiters { timeoutSeconds: [0, 2, 4, 8, 32, 60, 180, 240, 480, 720], storage: prefixStorage(createThrottlerStorage(), "slip:rate:ask-email-verification"), }); + this.askResetPassword = new Throttler({ + timeoutSeconds: [0, 2, 4, 8, 32, 60, 180, 240, 480, 720], + storage: prefixStorage(createThrottlerStorage(), "slip:rate:ask-reset-password"), + }); this.verifyEmailVerification = new Throttler({ timeoutSeconds: [0, 1, 2, 4, 8, 16, 30, 60, 180, 300], diff --git a/src/runtime/database/sqlite/schema.sqlite.ts b/src/runtime/database/sqlite/schema.sqlite.ts index afe2738..8d29c5a 100644 --- a/src/runtime/database/sqlite/schema.sqlite.ts +++ b/src/runtime/database/sqlite/schema.sqlite.ts @@ -52,7 +52,7 @@ export const getEmailVerificationCodesTableSchema = (tableNames: tableNames) => export const getPasswordResetTokensTableSchema = (tableNames: tableNames) => sqliteTable(tableNames.resetPasswordTokens, { token_hash: text("token_hash").primaryKey().notNull(), - user_id: text("user_id").unique() + user_id: text("user_id") .references(() => getUsersTableSchema(tableNames).id) .notNull(), expires_at: integer("expires_at", { mode: "timestamp" }).notNull(), diff --git a/src/runtime/h3/routes/register.post.ts b/src/runtime/h3/routes/register.post.ts index dd213c1..1e9b817 100644 --- a/src/runtime/h3/routes/register.post.ts +++ b/src/runtime/h3/routes/register.post.ts @@ -2,7 +2,7 @@ import { SlipAuthError } from "../../core/errors/SlipAuthError"; import { useSlipAuth } from "../../server/utils/useSlipAuth"; import { defineEventHandler, readBody, getHeader, createError } from "h3"; -// TODO: prevent login when user is already logged +// TODO: prevent register when user is already logged export default defineEventHandler(async (event) => { const body = await readBody(event); const auth = useSlipAuth(); diff --git a/src/runtime/server/plugins/auto-setup.plugin.ts b/src/runtime/server/plugins/auto-setup.plugin.ts index aa7176f..83fd574 100644 --- a/src/runtime/server/plugins/auto-setup.plugin.ts +++ b/src/runtime/server/plugins/auto-setup.plugin.ts @@ -75,7 +75,7 @@ export default defineNitroPlugin(async (nitro: NitroApp) => { await db.prepare(` CREATE TABLE IF NOT EXISTS ${config.tableNames.resetPasswordTokens} ( "token_hash" TEXT NOT NULL PRIMARY KEY, - "user_id" TEXT NOT NULL UNIQUE, + "user_id" TEXT NOT NULL, "expires_at" TIMESTAMP NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts index 374cda3..5c3fbf3 100644 --- a/tests/rate-limit.test.ts +++ b/tests/rate-limit.test.ts @@ -3,7 +3,7 @@ import sqlite from "db0/connectors/better-sqlite3"; import { createDatabase } from "db0"; import { SlipAuthCore } from "../src/runtime/core/core"; import { autoSetupTestsDatabase, createH3Event, testTablesNames } from "./test-helpers"; -import { EmailVerificationCodeExpired, EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, RateLimitAskEmailVerificationError, RateLimitLoginError, RateLimitVerifyEmailVerificationError } from "../src/runtime/core/errors/SlipAuthError"; +import { EmailVerificationFailedError, InvalidEmailOrPasswordError, RateLimitAskEmailVerificationError, RateLimitAskResetPasswordError, RateLimitLoginError, RateLimitVerifyEmailVerificationError } from "../src/runtime/core/errors/SlipAuthError"; import { createThrottlerStorage } from "../src/runtime/core/rate-limit/Throttler"; const db = createDatabase(sqlite({ @@ -176,7 +176,7 @@ describe("rate limit", () => { }); }); - describe("verify email verification", () => { + describe("verify reset password", () => { const verifyEmailVerificationTestStorage = createThrottlerStorage(); const askEmailVerificationTestStorage = createThrottlerStorage(); @@ -230,4 +230,41 @@ describe("rate limit", () => { }); }); }); + + describe("ask reset password", () => { + const askResetPasswordTestStorage = createThrottlerStorage(); + + beforeEach(async () => { + await askResetPasswordTestStorage.clear(); + auth.setters.setAskResetPasswordRateLimiter(() => askResetPasswordTestStorage); + }); + + it("should rate-limit", async () => { + const [userId] = await auth.register(createH3Event(), defaultInsert); + const doAttempt = () => auth.askPasswordReset(createH3Event(), { userId }); + + vi.useFakeTimers(); + + const t1 = await doAttempt(); + expect(t1).not.toBeInstanceOf(Error); + + const t2 = await doAttempt(); + expect(t2).not.toBeInstanceOf(Error); + + const t3 = doAttempt(); + await expect(t3).rejects.toBeInstanceOf(RateLimitAskResetPasswordError); + + vi.advanceTimersByTime(2000); + + const t4 = await doAttempt(); + expect(t4).not.toBeInstanceOf(Error); + + const t5 = await doAttempt().catch(e => JSON.parse(JSON.stringify(e))); + expect(t5).toMatchObject({ + data: { + msBeforeNext: 4000, + }, + }); + }); + }); }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 44bee61..95b93dc 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -23,7 +23,7 @@ export async function autoSetupTestsDatabase(db: Database) { await db.sql`CREATE TABLE IF NOT EXISTS slip_sessions ("id" TEXT NOT NULL PRIMARY KEY, "expires_at" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "ip" TEXT, "ua" TEXT, FOREIGN KEY (user_id) REFERENCES slip_users(id))`; await db.sql`CREATE TABLE IF NOT EXISTS slip_oauth_accounts ("provider_id" TEXT NOT NULL, "provider_user_id" TEXT NOT NULL, "user_id" TEXT NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (provider_id, provider_user_id), FOREIGN KEY (user_id) REFERENCES slip_users(id))`; await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_email_verification_codes ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "user_id" TEXT NOT NULL UNIQUE, "email" TEXT NOT NULL, "code" TEXT NOT NULL, "expires_at" TIMESTAMP NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(id))`; - await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_reset_password_tokens ("token_hash" TEXT PRIMARY KEY, "user_id" TEXT NOT NULL UNIQUE, "expires_at" TIMESTAMP NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(id))`; + await db.sql`CREATE TABLE IF NOT EXISTS slip_auth_reset_password_tokens ("token_hash" TEXT PRIMARY KEY, "user_id" TEXT NOT NULL, "expires_at" TIMESTAMP NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES slip_users(id))`; }; export function createH3Event(): H3Event {