Skip to content

Commit

Permalink
feat(ask-reset-password): add rate limit
Browse files Browse the repository at this point in the history
  • Loading branch information
adrienZ committed Oct 7, 2024
1 parent ccf54a4 commit f2e26fa
Show file tree
Hide file tree
Showing 10 changed files with 72 additions and 9 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 18 additions & 2 deletions src/runtime/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand All @@ -331,6 +340,10 @@ export class SlipAuthCore {
throw new InvalidUserIdToResetPasswordError();
}

if (error instanceof RateLimitAskResetPasswordError) {
throw error;
}

throw new UnhandledError();
}
}
Expand Down Expand Up @@ -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 }) {
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/core/errors/SlipAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions src/runtime/core/errors/SlipAuthErrorsCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum SlipAuthErrorsCode {
RateLimitLogin = "RateLimitLogin",
RateLimitAskEmailVerification = "RateLimitAskEmailVerification",
RateLimitVerifyEmailVerification = "RateLimitVerifyEmailVerification",
RateLimitAskResetPassword = "RateLimitAskResetPassword",
}
5 changes: 5 additions & 0 deletions src/runtime/core/rate-limit/SlipAuthRateLimiters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export class SlipAuthRateLimiters {
login: Throttler;
askEmailVerification: Throttler;
verifyEmailVerification: Throttler;
askResetPassword: Throttler;

constructor() {
this.login = new Throttler({
Expand All @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/database/sqlite/schema.sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/h3/routes/register.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/plugins/auto-setup.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 39 additions & 2 deletions tests/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -176,7 +176,7 @@ describe("rate limit", () => {
});
});

describe("verify email verification", () => {
describe("verify reset password", () => {
const verifyEmailVerificationTestStorage = createThrottlerStorage();
const askEmailVerificationTestStorage = createThrottlerStorage();

Expand Down Expand Up @@ -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,
},
});
});
});
});
2 changes: 1 addition & 1 deletion tests/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit f2e26fa

Please sign in to comment.