Skip to content
This repository has been archived by the owner on Sep 17, 2024. It is now read-only.

Commit

Permalink
feat: impl basic auth (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
NathanFlurry committed Mar 13, 2024
1 parent 174206b commit a2a1150
Show file tree
Hide file tree
Showing 44 changed files with 2,239 additions and 128 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ env:
jobs:
build:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
# Checkout registry repo
- name: Checkout registry Repo
Expand Down
5 changes: 5 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"fmt": {
"useTabs": true
}
}
8 changes: 8 additions & 0 deletions modules/auth/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Config {
email?: EmailConfig;
}

export interface EmailConfig {
fromEmail: string;
fromName?: string;
}
48 changes: 48 additions & 0 deletions modules/auth/db/migrations/20240310214734_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- CreateTable
CREATE TABLE "Identity" (
"userId" UUID NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deletedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Identity_pkey" PRIMARY KEY ("userId")
);

-- CreateTable
CREATE TABLE "EmailPasswordless" (
"id" UUID NOT NULL,
"identityId" UUID NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "EmailPasswordless_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "EmailPasswordlessVerification" (
"id" UUID NOT NULL,
"identityId" UUID,
"email" TEXT NOT NULL,
"code" TEXT NOT NULL,
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"maxAttemptCount" INTEGER NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expireAt" TIMESTAMP NOT NULL,
"completedAt" TIMESTAMP,

CONSTRAINT "EmailPasswordlessVerification_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "EmailPasswordless_email_key" ON "EmailPasswordless"("email");

-- CreateIndex
CREATE UNIQUE INDEX "EmailPasswordlessVerification_code_key" ON "EmailPasswordlessVerification"("code");

-- AddForeignKey
ALTER TABLE "EmailPasswordless" ADD CONSTRAINT "EmailPasswordless_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailPasswordless"("email") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE SET NULL ON UPDATE CASCADE;
12 changes: 12 additions & 0 deletions modules/auth/db/migrations/20240312024843_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `identityId` on the `EmailPasswordlessVerification` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_identityId_fkey";

-- AlterTable
ALTER TABLE "EmailPasswordlessVerification" DROP COLUMN "identityId",
ADD COLUMN "userId" UUID;
2 changes: 2 additions & 0 deletions modules/auth/db/migrations/20240312033322_/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- DropForeignKey
ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_email_fkey";
21 changes: 21 additions & 0 deletions modules/auth/db/migrations/20240312035811_/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `identityId` on the `EmailPasswordless` table. All the data in the column will be lost.
- You are about to drop the `Identity` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[userId]` on the table `EmailPasswordless` will be added. If there are existing duplicate values, this will fail.
- Added the required column `userId` to the `EmailPasswordless` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "EmailPasswordless" DROP CONSTRAINT "EmailPasswordless_identityId_fkey";

-- AlterTable
ALTER TABLE "EmailPasswordless" DROP COLUMN "identityId",
ADD COLUMN "userId" UUID NOT NULL;

-- DropTable
DROP TABLE "Identity";

-- CreateIndex
CREATE UNIQUE INDEX "EmailPasswordless_userId_key" ON "EmailPasswordless"("userId");
3 changes: 3 additions & 0 deletions modules/auth/db/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
30 changes: 30 additions & 0 deletions modules/auth/db/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model EmailPasswordless {
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @unique
email String @unique
createdAt DateTime @default(now()) @db.Timestamp
}

model EmailPasswordlessVerification {
id String @id @default(uuid()) @db.Uuid
// If exists, link to existing identity. If null, create new identity.
userId String? @db.Uuid
email String
// Code the user has to input to verify the email
code String @unique
attemptCount Int @default(0)
maxAttemptCount Int
createdAt DateTime @default(now()) @db.Timestamp
expireAt DateTime @db.Timestamp
completedAt DateTime? @db.Timestamp
}
16 changes: 16 additions & 0 deletions modules/auth/module.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
dependencies:
email: {}
users: {}
rate_limit: {}
scripts:
auth_email_passwordless:
public: true
verify_email_passwordless:
public: true
errors:
PROVIDER_DISABLED: {}
VERIFICATION_CODE_INVALID: {}
VERIFICATION_CODE_ATTEMPT_LIMIT: {}
VERIFICATION_CODE_EXPIRED: {}
VERIFICATION_CODE_ALREADY_USED: {}
EMAIL_ALREADY_USED: {}
76 changes: 76 additions & 0 deletions modules/auth/scripts/auth_email_passwordless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { RuntimeError } from "../_gen/mod.ts";
import { ScriptContext } from "../_gen/scripts/auth_email_passwordless.ts";
import { Verification } from "../utils/types.ts";

export interface Request {
email: string;
userToken?: string;
}

export interface Response {
verification: Verification;
}

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
if (!ctx.userConfig.email) throw new RuntimeError("PROVIDER_DISABLED");

await ctx.modules.rateLimit.throttle({});

// Fetch existing user if session token is provided
let userId: string | undefined;
if (req.userToken) {
const { userId } = await ctx.modules.users.validateUserToken({
userToken: req.userToken,
});

// Check if the email is already associated with an identity
const existingIdentity = await ctx.db.emailPasswordless.findFirst({
where: { email: req.email },
});
if (existingIdentity && existingIdentity.userId !== userId) {
throw new RuntimeError("EMAIL_ALREADY_USED");
}
}

// Create verification
const code = generateCode();
const maxAttemptCount = 3;
const expiration = 60 * 60 * 1000;
const verification = await ctx.db.emailPasswordlessVerification.create({
data: {
userId,
email: req.email,
code,
maxAttemptCount,
expireAt: new Date(Date.now() + expiration),
},
select: { id: true },
});

// Send email
await ctx.modules.email.sendEmail({
from: {
email: ctx.userConfig.email.fromEmail ?? "[email protected]",
name: ctx.userConfig.email.fromName ?? "Authentication Code",
},
to: [{ email: req.email }],
subject: "Your verification code",
text: `Your verification code is: ${code}`,
html: `Your verification code is: <b>${code}</b>`,
});

return { verification };
}

function generateCode(): string {
const length = 8;
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
106 changes: 106 additions & 0 deletions modules/auth/scripts/verify_email_passwordless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { assertExists } from "https://deno.land/[email protected]/assert/mod.ts";
import {
RuntimeError,
ScriptContext,
} from "../_gen/scripts/verify_email_passwordless.ts";
import { TokenWithSecret } from "../../tokens/types/common.ts";

export interface Request {
verificationId: string;
code: string;
}

export interface Response {
token: TokenWithSecret;
}

export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
await ctx.modules.rateLimit.throttle({});

const code = req.code.toUpperCase();

// Validate & mark as used
let userId: string | undefined;
await ctx.db.$transaction(async (tx) => {
const verification = await tx.emailPasswordlessVerification.update({
where: {
id: req.verificationId,
},
data: {
attemptCount: {
increment: 1,
},
},
select: {
email: true,
userId: true,
code: true,
expireAt: true,
completedAt: true,
attemptCount: true,
maxAttemptCount: true,
},
});
if (!verification) {
throw new RuntimeError("VERIFICATION_CODE_INVALID");
}
if (verification.attemptCount >= verification.maxAttemptCount) {
throw new RuntimeError("VERIFICATION_CODE_ATTEMPT_LIMIT");
}
if (verification.completedAt !== null) {
throw new RuntimeError("VERIFICATION_CODE_ALREADY_USED");
}
if (verification.code !== code) {
// Same error as above to prevent exploitation
throw new RuntimeError("VERIFICATION_CODE_INVALID");
}
if (verification.expireAt < new Date()) {
throw new RuntimeError("VERIFICATION_CODE_EXPIRED");
}

// Mark as used
const verificationConfirmation = await tx.emailPasswordlessVerification
.update({
where: {
id: req.verificationId,
completedAt: null,
},
data: {
completedAt: new Date(),
},
});
if (verificationConfirmation === null) {
throw new RuntimeError("VERIFICATION_CODE_ALREADY_USED");
}

// Get or create user
if (verification.userId) {
userId = verification.userId;
} else {
const { user } = await ctx.modules.users.createUser({});
userId = user.id;
}

// Create identity
await tx.emailPasswordless.upsert({
where: {
email: verification.email,
userId,
},
create: {
email: verification.email,
userId,
},
update: {},
});
});
assertExists(userId);

// Create token
const { token } = await ctx.modules.users.createUserToken({ userId });

return { token };
}
22 changes: 22 additions & 0 deletions modules/auth/tests/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, TestContext } from "../_gen/test.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
import { faker } from "https://deno.land/x/[email protected]/mod.ts";

test("e2e", async (ctx: TestContext) => {
const authRes = await ctx.modules.auth.authEmailPasswordless({
email: faker.internet.email(),
});

// Look up correct code
const { code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({
where: {
id: authRes.verification.id,
},
});

const verifyRes = await ctx.modules.auth.verifyEmailPasswordless({
verificationId: authRes.verification.id,
code: code,
});
assertEquals(verifyRes.token.type, "user");
});
8 changes: 8 additions & 0 deletions modules/auth/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Verification {
id: string;
}

export interface Session {
token: string;
expireAt: string;
}
2 changes: 1 addition & 1 deletion modules/currency/scripts/get_balance_by_token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function run(
): Promise<Response> {
await ctx.modules.rateLimit.throttle({ requests: 25 });

const { userId } = await ctx.modules.users.validateToken({
const { userId } = await ctx.modules.users.validateUserToken({
userToken: req.userToken,
});
const { balance } = await ctx.modules.currency.getBalance({ userId });
Expand Down
Loading

0 comments on commit a2a1150

Please sign in to comment.