From 8b5d6a50982e29b94eaa77ae2296f8925a3e4587 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:51:40 -0400 Subject: [PATCH] feat: Create `verifications` module to streamline `auth_email_*` and `auth_sms_*` --- .../1724526776_powerful_maverick.sql | 15 -- .../migrations/meta/1724526776_snapshot.json | 100 ---------- .../db/migrations/meta/_journal.json | 13 -- modules/auth_email_password/db/schema.ts | 18 -- modules/auth_email_password/module.json | 3 +- .../scripts/send_verification.ts | 18 +- .../scripts/verify_add_email_pass.ts | 20 +- .../scripts/verify_sign_up_email_pass.ts | 20 +- .../utils/code_management.ts | 76 -------- .../1724526776_powerful_maverick.sql | 15 -- .../migrations/meta/1724526776_snapshot.json | 100 ---------- modules/auth_email_passwordless/module.json | 3 +- .../scripts/send_verification.ts | 19 +- .../scripts/verify_add_no_pass.ts | 19 +- .../scripts/verify_login_or_create_no_pass.ts | 20 +- .../auth_email_passwordless/tests/common.ts | 15 +- .../auth_email_passwordless/tests/connect.ts | 8 +- .../auth_email_passwordless/tests/create.ts | 8 +- .../utils/code_management.ts | 76 -------- .../1725928555_long_steve_rogers.sql | 29 +++ .../migrations/meta/1725928555_snapshot.json | 179 ++++++++++++++++++ .../db/migrations/meta/_journal.json | 4 +- .../db/schema.ts | 18 +- modules/verifications/module.json | 60 ++++++ modules/verifications/scripts/attempt.ts | 58 ++++++ modules/verifications/scripts/create.ts | 69 +++++++ modules/verifications/scripts/get.ts | 39 ++++ modules/verifications/scripts/invalidate.ts | 26 +++ modules/verifications/tests/e2e.ts | 86 +++++++++ modules/verifications/utils/migrate.ts | 88 +++++++++ tests/basic/backend.json | 3 + tests/basic/deno.lock | 2 + 32 files changed, 751 insertions(+), 476 deletions(-) delete mode 100644 modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql delete mode 100644 modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json delete mode 100644 modules/auth_email_password/db/migrations/meta/_journal.json delete mode 100644 modules/auth_email_password/db/schema.ts delete mode 100644 modules/auth_email_password/utils/code_management.ts delete mode 100644 modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql delete mode 100644 modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json delete mode 100644 modules/auth_email_passwordless/utils/code_management.ts create mode 100644 modules/verifications/db/migrations/1725928555_long_steve_rogers.sql create mode 100644 modules/verifications/db/migrations/meta/1725928555_snapshot.json rename modules/{auth_email_passwordless => verifications}/db/migrations/meta/_journal.json (65%) rename modules/{auth_email_passwordless => verifications}/db/schema.ts (50%) create mode 100644 modules/verifications/module.json create mode 100644 modules/verifications/scripts/attempt.ts create mode 100644 modules/verifications/scripts/create.ts create mode 100644 modules/verifications/scripts/get.ts create mode 100644 modules/verifications/scripts/invalidate.ts create mode 100644 modules/verifications/tests/e2e.ts create mode 100644 modules/verifications/utils/migrate.ts diff --git a/modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql b/modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql deleted file mode 100644 index ea1087c9..00000000 --- a/modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE SCHEMA "module_auth_email"; ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "module_auth_email"."verifications" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "email" text NOT NULL, - "code" text NOT NULL, - "token" text NOT NULL, - "attempt_count" integer DEFAULT 0 NOT NULL, - "max_attempt_count" integer NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "expire_at" timestamp NOT NULL, - "completed_at" timestamp, - CONSTRAINT "verifications_code_unique" UNIQUE("code"), - CONSTRAINT "verifications_token_unique" UNIQUE("token") -); diff --git a/modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json b/modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json deleted file mode 100644 index 7639d487..00000000 --- a/modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "id": "9c7e6120-edaf-4441-880b-a2a1c21f20e3", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "module_auth_email.verifications": { - "name": "verifications", - "schema": "module_auth_email", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "code": { - "name": "code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "attempt_count": { - "name": "attempt_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "max_attempt_count": { - "name": "max_attempt_count", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expire_at": { - "name": "expire_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "verifications_code_unique": { - "name": "verifications_code_unique", - "nullsNotDistinct": false, - "columns": [ - "code" - ] - }, - "verifications_token_unique": { - "name": "verifications_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - } - } - }, - "enums": {}, - "schemas": { - "module_auth_email": "module_auth_email" - }, - "sequences": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/modules/auth_email_password/db/migrations/meta/_journal.json b/modules/auth_email_password/db/migrations/meta/_journal.json deleted file mode 100644 index 52f085c9..00000000 --- a/modules/auth_email_password/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1724526776986, - "tag": "1724526776_powerful_maverick", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/modules/auth_email_password/db/schema.ts b/modules/auth_email_password/db/schema.ts deleted file mode 100644 index 0287e367..00000000 --- a/modules/auth_email_password/db/schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { schema, Query } from "./schema.gen.ts"; - -export const verifications = schema.table('verifications', { - id: Query.uuid("id").primaryKey().defaultRandom(), - - email: Query.text("email").notNull(), - - code: Query.text("code").notNull().unique(), - token: Query.text("token").notNull().unique(), - - attemptCount: Query.integer("attempt_count").notNull().default(0), - maxAttemptCount: Query.integer("max_attempt_count").notNull(), - - createdAt: Query.timestamp("created_at").notNull().defaultNow(), - expireAt: Query.timestamp("expire_at").notNull(), - completedAt: Query.timestamp("completed_at"), -}); - diff --git a/modules/auth_email_password/module.json b/modules/auth_email_password/module.json index 73a94e72..69e2bb08 100644 --- a/modules/auth_email_password/module.json +++ b/modules/auth_email_password/module.json @@ -18,7 +18,8 @@ "users": {}, "tokens": {}, "user_passwords": {}, - "rate_limit": {} + "rate_limit": {}, + "verifications": {} }, "defaultConfig": { "fromEmail": "hello@test.com", diff --git a/modules/auth_email_password/scripts/send_verification.ts b/modules/auth_email_password/scripts/send_verification.ts index 8349234a..9da8048b 100644 --- a/modules/auth_email_password/scripts/send_verification.ts +++ b/modules/auth_email_password/scripts/send_verification.ts @@ -1,6 +1,4 @@ import { ScriptContext } from "../module.gen.ts"; -import { createVerification } from "../utils/code_management.ts"; -import { Verification } from "../utils/types.ts"; export interface Request { email: string; @@ -8,19 +6,23 @@ export interface Request { } export interface Response { - verification: Verification; + token: string; } +const HOUR_MS = 60 * 60 * 1000 * 1000; +const ATTEMPTS = 3; + export async function run( ctx: ScriptContext, req: Request, ): Promise { await ctx.modules.rateLimit.throttlePublic({}); - const { code, verification } = await createVerification( - ctx, - req.email, - ); + const { code, token } = await ctx.modules.verifications.create({ + data: { email: req.email }, + expireAt: new Date(Date.now() + HOUR_MS).toISOString(), + maxAttempts: ATTEMPTS, + }); // Send email await ctx.modules.email.sendEmail({ @@ -34,5 +36,5 @@ export async function run( html: `Your verification code is: ${code}`, }); - return { verification }; + return { token }; } diff --git a/modules/auth_email_password/scripts/verify_add_email_pass.ts b/modules/auth_email_password/scripts/verify_add_email_pass.ts index fae6c48a..943d1ee4 100644 --- a/modules/auth_email_password/scripts/verify_add_email_pass.ts +++ b/modules/auth_email_password/scripts/verify_add_email_pass.ts @@ -1,5 +1,4 @@ import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; -import { verifyCode } from "../utils/code_management.ts"; import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; @@ -10,7 +9,7 @@ export interface Request { password: string; oldPassword: string | null; - verificationToken: string; + token: string; code: string; } @@ -24,8 +23,17 @@ export async function run( // Check the verification code. If it is valid, but for the wrong email, say // the verification failed. - const { email } = await verifyCode(ctx, req.verificationToken, req.code); - if (!compareConstantTime(req.email, email)) { + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); + + if (!compareConstantTime(req.email, data.email)) { throw new RuntimeError("verification_failed"); } @@ -33,7 +41,7 @@ export async function run( const providedUser = await ctx.modules.users.authenticateToken({ userToken: req.userToken, }); - await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId])); + await ensureNotAssociatedAll(ctx, data.email, new Set([providedUser.userId])); // If an old password was provided, ensure it was correct and update it. // If one was not, register the user with the `userPasswords` module. @@ -58,7 +66,7 @@ export async function run( userToken: req.userToken, info: IDENTITY_INFO_PASSWORD, uniqueData: { - identifier: email, + identifier: data.email, }, additionalData: {}, }); diff --git a/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts b/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts index 1519a4a7..5a7d60fa 100644 --- a/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts +++ b/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts @@ -1,5 +1,4 @@ import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { verifyCode } from "../utils/code_management.ts"; import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; @@ -7,7 +6,7 @@ export interface Request { email: string; password: string; - verificationToken: string; + token: string; code: string; } @@ -23,19 +22,28 @@ export async function run( // Check the verification code. If it is valid, but for the wrong email, say // the verification failed. - const { email } = await verifyCode(ctx, req.verificationToken, req.code); - if (!compareConstantTime(req.email, email)) { + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); + + if (!compareConstantTime(req.email, data.email)) { throw new RuntimeError("verification_failed"); } // Ensure that the email is not associated with ANY accounts in ANY way. - await ensureNotAssociatedAll(ctx, email, new Set()); + await ensureNotAssociatedAll(ctx, data.email, new Set()); // Sign up the user with the passwordless email identity const { userToken, userId } = await ctx.modules.identities.signUp({ info: IDENTITY_INFO_PASSWORD, uniqueData: { - identifier: email, + identifier: data.email, }, additionalData: {}, }); diff --git a/modules/auth_email_password/utils/code_management.ts b/modules/auth_email_password/utils/code_management.ts deleted file mode 100644 index 66740b90..00000000 --- a/modules/auth_email_password/utils/code_management.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { RuntimeError, ScriptContext, Module, Database, Query } from "../module.gen.ts"; - -const MAX_ATTEMPT_COUNT = 3; -const EXPIRATION_TIME = 60 * 60 * 1000; - -export async function createVerification(ctx: ScriptContext, email: string) { - // Create verification - const code = Module.tokens.generateRandomCodeSecure("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 8); - const verification = await ctx.db.insert(Database.verifications) - .values({ - token: Module.tokens.genSecureId(), - email, - code, - maxAttemptCount: MAX_ATTEMPT_COUNT, - expireAt: new Date(Date.now() + EXPIRATION_TIME), - }) - .returning(); - - return { verification: verification[0]!, code }; -} - -export async function verifyCode( - ctx: ScriptContext, - verificationToken: string, - codeInput: string, -) { - await ctx.modules.rateLimit.throttlePublic({}); - - const code = codeInput.toUpperCase(); - - return await ctx.db.transaction(async (tx) => { - const verification = await tx.update(Database.verifications) - .set({ - attemptCount: Query.sql`${Database.verifications.attemptCount} + 1`, - }) - .where(Query.eq(Database.verifications.token, verificationToken)) - .returning(); - if (!verification[0]) { - throw new RuntimeError("verification_code_invalid"); - } - if (verification[0]!.attemptCount >= verification[0]!.maxAttemptCount) { - throw new RuntimeError("verification_code_attempt_limit"); - } - if (verification[0]!.completedAt !== null) { - throw new RuntimeError("verification_code_already_used"); - } - if (verification[0]!.code !== code) { - // Same error as above to prevent exploitation - throw new RuntimeError("verification_code_invalid"); - } - if (verification[0]!.expireAt < new Date()) { - throw new RuntimeError("verification_code_expired"); - } - - const completedAt = new Date(); - - // Mark as used - const verificationConfirmation = await tx.update(Database.verifications) - .set({ - completedAt, - }) - .where(Query.and( - Query.eq(Database.verifications.token, verificationToken), - Query.isNull(Database.verifications.completedAt) - )) - .returning(); - if (verificationConfirmation.length === 0) { - throw new RuntimeError("verification_code_already_used"); - } - - return { - email: verificationConfirmation[0]!.email, - completedAt, - }; - }); -} \ No newline at end of file diff --git a/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql b/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql deleted file mode 100644 index ea1087c9..00000000 --- a/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE SCHEMA "module_auth_email"; ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "module_auth_email"."verifications" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "email" text NOT NULL, - "code" text NOT NULL, - "token" text NOT NULL, - "attempt_count" integer DEFAULT 0 NOT NULL, - "max_attempt_count" integer NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "expire_at" timestamp NOT NULL, - "completed_at" timestamp, - CONSTRAINT "verifications_code_unique" UNIQUE("code"), - CONSTRAINT "verifications_token_unique" UNIQUE("token") -); diff --git a/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json b/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json deleted file mode 100644 index 7639d487..00000000 --- a/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "id": "9c7e6120-edaf-4441-880b-a2a1c21f20e3", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "module_auth_email.verifications": { - "name": "verifications", - "schema": "module_auth_email", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "code": { - "name": "code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "attempt_count": { - "name": "attempt_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "max_attempt_count": { - "name": "max_attempt_count", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expire_at": { - "name": "expire_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "verifications_code_unique": { - "name": "verifications_code_unique", - "nullsNotDistinct": false, - "columns": [ - "code" - ] - }, - "verifications_token_unique": { - "name": "verifications_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - } - } - }, - "enums": {}, - "schemas": { - "module_auth_email": "module_auth_email" - }, - "sequences": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/modules/auth_email_passwordless/module.json b/modules/auth_email_passwordless/module.json index 3271ccf9..3d2a5274 100644 --- a/modules/auth_email_passwordless/module.json +++ b/modules/auth_email_passwordless/module.json @@ -17,7 +17,8 @@ "identities": {}, "users": {}, "tokens": {}, - "rate_limit": {} + "rate_limit": {}, + "verifications": {} }, "defaultConfig": { "fromEmail": "hello@test.com", diff --git a/modules/auth_email_passwordless/scripts/send_verification.ts b/modules/auth_email_passwordless/scripts/send_verification.ts index 0fd3cbf6..0bf389a3 100644 --- a/modules/auth_email_passwordless/scripts/send_verification.ts +++ b/modules/auth_email_passwordless/scripts/send_verification.ts @@ -1,28 +1,29 @@ import { ScriptContext } from "../module.gen.ts"; -import { createVerification } from "../utils/code_management.ts"; import { Verification } from "../utils/types.ts"; export interface Request { email: string; userToken?: string; - fromEmail?: string; - fromName?: string; } export interface Response { - verification: Verification; + token: string; } +const HOUR_MS = 60 * 60 * 1000 * 1000; +const ATTEMPTS = 3; + export async function run( ctx: ScriptContext, req: Request, ): Promise { await ctx.modules.rateLimit.throttlePublic({}); - const { code, verification } = await createVerification( - ctx, - req.email, - ); + const { code, token } = await ctx.modules.verifications.create({ + data: { email: req.email }, + expireAt: new Date(Date.now() + HOUR_MS).toISOString(), + maxAttempts: ATTEMPTS, + }); // Send email await ctx.modules.email.sendEmail({ @@ -36,5 +37,5 @@ export async function run( html: `Your verification code is: ${code}`, }); - return { verification }; + return { token }; } diff --git a/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts b/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts index 6c21da53..bec23f20 100644 --- a/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts +++ b/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts @@ -1,10 +1,9 @@ -import { Empty, ScriptContext } from "../module.gen.ts"; -import { verifyCode } from "../utils/code_management.ts"; +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; import { IDENTITY_INFO_PASSWORDLESS, IDENTITY_INFO_LINK } from "../utils/provider.ts"; import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; export interface Request { - verificationToken: string; + token: string; code: string; userToken: string; } @@ -18,20 +17,28 @@ export async function run( await ctx.modules.rateLimit.throttlePublic({}); // Verify that the code is correct and valid - const { email } = await verifyCode(ctx, req.verificationToken, req.code); + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); // Ensure that the email is not already associated with another account const providedUser = await ctx.modules.users.authenticateToken({ userToken: req.userToken, }); - await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId])); + await ensureNotAssociatedAll(ctx, data.email, new Set([providedUser.userId])); // Add email passwordless sign in to the user's account await ctx.modules.identities.link({ userToken: req.userToken, info: ctx.config.mode === "link" ? IDENTITY_INFO_LINK : IDENTITY_INFO_PASSWORDLESS, uniqueData: { - identifier: email, + identifier: data.email, }, additionalData: {}, }); diff --git a/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts b/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts index 09f08c48..8981dd5b 100644 --- a/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts +++ b/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts @@ -1,10 +1,9 @@ import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { verifyCode } from "../utils/code_management.ts"; import { IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; export interface Request { - verificationToken: string; + token: string; code: string; } @@ -19,14 +18,23 @@ export async function run( if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); await ctx.modules.rateLimit.throttlePublic({}); - const { email } = await verifyCode(ctx, req.verificationToken, req.code); + // Verify that the code is correct and valid + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); // Try signing in with the email, and return the user token if successful. try { const signInOrUpResponse = await ctx.modules.identities.signIn({ info: IDENTITY_INFO_PASSWORDLESS, uniqueData: { - identifier: email, + identifier: data.email, }, }); @@ -40,13 +48,13 @@ export async function run( } // Ensure email is not associated to ANY account - await ensureNotAssociatedAll(ctx, email, new Set()); + await ensureNotAssociatedAll(ctx, data.email, new Set()); // Sign up the user with the passwordless email identity const signUpResponse = await ctx.modules.identities.signUp({ info: IDENTITY_INFO_PASSWORDLESS, uniqueData: { - identifier: email, + identifier: data.email, }, additionalData: {}, }); diff --git a/modules/auth_email_passwordless/tests/common.ts b/modules/auth_email_passwordless/tests/common.ts index 4a7d1895..31f372a1 100644 --- a/modules/auth_email_passwordless/tests/common.ts +++ b/modules/auth_email_passwordless/tests/common.ts @@ -1,19 +1,16 @@ -import { TestContext, Query, Database } from "../module.gen.ts"; +import { TestContext } from "../module.gen.ts"; import { assertEquals, assertExists, } from "https://deno.land/std@0.208.0/assert/mod.ts"; -export async function getVerification(ctx: TestContext, email: string) { - // Get a valid verification - const { verification: { token: verificationToken } } = await ctx.modules.authEmailPasswordless - .sendVerification({ email }); - const verification = await ctx.db.query.verifications.findFirst({ - where: Query.eq(Database.verifications.token, verificationToken), - }); +export async function getVerification(ctx: TestContext, data: { email: string }) { + const { id } = await ctx.modules.verifications.create({ data }); + + const { verification } = await ctx.modules.verifications.get({ id }); assertExists(verification); - return { verificationToken, code: verification.code }; + return verification; } export async function verifyProvider( diff --git a/modules/auth_email_passwordless/tests/connect.ts b/modules/auth_email_passwordless/tests/connect.ts index 35539210..be0bb9e7 100644 --- a/modules/auth_email_passwordless/tests/connect.ts +++ b/modules/auth_email_passwordless/tests/connect.ts @@ -16,10 +16,10 @@ test("connect_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Connect { - const { verificationToken, code } = await getVerification(ctx, email); + const { token, code } = await getVerification(ctx, { email }); await ctx.modules.authEmailPasswordless.verifyAddNoPass({ userToken, - verificationToken, + token, code, }); } @@ -28,11 +28,11 @@ test("connect_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Log in { - const { verificationToken, code } = await getVerification(ctx, email); + const { token, code } = await getVerification(ctx, { email }); const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( { - verificationToken, + token, code, }, ); diff --git a/modules/auth_email_passwordless/tests/create.ts b/modules/auth_email_passwordless/tests/create.ts index fdbb2efe..740eb6c2 100644 --- a/modules/auth_email_passwordless/tests/create.ts +++ b/modules/auth_email_passwordless/tests/create.ts @@ -13,9 +13,9 @@ test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Sign Up { - const { verificationToken, code } = await getVerification(ctx, email); + const { token, code } = await getVerification(ctx, { email }); const signUpRes = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass({ - verificationToken, + token, code, }); userToken = signUpRes.userToken; @@ -30,11 +30,11 @@ test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Log in { - const { verificationToken, code } = await getVerification(ctx, email); + const { token, code } = await getVerification(ctx, { email }); const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( { - verificationToken, + token, code, }, ); diff --git a/modules/auth_email_passwordless/utils/code_management.ts b/modules/auth_email_passwordless/utils/code_management.ts deleted file mode 100644 index 66740b90..00000000 --- a/modules/auth_email_passwordless/utils/code_management.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { RuntimeError, ScriptContext, Module, Database, Query } from "../module.gen.ts"; - -const MAX_ATTEMPT_COUNT = 3; -const EXPIRATION_TIME = 60 * 60 * 1000; - -export async function createVerification(ctx: ScriptContext, email: string) { - // Create verification - const code = Module.tokens.generateRandomCodeSecure("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 8); - const verification = await ctx.db.insert(Database.verifications) - .values({ - token: Module.tokens.genSecureId(), - email, - code, - maxAttemptCount: MAX_ATTEMPT_COUNT, - expireAt: new Date(Date.now() + EXPIRATION_TIME), - }) - .returning(); - - return { verification: verification[0]!, code }; -} - -export async function verifyCode( - ctx: ScriptContext, - verificationToken: string, - codeInput: string, -) { - await ctx.modules.rateLimit.throttlePublic({}); - - const code = codeInput.toUpperCase(); - - return await ctx.db.transaction(async (tx) => { - const verification = await tx.update(Database.verifications) - .set({ - attemptCount: Query.sql`${Database.verifications.attemptCount} + 1`, - }) - .where(Query.eq(Database.verifications.token, verificationToken)) - .returning(); - if (!verification[0]) { - throw new RuntimeError("verification_code_invalid"); - } - if (verification[0]!.attemptCount >= verification[0]!.maxAttemptCount) { - throw new RuntimeError("verification_code_attempt_limit"); - } - if (verification[0]!.completedAt !== null) { - throw new RuntimeError("verification_code_already_used"); - } - if (verification[0]!.code !== code) { - // Same error as above to prevent exploitation - throw new RuntimeError("verification_code_invalid"); - } - if (verification[0]!.expireAt < new Date()) { - throw new RuntimeError("verification_code_expired"); - } - - const completedAt = new Date(); - - // Mark as used - const verificationConfirmation = await tx.update(Database.verifications) - .set({ - completedAt, - }) - .where(Query.and( - Query.eq(Database.verifications.token, verificationToken), - Query.isNull(Database.verifications.completedAt) - )) - .returning(); - if (verificationConfirmation.length === 0) { - throw new RuntimeError("verification_code_already_used"); - } - - return { - email: verificationConfirmation[0]!.email, - completedAt, - }; - }); -} \ No newline at end of file diff --git a/modules/verifications/db/migrations/1725928555_long_steve_rogers.sql b/modules/verifications/db/migrations/1725928555_long_steve_rogers.sql new file mode 100644 index 00000000..c48da272 --- /dev/null +++ b/modules/verifications/db/migrations/1725928555_long_steve_rogers.sql @@ -0,0 +1,29 @@ +CREATE SCHEMA "module_verifications"; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "module_verifications"."old_verifications" ( + "id" uuid PRIMARY KEY NOT NULL, + "data" jsonb NOT NULL, + "code" text NOT NULL, + "token" text NOT NULL, + "attempt_count" integer NOT NULL, + "was_completed" boolean NOT NULL, + "created_at" timestamp NOT NULL, + "invalidated_at" timestamp, + "expired_at" timestamp, + "completed_at" timestamp, + CONSTRAINT "old_verifications_code_unique" UNIQUE("code"), + CONSTRAINT "old_verifications_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "module_verifications"."verifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "data" jsonb NOT NULL, + "code" text NOT NULL, + "token" text NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "max_attempt_count" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expire_at" timestamp NOT NULL, + CONSTRAINT "verifications_code_unique" UNIQUE("code"), + CONSTRAINT "verifications_token_unique" UNIQUE("token") +); diff --git a/modules/verifications/db/migrations/meta/1725928555_snapshot.json b/modules/verifications/db/migrations/meta/1725928555_snapshot.json new file mode 100644 index 00000000..a158f8d3 --- /dev/null +++ b/modules/verifications/db/migrations/meta/1725928555_snapshot.json @@ -0,0 +1,179 @@ +{ + "id": "2a03f741-e921-4b9e-af78-40490bc5d5df", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "module_verifications.old_verifications": { + "name": "old_verifications", + "schema": "module_verifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "was_completed": { + "name": "was_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invalidated_at": { + "name": "invalidated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "old_verifications_code_unique": { + "name": "old_verifications_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + }, + "old_verifications_token_unique": { + "name": "old_verifications_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "module_verifications.verifications": { + "name": "verifications", + "schema": "module_verifications", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempt_count": { + "name": "max_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expire_at": { + "name": "expire_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verifications_code_unique": { + "name": "verifications_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + }, + "verifications_token_unique": { + "name": "verifications_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + } + }, + "enums": {}, + "schemas": { + "module_verifications": "module_verifications" + }, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/modules/auth_email_passwordless/db/migrations/meta/_journal.json b/modules/verifications/db/migrations/meta/_journal.json similarity index 65% rename from modules/auth_email_passwordless/db/migrations/meta/_journal.json rename to modules/verifications/db/migrations/meta/_journal.json index 52f085c9..8fe075cf 100644 --- a/modules/auth_email_passwordless/db/migrations/meta/_journal.json +++ b/modules/verifications/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1724526776986, - "tag": "1724526776_powerful_maverick", + "when": 1725928555413, + "tag": "1725928555_long_steve_rogers", "breakpoints": true } ] diff --git a/modules/auth_email_passwordless/db/schema.ts b/modules/verifications/db/schema.ts similarity index 50% rename from modules/auth_email_passwordless/db/schema.ts rename to modules/verifications/db/schema.ts index 0287e367..174bfdd2 100644 --- a/modules/auth_email_passwordless/db/schema.ts +++ b/modules/verifications/db/schema.ts @@ -3,7 +3,7 @@ import { schema, Query } from "./schema.gen.ts"; export const verifications = schema.table('verifications', { id: Query.uuid("id").primaryKey().defaultRandom(), - email: Query.text("email").notNull(), + data: Query.jsonb("data").notNull(), code: Query.text("code").notNull().unique(), token: Query.text("token").notNull().unique(), @@ -13,6 +13,22 @@ export const verifications = schema.table('verifications', { createdAt: Query.timestamp("created_at").notNull().defaultNow(), expireAt: Query.timestamp("expire_at").notNull(), +}); + +export const oldVerifications = schema.table('old_verifications', { + id: Query.uuid("id").primaryKey(), + + data: Query.jsonb("data").notNull(), + + code: Query.text("code").notNull().unique(), + token: Query.text("token").notNull().unique(), + + attemptsCount: Query.integer("attempt_count").notNull(), + wasCompleted: Query.boolean("was_completed").notNull(), + + createdAt: Query.timestamp("created_at").notNull(), + invalidatedAt: Query.timestamp("invalidated_at"), + expiredAt: Query.timestamp("expired_at"), completedAt: Query.timestamp("completed_at"), }); diff --git a/modules/verifications/module.json b/modules/verifications/module.json new file mode 100644 index 00000000..2a0be285 --- /dev/null +++ b/modules/verifications/module.json @@ -0,0 +1,60 @@ +{ + "status": "stable", + "name": "Verifications", + "description": "INTERNAL ONLY module — helper for OTP verifications (email/sms)", + "icon": "key", + "tags": [ + "auth" + ], + "authors": [ + "Blckbrry-Pi" + ], + "dependencies": { + "tokens": {} + }, + "scripts": { + "create": { + "name": "Create Verification", + "public": false + }, + "attempt": { + "name": "Attempt to Complete Verification", + "public": false + }, + "invalidate": { + "name": "Invalidate Verification", + "public": false + }, + "get": { + "name": "Get Verification Information", + "public": false + } + }, + "errors": { + "unable_to_generate_unique_code": { + "name": "Unable to Generate Unique Code", + "description": "When generating a new code for a new verification, the script was unable to find a unique one.", + "internal": true + }, + "no_verification_found": { + "name": "No verification found matching the required parameters", + "description": "When attempting to find a verification, the database returned no matching results.", + "internal": true + }, + "failed_to_create": { + "name": "Failed to Create New Verification", + "description": "There was a database error while creating a new verification.", + "internal": true + }, + "failed_to_update": { + "name": "Failed to Update Existing Verification", + "description": "There was a database error while updating the information on an existing verification.", + "internal": true + }, + "unknown_err": { + "name": "Unknown Error", + "description": "There was an unexpected error of an unknown type.", + "internal": true + } + } +} \ No newline at end of file diff --git a/modules/verifications/scripts/attempt.ts b/modules/verifications/scripts/attempt.ts new file mode 100644 index 00000000..076fa2b2 --- /dev/null +++ b/modules/verifications/scripts/attempt.ts @@ -0,0 +1,58 @@ +import { ScriptContext, Query, Database, RuntimeError } from "../module.gen.ts"; +import { complete, moveToOldIfNecessary } from "../utils/migrate.ts"; + +export interface Request { + token: string; + code: string; +} + +export interface Response { + succeeded: boolean; + canTryAgain: boolean; + data: unknown; +} + +export async function run(ctx: ScriptContext, req: Request): Promise { + try { + const [verification] = await ctx.db.select() + .from(Database.verifications) + .where(Query.eq(Database.verifications.token, req.token)); + + if (!verification) throw new RuntimeError("no_verification_found"); + if (await moveToOldIfNecessary(ctx, verification.code)) throw new RuntimeError("no_verification_found"); + + let codeDiffers = Number(req.code.length !== verification.code.length); + for (let i = 0; i < verification.code.length; i++) { + codeDiffers |= verification.code.charCodeAt(i) ^ req.code.charCodeAt(i); + } + + if (codeDiffers) { + await ctx.db.update(Database.verifications) + .set({ + attemptCount: Query.sql`${Database.verifications.attemptCount} + 1`, + }) + .where(Query.eq(Database.verifications.id, verification.id)).returning(); + const canTryAgain = !await moveToOldIfNecessary(ctx, verification.code); + return { + succeeded: false, + canTryAgain, + data: verification.data, + } + } else { + await complete(ctx, verification.id); + return { + succeeded: true, + canTryAgain: false, + data: verification.data, + } + } + } catch (e) { + if (e instanceof RuntimeError) { + throw e; + } else if (e instanceof Query.DrizzleError) { + throw new RuntimeError("failed_to_update"); + } else { + throw new RuntimeError("unknown_err"); + } + } +} \ No newline at end of file diff --git a/modules/verifications/scripts/create.ts b/modules/verifications/scripts/create.ts new file mode 100644 index 00000000..dd1b6905 --- /dev/null +++ b/modules/verifications/scripts/create.ts @@ -0,0 +1,69 @@ +import { ScriptContext, Module, Database, RuntimeError, Query } from "../module.gen.ts"; +import { moveToOldIfNecessary } from "../utils/migrate.ts"; + +export interface Request { + data: unknown; + maxAttempts?: number; + expireAt?: string; +} + +export interface Response { + id: string; + code: string; + token: string; +} + +// This is very generous— we should definitely flush old verifications however +const MAX_ATTEMPTS = 20; + +export async function run(ctx: ScriptContext, req: Request): Promise { + const failedCodes = []; + try { + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // Generate code + token cryptographically using the `tokens` public + // functions + const code = Module.tokens.generateRandomCodeSecure("0123456789", 8); + const token = Module.tokens.genSecureId(32, Module.tokens.SecureIdFormat.HEX); + + // Default to PG INT_MAX number of attempts + const maxAttemptCount = req.maxAttempts ?? 0x7FFFFFFF; + + // If expiry is left unspecified, set for 1 day from now + const expireAt = req.expireAt ? new Date(req.expireAt) : new Date(Date.now() + 24 * 60 * 60 * 1000); + + const [verification] = await ctx.db.insert(Database.verifications).values({ + // The identifying data for the insertion. Not necessarily unique. + data: req.data, + + code, + token, + maxAttemptCount, + expireAt, + }).returning({ + id: Database.verifications.id, + code: Database.verifications.code, + token: Database.verifications.token, + }).onConflictDoNothing({ + target: Database.verifications.code, + }); + + if (!verification) { + failedCodes.push(code); + continue; + } + return verification; + } + + throw new RuntimeError("unable_to_generate_unique_code"); + } catch (e) { + if (e instanceof RuntimeError) { + throw e; + } else if (e instanceof Query.DrizzleError) { + throw new RuntimeError("failed_to_create"); + } else { + throw new RuntimeError("unknown_err"); + } + } finally { + await Promise.all(failedCodes.map(code => moveToOldIfNecessary(ctx, code))); + } +} diff --git a/modules/verifications/scripts/get.ts b/modules/verifications/scripts/get.ts new file mode 100644 index 00000000..dde724c1 --- /dev/null +++ b/modules/verifications/scripts/get.ts @@ -0,0 +1,39 @@ +import { ScriptContext, Query, Database, RuntimeError, UnreachableError } from "../module.gen.ts"; +import { Verification } from "../utils/migrate.ts"; + +interface IdRequest { + id: string; +} +interface TokenRequest { + token: string; +} +interface DataRequest { + data: {}; +} + +export type Request = IdRequest | TokenRequest | DataRequest; + +export interface Response { + verification?: Verification; +} + +export async function run(ctx: ScriptContext, req: Request): Promise { + let where: Query.SQL; + if ("id" in req) { + where = Query.eq(Database.verifications.id, req.id); + } else if ("token" in req) { + where = Query.eq(Database.verifications.token, req.token); + } else if ("data" in req) { + where = Query.eq(Database.verifications.data, req.data); + } else { + throw new UnreachableError(req); + } + try { + const [verification] = await ctx.db.select() + .from(Database.verifications) + .where(where); + return { verification }; + } catch (e) { + throw new RuntimeError("unknown_err"); + } +} \ No newline at end of file diff --git a/modules/verifications/scripts/invalidate.ts b/modules/verifications/scripts/invalidate.ts new file mode 100644 index 00000000..06908934 --- /dev/null +++ b/modules/verifications/scripts/invalidate.ts @@ -0,0 +1,26 @@ +import { ScriptContext, Query, RuntimeError } from "../module.gen.ts"; +import { invalidate } from "../utils/migrate.ts"; + +export interface Request { + token: string; +} + +export interface Response { + data: unknown; +} + +export async function run(ctx: ScriptContext, req: Request): Promise { + try { + const { data } = await invalidate(ctx, req.token); + + return { data }; + } catch (e) { + if (e instanceof RuntimeError) { + throw e; + } else if (e instanceof Query.DrizzleError) { + throw new RuntimeError("failed_to_update"); + } else { + throw new RuntimeError("unknown_err"); + } + } +} diff --git a/modules/verifications/tests/e2e.ts b/modules/verifications/tests/e2e.ts new file mode 100644 index 00000000..e83667eb --- /dev/null +++ b/modules/verifications/tests/e2e.ts @@ -0,0 +1,86 @@ +import { test, TestContext, RuntimeError } from "../module.gen.ts"; +import { assert, assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; + +test("right_code_first_time", async (ctx: TestContext) => { + const data = { email: faker.internet.email() }; + const createdVerification = await ctx.modules.verifications.create({ data }); + const attemptResult = await ctx.modules.verifications.attempt({ + token: createdVerification.token, + code: createdVerification.code, + }); + + assert(attemptResult.succeeded); + assert(!attemptResult.canTryAgain); + assertEquals(attemptResult.data, data); +}); + +test("wrong_code_fail", async (ctx: TestContext) => { + const data = { email: faker.internet.email() }; + + const createdVerification = await ctx.modules.verifications.create({ data }); + const attemptResult = await ctx.modules.verifications.attempt({ + token: createdVerification.token, + code: "AAAAAAAA", + }); + + assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have"); + assert(attemptResult.canTryAgain, "Verification should have more than 1 try"); + assertEquals(attemptResult.data, data, "Verification data did not match"); +}); + +test("overattempted", async (ctx: TestContext) => { + const data = { email: faker.internet.email() }; + + const MAX_ATTEMPTS = 5; + + const createdVerification = await ctx.modules.verifications.create({ + data, + maxAttempts: MAX_ATTEMPTS, + }); + + for (let i = 0; i < MAX_ATTEMPTS - 1; i++) { + const attemptResult = await ctx.modules.verifications.attempt({ + token: createdVerification.token, + code: "AAAAAAAA", + }); + + assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have"); + assert(attemptResult.canTryAgain, "Verification ran out of tries earlier than it should have"); + assertEquals(attemptResult.data, data, "Verification data did not match"); + } + + const attemptResult = await ctx.modules.verifications.attempt({ + token: createdVerification.token, + code: "AAAAAAAA", + }); + assert(!attemptResult.succeeded, "Verification succeeded when it shouldn't have"); + assert(!attemptResult.canTryAgain, "Verification should be out of tries"); + assertEquals(attemptResult.data, data, "Verification data did not match"); + + + const err = await assertRejects(() => ctx.modules.verifications.attempt({ + token: createdVerification.token, + code: "AAAAAAAA", + }), RuntimeError); + + assertEquals(err.code, "no_verification_found"); +}); + +test("get_all_methods", async (ctx: TestContext) => { + const data = { email: faker.internet.email() }; + + const createdVerification = await ctx.modules.verifications.create({ data }); + const getById = await ctx.modules.verifications.get({ + id: createdVerification.id, + }); + const getByToken = await ctx.modules.verifications.get({ + token: createdVerification.token, + }); + const getByData = await ctx.modules.verifications.get({ + data, + }); + + assertEquals(getById, getByToken); + assertEquals(getByToken, getByData); +}); diff --git a/modules/verifications/utils/migrate.ts b/modules/verifications/utils/migrate.ts new file mode 100644 index 00000000..a87bdcb9 --- /dev/null +++ b/modules/verifications/utils/migrate.ts @@ -0,0 +1,88 @@ +import { Database, ModuleContext, Query, RuntimeError } from "../module.gen.ts"; + +export type Verification = typeof Database.verifications.$inferSelect; + +export function verificationIsInvalid(verification: typeof Database.verifications.$inferSelect): boolean { + const expired = verification.expireAt.getTime() > Date.now(); + const overattempted = verification.attemptCount > verification.maxAttemptCount; + + return expired || overattempted; +} + +export async function moveToOldIfNecessary(ctx: Ctx, code: string): Promise { + return await ctx.db.transaction(async tx => { + const [deleted] = await tx.delete(Database.verifications).where(Query.and( + Query.eq(Database.verifications.code, code), + Query.or( + Query.lte(Database.verifications.expireAt, new Date()), + Query.gte(Database.verifications.attemptCount, Database.verifications.maxAttemptCount), + ), + )).returning(); + if (!deleted) return false; + + await tx.insert(Database.oldVerifications).values({ + id: deleted.id, + data: deleted.data, + code: deleted.code, + token: deleted.token, + attemptsCount: deleted.attemptCount, + wasCompleted: false, + createdAt: deleted.createdAt, + invalidatedAt: deleted.attemptCount >= deleted.maxAttemptCount ? new Date() : null, + expiredAt: deleted.expireAt.getTime() >= Date.now() ? deleted.expireAt : null, + completedAt: null, + }); + + return true; + }) +} + +export async function complete(ctx: Ctx, id: string): Promise { + return await ctx.db.transaction(async tx => { + const [deleted] = await tx.delete(Database.verifications).where( + Query.eq(Database.verifications.id, id), + ).returning(); + + if (!deleted) throw new RuntimeError("no_verification_found"); + + await tx.insert(Database.oldVerifications).values({ + id: deleted.id, + data: deleted.data, + code: deleted.code, + token: deleted.token, + attemptsCount: deleted.attemptCount, + wasCompleted: true, + createdAt: deleted.createdAt, + invalidatedAt: null, + expiredAt: null, + completedAt: new Date(), + }); + + return deleted; + }) +} + +export async function invalidate(ctx: Ctx, token: string): Promise { + return await ctx.db.transaction(async tx => { + const [deleted] = await tx.delete(Database.verifications).where( + Query.eq(Database.verifications.token, token), + ).returning(); + + if (!deleted) throw new RuntimeError("no_verification_found"); + + await tx.insert(Database.oldVerifications).values({ + id: deleted.id, + data: deleted.data, + code: deleted.code, + token: deleted.token, + attemptsCount: deleted.attemptCount, + wasCompleted: false, + createdAt: deleted.createdAt, + invalidatedAt: new Date(), + expiredAt: null, + completedAt: null, + }); + + return deleted; + }) +} diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 039ade2e..203dfa34 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -96,6 +96,9 @@ }, "auth_oauth": { "registry": "local" + }, + "verifications": { + "registry": "local" } } } diff --git a/tests/basic/deno.lock b/tests/basic/deno.lock index 969c62be..6214b960 100644 --- a/tests/basic/deno.lock +++ b/tests/basic/deno.lock @@ -2534,6 +2534,8 @@ "https://esm.sh/v135/tslib@2.6.2/denonext/tslib.mjs": "29782bcd3139f77ec063dc5a9385c0fff4a8d0a23b6765c73d9edeb169a04bf1", "https://esm.sh/v135/tslib@2.6.3/denonext/tslib.mjs": "0834c22e9fbf95f6a5659cc2017543f7d41aa880f24ab84cb11d24e6bee99303", "https://esm.sh/v135/uuid@9.0.1/denonext/uuid.mjs": "7d7d3aa57fa136e2540886654c416d9da10d8cfebe408bae47fd47070f0bfb2a", + "https://esm.sh/v135/zod-validation-error@3.3.0/denonext/zod-validation-error.mjs": "4efabd593e1430c31a044f79d299a62120946a3e701159b29922b50f3223c186", + "https://esm.sh/v135/zod@3.23.8/denonext/zod.mjs": "b3707b03ddc01aab11b740436ab23c0fcc8d15fed072be20085c1fd611016b61", "https://esm.sh/zod-validation-error@3.3.0": "d8825ca67952b6adff6b35026dc465f9638d4923dbd54fe9e8e81fbfddca9630", "https://esm.sh/zod@3.23.8": "728819c1f651800179a5a80daf24b3e54b2ddea87828bd10e63875a604bcb94e" }