Skip to content

Commit

Permalink
feat(email-verification): rate-limit
Browse files Browse the repository at this point in the history
  • Loading branch information
adrienZ committed Oct 7, 2024
1 parent 0c285f8 commit 58ed8e9
Show file tree
Hide file tree
Showing 9 changed files with 254 additions and 139 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,11 @@ You should have your migrations in the migrations folder.
- [x] Email + Password
- [x] forgot password
- [x] reset password
- [ ] rate-limit login
- [ ] rate-limit email verification
- [x] rate-limit login
- [x] rate-limit email verification
- [ ] rate-limit forgot password
- [ ] rate-limit reset password
- [ ] rate limit register
- [x] ~~rate limit register~~ (rate-limit ask email verification)
- [ ] error message strategy (email already taken, etc)
- [ ] oauth accounts linking
- [ ] ~~Ihavebeenpwnd plugin~~
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@
"drizzle-orm": "^0.33.0",
"drizzle-schema-checker": "^1.2.0",
"node-ipinfo": "^3.5.3",
"oslo": "^1.2.1",
"rate-limiter-flexible": "^5.0.3"
"oslo": "^1.2.1"
},
"devDependencies": {
"@nuxt/devtools": "^1.5.2",
Expand Down
146 changes: 55 additions & 91 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

47 changes: 39 additions & 8 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 { InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitLoginError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js";
import { EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitAskEmailVerificationError, 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 @@ -248,7 +248,19 @@ export class SlipAuthCore {
throw new Error("could not find oauth user");
}

/**
* Make sure to set the Referrer Policy tag to strict-origin (or equivalent) for any path that includes tokens to protect the tokens from referer leakage.
*/
public async askEmailVerificationCode(event: H3Event, { user }: { user: SlipAuthUser }): Promise<void> {
// rate limit any function that leads to send email
const [isNotRateLimited, rateLimitResult] = await this.#rateLimiters.askEmailVerification.check(user.id);
if (!isNotRateLimited) {
throw new RateLimitAskEmailVerificationError({
msBeforeNext: (rateLimitResult.updatedAt + rateLimitResult.timeout * 1000) - Date.now(),
});
}

await this.#rateLimiters.askEmailVerification.increment(user.id);
await this.#repos.emailVerificationCodes.deleteAllByUserId(user.id);
await this.#repos.emailVerificationCodes.insert({
userId: user.id,
Expand All @@ -259,28 +271,40 @@ export class SlipAuthCore {
}

// TODO: use transactions
// TODO: rate limit
public async verifyEmailVerificationCode(h3Event: H3Event, params: { user: SlipAuthUser, code: string }): Promise<boolean> {
public async verifyEmailVerificationCode(h3Event: H3Event, params: { user: SlipAuthUser, code: string }): Promise<true> {
// TODO add where clause with code
// TODO add where clause with email ?
const databaseCode = await this.#repos.emailVerificationCodes.findByUserId({ userId: params.user.id });
if (!databaseCode || databaseCode.code !== params.code) {
return false;
throw new EmailVerificationFailedError();
}

this.#repos.emailVerificationCodes.deleteById(databaseCode.id);
// rate limit any function that leads to send email
const [isNotRateLimited, rateLimitResult] = await this.#rateLimiters.verifyEmailVerification.check(databaseCode.user_id);

if (!isNotRateLimited) {
throw new RateLimitVerifyEmailVerificationError({
msBeforeNext: (rateLimitResult.updatedAt + rateLimitResult.timeout * 1000) - Date.now(),
});
}

this.#repos.emailVerificationCodes.deleteById(databaseCode.id);
const expirationDate = databaseCode.expires_at instanceof Date ? databaseCode.expires_at : new Date(databaseCode.expires_at);
const offset = expirationDate.getTimezoneOffset() * 60000; // Get local time zone offset in milliseconds
const localExpirationDate = new Date(expirationDate.getTime() - offset); // Adjust for local time zone

if (!isWithinExpirationDate(localExpirationDate)) {
return false;
await this.#rateLimiters.verifyEmailVerification.increment(databaseCode.user_id);
throw new EmailVerificationCodeExpiredError();
}

if (databaseCode.email !== params.user.email) {
return false;
await this.#rateLimiters.verifyEmailVerification.increment(databaseCode.user_id);
throw new EmailVerificationFailedError();
}

await this.#repos.users.updateEmailVerifiedByUserId({ userId: databaseCode.user_id, value: true });
// should recreate session if 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).
return true;
}

Expand Down Expand Up @@ -381,6 +405,13 @@ export class SlipAuthCore {
setLoginRateLimiter: (fn: () => Storage) => {
this.#rateLimiters.login.storage = fn();
},

setAskEmailRateLimiter: (fn: () => Storage) => {
this.#rateLimiters.askEmailVerification.storage = fn();
},
setVerifyEmailRateLimiter: (fn: () => Storage) => {
this.#rateLimiters.verifyEmailVerification.storage = fn();
},
};

public getUser({ userId }: { userId: string }) {
Expand Down
51 changes: 36 additions & 15 deletions src/runtime/core/errors/SlipAuthError.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { SlipAuthErrorsCode } from "./SlipAuthErrorsCode";

export class SlipAuthError extends Error {
slipError!: SlipAuthErrorsCode;
slipErrorCode!: SlipAuthErrorsCode;
slipErrorName!: string;
};

export class SlipAuthRateLimiterError extends SlipAuthError {
Expand All @@ -16,13 +17,13 @@ export class SlipAuthRateLimiterError extends SlipAuthError {
}

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

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

Expand All @@ -33,25 +34,45 @@ export class InvalidEmailOrPasswordError extends SlipAuthError {
}

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

export class EmailVerificationFailedError extends SlipAuthError {
override slipErrorName = "EmailVerificationFailedError";
override slipErrorCode = SlipAuthErrorsCode.EmailVerificationFailedError;
}

export class EmailVerificationCodeExpiredError extends SlipAuthError {
override slipErrorName = "EmailVerificationCodeExpiredError";
override slipErrorCode = SlipAuthErrorsCode.EmailVerificationCodeExpired;
}

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

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

export class RateLimitLoginError extends SlipAuthRateLimiterError {
override name = "RateLimitLoginError";
override slipError = SlipAuthErrorsCode.RateLimitLogin;
override slipErrorName = "RateLimitLoginError";
override slipErrorCode = SlipAuthErrorsCode.RateLimitLogin;
}

export class RateLimitAskEmailVerificationError extends SlipAuthRateLimiterError {
override slipErrorName = "RateLimitAskEmailVerificationError";
override slipErrorCode = SlipAuthErrorsCode.RateLimitAskEmailVerification;
}

export class RateLimitVerifyEmailVerificationError extends SlipAuthRateLimiterError {
override slipErrorName = "RateLimitVerifyEmailVerificationError";
override slipErrorCode = SlipAuthErrorsCode.RateLimitVerifyEmailVerification;
}
4 changes: 4 additions & 0 deletions src/runtime/core/errors/SlipAuthErrorsCode.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export enum SlipAuthErrorsCode {
Unhandled = "Unhandled",
InvalidEmailOrPassword = "InvalidEmailOrPassword",
EmailVerificationFailedError = "EmailVerificationFailed",
EmailVerificationCodeExpired = "EmailVerificationCodeExpired",
InvalidEmailToResetPassword = "InvalidEmailToResetPassword",
InvalidUserIdToResetPassword = "InvalidUserIdToResetPassword",
InvalidPasswordToReset = "InvalidPasswordToReset",
ResetPasswordTokenExpired = "ResetPasswordTokenExpired",
RateLimitLogin = "RateLimitLogin",
RateLimitAskEmailVerification = "RateLimitAskEmailVerification",
RateLimitVerifyEmailVerification = "RateLimitVerifyEmailVerification",
}
15 changes: 14 additions & 1 deletion src/runtime/core/rate-limit/SlipAuthRateLimiters.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { Throttler, createThrottlerStorage } from "./Throttler";
import { prefixStorage } from "unstorage";

export class SlipAuthRateLimiters {
login: Throttler;
askEmailVerification: Throttler;
verifyEmailVerification: Throttler;

constructor() {
this.login = new Throttler({
timeoutSeconds: [0, 1, 2, 4, 8, 16, 30, 60, 180, 300],
storage: createThrottlerStorage(),
storage: prefixStorage(createThrottlerStorage(), "slip:rate:login"),
});

this.askEmailVerification = new Throttler({
timeoutSeconds: [0, 2, 4, 8, 32, 60, 180, 240, 480, 720],
storage: prefixStorage(createThrottlerStorage(), "slip:rate:ask-email-verification"),
});

this.verifyEmailVerification = new Throttler({
timeoutSeconds: [0, 1, 2, 4, 8, 16, 30, 60, 180, 300],
storage: prefixStorage(createThrottlerStorage(), "slip:rate:verify-email-verification"),
});
}
}
2 changes: 1 addition & 1 deletion src/runtime/core/rate-limit/Throttler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class Throttler {
return [true];
}
else {
return [false, counter];
return [false, { updatedAt: counter.updatedAt, timeout: this.timeoutSeconds[counter.timeout] }];
}
}

Expand Down
Loading

0 comments on commit 58ed8e9

Please sign in to comment.