Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
adrienZ committed Oct 7, 2024
1 parent b77c432 commit 17c5ff0
Show file tree
Hide file tree
Showing 13 changed files with 340 additions and 72 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ You should have your migrations in the migrations folder.
[npm-version-href]: https://npmjs.com/package/nuxt-slip-auth
[npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-slip-auth.svg?style=flat&colorA=020420&colorB=00DC82
[npm-downloads-href]: https://npmjs.com/package/nuxt-slip-auth
[npm-downloads-href]: https://npm.chart.dev/nuxt-slip-auth
[license-src]: https://img.shields.io/npm/l/nuxt-slip-auth.svg?style=flat&colorA=020420&colorB=00DC82
[license-href]: https://npmjs.com/package/nuxt-slip-auth
Expand Down
1 change: 1 addition & 0 deletions data/ratelimiter/login/mRQRawPZmPFiJZ1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.5
1 change: 1 addition & 0 deletions data/ratelimiter/login/user-id-1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.5
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"drizzle-orm": "^0.33.0",
"drizzle-schema-checker": "^1.2.0",
"node-ipinfo": "^3.5.3",
"oslo": "^1.2.1"
"oslo": "^1.2.1",
"rate-limiter-flexible": "^5.0.3"
},
"devDependencies": {
"@nuxt/devtools": "^1.5.2",
Expand Down
146 changes: 91 additions & 55 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion src/runtime/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ 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 { InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js";
import { InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitLoginError, ResetPasswordTokenExpiredError, SlipAuthRateLimiterError, UnhandledError } from "./errors/SlipAuthError.js";
import type { Database } from "db0";
import { createDate, isWithinExpirationDate, TimeSpan } from "oslo";
import type { H3Event } from "h3";
import { SlipAuthRateLimiters } from "./rate-limit/SlipAuthRateLimiters";

export class SlipAuthCore {
readonly #db: Database;
Expand All @@ -38,6 +39,8 @@ export class SlipAuthCore {
verify: defaultVerifyPasswordMethod,
};

#rateLimiters = new SlipAuthRateLimiters();

readonly schemas: SchemasMockValue;
readonly hooks = createSlipHooks();

Expand Down Expand Up @@ -111,10 +114,17 @@ export class SlipAuthCore {
throw new InvalidEmailOrPasswordError("no password oauth user");
}

const rateLimitRes = await this.#rateLimiters.login.consumeIncremental(existingUser.id);
if (rateLimitRes instanceof SlipAuthRateLimiterError) {
throw new RateLimitLoginError(rateLimitRes.data);
}

const validPassword = await this.#passwordHashingMethods.verify(existingUser.password, password);
if (!validPassword) {
throw new InvalidEmailOrPasswordError("login invalid password");
}

await this.#rateLimiters.login.delete(existingUser.id);
const sessionToLoginId = this.#createRandomSessionId();
const sessionToLogin = await this.#repos.sessions.insert({
sessionId: sessionToLoginId,
Expand Down
22 changes: 16 additions & 6 deletions src/runtime/core/errors/SlipAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@ export class SlipAuthError extends Error {
slipError!: SlipAuthErrorsCode;
};

export class SlipAuthRateLimiterError extends SlipAuthError {
data: {
msBeforeNext?: number
};

constructor(data: { msBeforeNext?: number }) {
super();
this.data = data;
}
}

export class UnhandledError extends SlipAuthError {
override name = "InvalidEmailOrPasswordError";
override message = "InvalidEmailOrPasswordError";
override slipError = SlipAuthErrorsCode.Unhandled;
}

export class InvalidEmailOrPasswordError extends SlipAuthError {
override slipError = SlipAuthErrorsCode.InvalidEmailOrPassword;
override name = "InvalidEmailOrPasswordError";
override message = "InvalidEmailOrPasswordError";
// eslint-disable-next-line no-unused-private-class-members
#debugReason: string;

Expand All @@ -25,23 +34,24 @@ export class InvalidEmailOrPasswordError extends SlipAuthError {

export class InvalidEmailToResetPasswordError extends SlipAuthError {
override name = "InvalidEmailToResetPasswordError";
override message = "InvalidEmailToResetPasswordError";
override slipError = SlipAuthErrorsCode.InvalidEmailToResetPassword;
}

export class InvalidUserIdToResetPasswordError extends SlipAuthError {
override name = "InvalidUserIdToResetPasswordError";
override message = "InvalidUserIdToResetPasswordError";
override slipError = SlipAuthErrorsCode.InvalidUserIdToResetPassword;
}

export class InvalidPasswordToResetError extends SlipAuthError {
override name = "InvalidPasswordToResetError";
override message = "InvalidPasswordToResetError";
override slipError = SlipAuthErrorsCode.InvalidPasswordToReset;
}
export class ResetPasswordTokenExpiredError extends SlipAuthError {
override name = "ResetPasswordTokenExpiredError";
override message = "ResetPasswordTokenExpiredError";
override slipError = SlipAuthErrorsCode.ResetPasswordTokenExpired;
}

export class RateLimitLoginError extends SlipAuthRateLimiterError {
override name = "RateLimitLoginError";
override slipError = SlipAuthErrorsCode.RateLimitLogin;
}
1 change: 1 addition & 0 deletions src/runtime/core/errors/SlipAuthErrorsCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export enum SlipAuthErrorsCode {
InvalidUserIdToResetPassword = "InvalidUserIdToResetPassword",
InvalidPasswordToReset = "InvalidPasswordToReset",
ResetPasswordTokenExpired = "ResetPasswordTokenExpired",
RateLimitLogin = "RateLimitLogin",
}
15 changes: 15 additions & 0 deletions src/runtime/core/rate-limit/SlipAuthRateLimiters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Throttler, createThrottlerStorage } from "./Throttler";

export class SlipAuthRateLimiters {
login: Throttler;

constructor() {
this.login = new Throttler({
points: 5,
duration: 0,
},
{
storage: createThrottlerStorage("./data/ratelimiter/login"),
});
}
}
53 changes: 53 additions & 0 deletions src/runtime/core/rate-limit/Throttler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { RateLimiterMemory, RateLimiterRes, type IRateLimiterOptions } from "rate-limiter-flexible";
import { createStorage, type Storage } from "unstorage";
import fsDriver from "unstorage/drivers/fs";
import { SlipAuthRateLimiterError } from "../errors/SlipAuthError";

export function createThrottlerStorage(base: string = "./.data/cache/ratelimit"): Storage<number> {
return createStorage<number>({
driver: fsDriver({ base }),
});
}

export class Throttler extends RateLimiterMemory {
storage: Storage<number>;
initialBlockDurationSeconds = 5;
#incrementFactor = 2;

constructor(depOptions: IRateLimiterOptions, options: { storage: Storage<number>, initialBlockDurationSeconds?: number }) {
super(depOptions);
this.storage = options.storage;
this.initialBlockDurationSeconds = options.initialBlockDurationSeconds ?? this.initialBlockDurationSeconds;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async consumeIncremental(key: string | number, pointsToConsume?: number, options?: { [key: string]: any }): Promise<RateLimiterRes | SlipAuthRateLimiterError> {
const strKey = key.toString();
const cachedBlockDuration = await this.storage.getItem(strKey) ?? (this.initialBlockDurationSeconds / this.#incrementFactor);

return super.consume(strKey, pointsToConsume, options)
.then((res) => {
this.storage.setItem(strKey, cachedBlockDuration);

if (res.remainingPoints <= 0) {
this.block(strKey, cachedBlockDuration);
}

return res;
})
.catch((error) => {
let msBeforeNext;

if (error instanceof RateLimiterRes && error.remainingPoints === 0) {
// Increase block duration and ensure it stays within the 32-bit signed integer range
const newBlockDuration = Math.min(cachedBlockDuration * 2, Number.MAX_SAFE_INTEGER);
this.storage.setItem(strKey, newBlockDuration);
msBeforeNext = newBlockDuration * 1000;
}

return new SlipAuthRateLimiterError({
msBeforeNext: msBeforeNext,
});
});
}
}
6 changes: 4 additions & 2 deletions src/runtime/h3/routes/login.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SlipAuthError } from "../../core/errors/SlipAuthError";
import { SlipAuthRateLimiterError } from "../../core/errors/SlipAuthError";
import { useSlipAuth } from "../../server/utils/useSlipAuth";
import { defineEventHandler, readBody, getHeader, createError } from "h3";

Expand Down Expand Up @@ -32,7 +32,9 @@ export default defineEventHandler(async (event) => {
catch (error) {
throw createError({
...(error instanceof Error ? error : {}),
data: error instanceof SlipAuthError ? error : undefined,
data: {
...(error instanceof SlipAuthRateLimiterError ? error.data : {}),
},
});
}
});
52 changes: 46 additions & 6 deletions tests/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import sqlite from "db0/connectors/better-sqlite3";
import { createDatabase } from "db0";
import { SlipAuthCore } from "../src/runtime/core/core";
import type { SlipAuthUser } from "~/src/runtime/core/types";
import { autoSetupTestsDatabase, createH3Event, testTablesNames } from "./test-helpers";
import { RateLimitLoginError, SlipAuthRateLimiterError } from "../src/runtime/core/errors/SlipAuthError";

const db = createDatabase(sqlite({
name: "core-email-password.test",
name: "rate-limit.test",
}));

let auth: SlipAuthCore;
Expand All @@ -15,6 +15,11 @@ beforeEach(async () => {
await autoSetupTestsDatabase(db);
});

const defaultInsert = {
email: "[email protected]",
password: "pa$$word",
};

const mocks = vi.hoisted(() => {
return {
userCreatedCount: 0,
Expand Down Expand Up @@ -63,9 +68,44 @@ describe("rate limit", () => {
});

describe("login", () => {
it("should work", async () => {
auth.login(createH3Event(), { email: "[email protected]", password: "password" });
auth.login(createH3Event(), { email: "[email protected]", password: "password" });
it.only("should allow 5 failed tries", async () => {
await auth.register(createH3Event(), defaultInsert);
const doAttempt = () => auth.login(createH3Event(), {
email: defaultInsert.email,
password: defaultInsert.password + "123",
});

const results = await Promise.all([
doAttempt(),
doAttempt(),
doAttempt(),
doAttempt(),
doAttempt(),
]);

expect(results.every(res => res instanceof SlipAuthRateLimiterError)).toBe(false);
});

it("should rate-limit 6 failed tries", async () => {
await auth.register(createH3Event(), defaultInsert);
const doAttempt = () => auth.login(createH3Event(), {
email: defaultInsert.email,
password: defaultInsert.password + "123",
}).catch(e => e);

const results = await Promise.all([
doAttempt(),
doAttempt(),
doAttempt(),
doAttempt(),
doAttempt(),
doAttempt(),
]);

console.log(results);


expect(results.every(res => res instanceof RateLimitLoginError)).toBe(false);
});
});
});
98 changes: 98 additions & 0 deletions tests/throttler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Throttler } from "../src/runtime/core/rate-limit/Throttler";
import { SlipAuthRateLimiterError } from "../src/runtime/core/errors/SlipAuthError";
import { RateLimiterRes } from "rate-limiter-flexible";
import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory";

const date = new Date(Date.UTC(1998, 11, 19));

const storage = createStorage<number>({
driver: memoryDriver(),
});

describe("Throttler", () => {
beforeEach(() => {
storage.clear();
});

it("should allow X failed attemps", async () => {
const attempsLimit = 5;

const rateLimit = new Throttler({
points: attempsLimit,
duration: 20,
},
{
storage,
});

const attemptsArray = Array.from(Array(attempsLimit).keys());
const attemptsResults = await Promise.all(attemptsArray.map(async () => {
return await rateLimit.consumeIncremental("hey");
}));

expect(attemptsResults.every(_result => _result instanceof RateLimiterRes)).toBe(true);
});

it("should not allow X + 1 failed attemps", async () => {
const attempsLimit = 5;

const rateLimit = new Throttler({
points: attempsLimit,
duration: 20,
},
{
storage,
});

const attemptsArray = Array.from(Array(attempsLimit + 1).keys());
const attemptsResults = await Promise.all(attemptsArray.map(async () => {
return await rateLimit.consumeIncremental("hey");
}));

const allAttemptsButLast = attemptsResults.slice(0, -1);
const lastAttempt = attemptsResults[attemptsResults.length - 1];

expect(allAttemptsButLast.every(_result => _result instanceof RateLimiterRes)).toBe(true);
expect(lastAttempt instanceof SlipAuthRateLimiterError).toBe(true);
expect((lastAttempt as SlipAuthRateLimiterError).data.msBeforeNext).toBe(5000);
});

it("should increment timeout on failed attemps", async () => {
vi.useFakeTimers();
vi.setSystemTime(date);

const attempsLimit = 5;

const rateLimit = new Throttler({
points: attempsLimit,
duration: 20,
},
{
storage,
initialBlockDurationSeconds: 1,
});

const attemptsArray = Array.from(Array(attempsLimit).keys());
await Promise.all(attemptsArray.map(async () => {
return await rateLimit.consumeIncremental("hey");
}));

// Now, exceed the limit and verify block duration increments correctly
let expectedMsBeforeNext = 1000; // Initial block duration in ms (1 second)
for (let i = 0; i < 20; i++) {
// vi.advanceTimersByTime(expectedMsBeforeNext);
const result = await rateLimit.consumeIncremental("hey") as SlipAuthRateLimiterError;

// Expect SlipAuthRateLimiterError after limit is reached
expect(result instanceof SlipAuthRateLimiterError).toBe(true);
expect(result.data.msBeforeNext).toBe(
expectedMsBeforeNext,
);

// Double the expected block duration for the next iteration
expectedMsBeforeNext = Math.min(expectedMsBeforeNext * 2, Number.MAX_SAFE_INTEGER);
}
});
});

0 comments on commit 17c5ff0

Please sign in to comment.