From 771abf1c3b1e009357a769b5dba7f1c44acd76af Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Thu, 17 Oct 2024 12:38:25 -0400 Subject: [PATCH 01/11] User Auth Template --- src/index.ts | 56 ++++++++++++++- templates/auth/package.json | 16 +++++ templates/auth/src/index.ts | 87 ++++++++++++++++++++++++ templates/auth/worker-configuration.d.ts | 6 ++ templates/auth/wrangler.toml | 22 ++++++ worker-configuration.d.ts | 3 +- wrangler.toml | 10 ++- 7 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 templates/auth/package.json create mode 100644 templates/auth/src/index.ts create mode 100644 templates/auth/worker-configuration.d.ts create mode 100644 templates/auth/wrangler.toml diff --git a/src/index.ts b/src/index.ts index 6015112..847d9e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,16 @@ import { importTableFromJsonRoute } from './import/json'; const DURABLE_OBJECT_ID = 'sql-durable-object'; +interface Env { + AUTHORIZATION_TOKEN: string; + DATABASE_DURABLE_OBJECT: DurableObjectNamespace; + STUDIO_USER?: string; + STUDIO_PASS?: string; + AUTH: { + handleAuth(pathname: string, verb: string, body: any): Promise; + } +} + export class DatabaseDurableObject extends DurableObject { // Durable storage for the SQL database public sql: SqlStorage; @@ -37,7 +47,7 @@ export class DatabaseDurableObject extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.sql = ctx.storage.sql; - + // Initialize LiteREST for handling /lite routes this.liteREST = new LiteREST( ctx, @@ -47,6 +57,33 @@ export class DatabaseDurableObject extends DurableObject { ); } + /** + * Execute a raw SQL query on the database, typically used for external requests + * from other service bindings (e.g. auth). This serves as an exposed function for + * other service bindings to query the database without having to have knowledge of + * the current operation queue or processing state. + * + * @param sql - The SQL query to execute. + * @param params - Optional parameters for the SQL query. + * @returns A response containing the query result or an error message. + */ + async executeExternalQuery(sql: string, params: any[] | undefined) { + try { + const queries = [{ sql, params }]; + const response = await enqueueOperation( + queries, + false, + false, + this.operationQueue, + () => processNextOperation(this.sql, this.operationQueue, this.ctx, this.processingOperation) + ); + + return createResponseFromOperationResponse(response); + } catch (error: any) { + return createResponse(undefined, error.error || 'An unexpected error occurred.', error.status || 500); + } + } + async queryRoute(request: Request, isRaw: boolean): Promise { try { const contentType = request.headers.get('Content-Type') || ''; @@ -207,6 +244,7 @@ export default { * @returns The response to be sent back to the client */ async fetch(request, env, ctx): Promise { + const pathname = new URL(request.url).pathname; const isWebSocket = request.headers.get("Upgrade") === "websocket"; /** @@ -215,7 +253,7 @@ export default { * Studio provides a user interface to interact with the SQLite database in the Durable * Object. */ - if (env.STUDIO_USER && env.STUDIO_PASS && request.method === 'GET' && new URL(request.url).pathname === '/studio') { + if (env.STUDIO_USER && env.STUDIO_PASS && request.method === 'GET' && pathname === '/studio') { return handleStudioRequest(request, { username: env.STUDIO_USER, password: env.STUDIO_PASS, @@ -250,6 +288,20 @@ export default { let id: DurableObjectId = env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); let stub = env.DATABASE_DURABLE_OBJECT.get(id); + /** + * If the pathname starts with /auth, we want to pass the request off to another Worker + * that is responsible for handling authentication. + */ + if (pathname.startsWith('/auth')) { + // return new Response(`Auth ENV ${JSON.stringify(env)}`, {status: 200}); + // const body = await request.json(); + // console.log("Auth Request: ", body); + console.log("Auth Request: ", request); + return await env.AUTH.handleAuth(pathname, request.method, {}); + } + + console.log('SHOULD NOT SEE THIS'); + /** * Pass the fetch request directly to the Durable Object, which will handle the request * and return a response to be sent back to the client. diff --git a/templates/auth/package.json b/templates/auth/package.json new file mode 100644 index 0000000..82d112a --- /dev/null +++ b/templates/auth/package.json @@ -0,0 +1,16 @@ +{ + "name": "starbasedb-auth", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "cf-typegen": "wrangler types" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.2", + "wrangler": "^3.60.3" + } +} \ No newline at end of file diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts new file mode 100644 index 0000000..93c4905 --- /dev/null +++ b/templates/auth/src/index.ts @@ -0,0 +1,87 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + +const DURABLE_OBJECT_ID = 'sql-durable-object'; + +export default class AuthEntrypoint extends WorkerEntrypoint { + // Currently, entrypoints without a named handler are not supported + async fetch() { return new Response(null, {status: 404}); } + + // TEMPORARY: Setup auth tables via a shell script instead of in here. + async setupAuthTables() { + let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); + let stub = this.env.DATABASE_DURABLE_OBJECT.get(id); + + const createUserTableQuery = ` + CREATE TABLE IF NOT EXISTS auth_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL + ); + `; + + const createSessionTableQuery = ` + CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + session_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES auth_users (id) + ); + `; + + // Make a request to the binded database + let response = await stub.executeExternalQuery(`${createUserTableQuery} ${createSessionTableQuery}`, []); + return response; + } + + async handleAuth(pathname: string, verb: string, body: any) { + console.log('Handling Auth in Service Binding: ', body) + + await this.setupAuthTables(); + + if (verb === "POST" && pathname === "/auth/signup") { + return this.handleSignup(body); + } + + return new Response(null, {status: 405}); + } + + async handleSignup(body: any) { + console.log("Handling Signup: ", body); + + let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); + let stub = this.env.DATABASE_DURABLE_OBJECT.get(id); + + // Make a request to the binded database + let response = await stub.executeExternalQuery('SELECT * FROM users', []); + return response; + } + + async handleVerifyEmail(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } + + async handleResendEmail(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } + + async handleForgotPassword(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } + + async handleResetPassword(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } + + async handleLogin(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } + + async handleLogout(request: Request) { + return new Response(`${request.json()}`, {status: 200}); + } +} diff --git a/templates/auth/worker-configuration.d.ts b/templates/auth/worker-configuration.d.ts new file mode 100644 index 0000000..5fc3b34 --- /dev/null +++ b/templates/auth/worker-configuration.d.ts @@ -0,0 +1,6 @@ +// Generated by Wrangler by running `wrangler types` + +interface Env { + REQUIRE_EMAIL_CONFIRM: 1; + DATABASE_DURABLE_OBJECT: DurableObjectNamespace /* DatabaseDurableObject from starbasedb */; +} diff --git a/templates/auth/wrangler.toml b/templates/auth/wrangler.toml new file mode 100644 index 0000000..ee21ad2 --- /dev/null +++ b/templates/auth/wrangler.toml @@ -0,0 +1,22 @@ +name = "starbasedb_auth" +main = "./src/index.ts" +compatibility_date = "2024-09-25" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +#[[durable_objects.bindings]] +#name = "DATABASE_DURABLE_OBJECT" +#class_name = "DatabaseDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +#[[migrations]] +#tag = "v1" +#new_sqlite_classes = ["DatabaseDurableObject"] # Array of new classes + +[durable_objects] +bindings = [{ name = "DATABASE_DURABLE_OBJECT", class_name = "DatabaseDurableObject", script_name = "starbasedb" }] + +[vars] +REQUIRE_EMAIL_CONFIRM = 1 \ No newline at end of file diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index f144958..8c9d1bc 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -2,7 +2,6 @@ interface Env { AUTHORIZATION_TOKEN: "ABC123"; - STUDIO_USER?: string; - STUDIO_PASS?: string; DATABASE_DURABLE_OBJECT: DurableObjectNamespace; + AUTH: Fetcher; } diff --git a/wrangler.toml b/wrangler.toml index 5b2804c..f1323ca 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -4,6 +4,12 @@ main = "src/index.ts" compatibility_date = "2024-09-25" account_id = "" +# Service Bindings +[[services]] +binding = "AUTH" +service = "starbasedb_auth" +entrypoint = "AuthEntrypoint" + # Workers Logs # Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ # Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs @@ -28,5 +34,5 @@ AUTHORIZATION_TOKEN = "ABC123" # Uncomment the section below to create a user for logging into your database UI. # You can access the Studio UI at: https://your_endpoint/studio -# STUDIO_USER = "admin" -# STUDIO_PASS = "123456" \ No newline at end of file +STUDIO_USER = "admin" +STUDIO_PASS = "123456" From afc557a79707638ce6e6d4041f0cb489474d57ee Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Thu, 17 Oct 2024 16:52:20 -0400 Subject: [PATCH 02/11] Allow SQL commands to be executed for basic signup --- src/index.ts | 16 ++-- templates/auth/src/index.ts | 116 ++++++++++++++++++++--- templates/auth/src/utils.ts | 19 ++++ templates/auth/worker-configuration.d.ts | 5 + templates/auth/wrangler.toml | 20 ++-- 5 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 templates/auth/src/utils.ts diff --git a/src/index.ts b/src/index.ts index 847d9e4..ed50896 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,7 @@ export class DatabaseDurableObject extends DurableObject { * @param params - Optional parameters for the SQL query. * @returns A response containing the query result or an error message. */ - async executeExternalQuery(sql: string, params: any[] | undefined) { + async executeExternalQuery(sql: string, params: any[] | undefined): Promise { try { const queries = [{ sql, params }]; const response = await enqueueOperation( @@ -78,9 +78,10 @@ export class DatabaseDurableObject extends DurableObject { () => processNextOperation(this.sql, this.operationQueue, this.ctx, this.processingOperation) ); - return createResponseFromOperationResponse(response); + return response; } catch (error: any) { - return createResponse(undefined, error.error || 'An unexpected error occurred.', error.status || 500); + console.error('Execute External Query Error:', error); + return null; } } @@ -293,15 +294,10 @@ export default { * that is responsible for handling authentication. */ if (pathname.startsWith('/auth')) { - // return new Response(`Auth ENV ${JSON.stringify(env)}`, {status: 200}); - // const body = await request.json(); - // console.log("Auth Request: ", body); - console.log("Auth Request: ", request); - return await env.AUTH.handleAuth(pathname, request.method, {}); + const body = await request.json(); + return await env.AUTH.handleAuth(pathname, request.method, body); } - console.log('SHOULD NOT SEE THIS'); - /** * Pass the fetch request directly to the Durable Object, which will handle the request * and return a response to be sent back to the client. diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index 93c4905..681203a 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -1,24 +1,25 @@ import { WorkerEntrypoint } from "cloudflare:workers"; +import { createResponse } from "./utils"; const DURABLE_OBJECT_ID = 'sql-durable-object'; export default class AuthEntrypoint extends WorkerEntrypoint { + private stub: any; + // Currently, entrypoints without a named handler are not supported async fetch() { return new Response(null, {status: 404}); } // TEMPORARY: Setup auth tables via a shell script instead of in here. async setupAuthTables() { - let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); - let stub = this.env.DATABASE_DURABLE_OBJECT.get(id); - const createUserTableQuery = ` CREATE TABLE IF NOT EXISTS auth_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, + username TEXT UNIQUE, password TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL + deleted_at TIMESTAMP DEFAULT NULL, + CHECK ((username IS NOT NULL AND email IS NULL) OR (username IS NULL AND email IS NOT NULL) OR (username IS NOT NULL AND email IS NOT NULL)) ); `; @@ -34,13 +35,16 @@ export default class AuthEntrypoint extends WorkerEntrypoint { `; // Make a request to the binded database - let response = await stub.executeExternalQuery(`${createUserTableQuery} ${createSessionTableQuery}`, []); + let response = await this.stub.executeExternalQuery(`${createUserTableQuery} ${createSessionTableQuery}`, []); return response; } async handleAuth(pathname: string, verb: string, body: any) { console.log('Handling Auth in Service Binding: ', body) + let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); + this.stub = this.env.DATABASE_DURABLE_OBJECT.get(id); + await this.setupAuthTables(); if (verb === "POST" && pathname === "/auth/signup") { @@ -50,15 +54,105 @@ export default class AuthEntrypoint extends WorkerEntrypoint { return new Response(null, {status: 405}); } + verifyPassword(password: string): boolean { + if (password.length < this.env.PASSWORD_REQUIRE_LENGTH) { + return false; + } + + if (this.env.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) { + return false; + } + + if (this.env.PASSWORD_REQUIRE_LOWERCASE && !/[a-z]/.test(password)) { + return false; + } + + if (this.env.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) { + return false; + } + + if (this.env.PASSWORD_REQUIRE_SPECIAL && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + return false; + } + + return true; + } + + async encryptPassword(password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(password); + const hash = await crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode(...new Uint8Array(hash))); + } + + async decryptPassword(encryptedPassword: string): Promise { + const decoder = new TextDecoder(); + const data = new Uint8Array(atob(encryptedPassword).split('').map(c => c.charCodeAt(0))); + const hash = await crypto.subtle.digest('SHA-256', data); + return decoder.decode(hash); + } + async handleSignup(body: any) { console.log("Handling Signup: ", body); - let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); - let stub = this.env.DATABASE_DURABLE_OBJECT.get(id); + // Check if the email and password are provided + // Only email or username is required, not both + if ((!body.email && !body.username) || !body.password) { + return new Response(JSON.stringify({error: "Missing required fields"}), {status: 400}); + } + + const isValidPassword = this.verifyPassword(body.password); + console.log("Password is valid: ", isValidPassword); + if (!isValidPassword) { + const errorMessage = `Password must be at least ${this.env.PASSWORD_REQUIRE_LENGTH} characters, ` + + `contain at least one uppercase letter, ` + + `one lowercase letter, ` + + `one number, and ` + + `one special character`; + return createResponse(undefined, errorMessage, 400); + } + + // // Check to see if the username or email already exists + let verifyUserResponse = await this.stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]); + console.log("Verify User Response: ", JSON.stringify(verifyUserResponse)); + if (verifyUserResponse.result.length > 0) { + return new Response(JSON.stringify({error: "Username or email already exists"}), {status: 400}); + } + + // // Create the user + const encryptedPassword = await this.encryptPassword(body.password); + console.log("Encrypted Password: ", encryptedPassword); + let createUserResponse = await this.stub.executeExternalQuery( + `INSERT INTO auth_users (username, password, email) + VALUES (?, ?, ?) + RETURNING id, username, email`, + [body.username, encryptedPassword, body.email] + ); + console.log("Create User Response: ", JSON.stringify(createUserResponse)); + if (createUserResponse.result.length === 0) { + return new Response(JSON.stringify({error: "Failed to create user"}), {status: 500}); + } + + // // Create a session for the user + const sessionToken = crypto.randomUUID(); + let createSessionResponse = await this.stub.executeExternalQuery( + `INSERT INTO auth_sessions (user_id, session_token) + VALUES (?, ?) + RETURNING id, user_id, session_token, created_at`, + [createUserResponse.result[0].id, sessionToken] + ); + console.log("Create Session Response: ", JSON.stringify(createSessionResponse)); + if (createSessionResponse.result.length === 0) { + return new Response(JSON.stringify({error: "Failed to create session"}), {status: 500}); + } // Make a request to the binded database - let response = await stub.executeExternalQuery('SELECT * FROM users', []); - return response; + let response = await this.stub.executeExternalQuery('SELECT * FROM auth_users', []); + return new Response(JSON.stringify({ + allUsers: response, + newUser: createUserResponse.result[0], + newSession: createSessionResponse.result[0] + }), {status: 200, headers: {'Content-Type': 'application/json'}}); } async handleVerifyEmail(request: Request) { diff --git a/templates/auth/src/utils.ts b/templates/auth/src/utils.ts new file mode 100644 index 0000000..dca17be --- /dev/null +++ b/templates/auth/src/utils.ts @@ -0,0 +1,19 @@ +export function createJSONResponse(data: { result: any, error: string | undefined, status: number }): Response { + return new Response(JSON.stringify({ + result: data.result, + error: data.error, + }), { + status: data.status, + headers: { + 'Content-Type': 'application/json', + }, + }); +} + +export function createResponse(result: any, error: string | undefined, status: number): Response { + return createJSONResponse({ + result, + error, + status, + }); +}; \ No newline at end of file diff --git a/templates/auth/worker-configuration.d.ts b/templates/auth/worker-configuration.d.ts index 5fc3b34..3ad4c86 100644 --- a/templates/auth/worker-configuration.d.ts +++ b/templates/auth/worker-configuration.d.ts @@ -2,5 +2,10 @@ interface Env { REQUIRE_EMAIL_CONFIRM: 1; + PASSWORD_REQUIRE_LENGTH: 13; + PASSWORD_REQUIRE_UPPERCASE: true; + PASSWORD_REQUIRE_LOWERCASE: true; + PASSWORD_REQUIRE_NUMBER: false; + PASSWORD_REQUIRE_SPECIAL: true; DATABASE_DURABLE_OBJECT: DurableObjectNamespace /* DatabaseDurableObject from starbasedb */; } diff --git a/templates/auth/wrangler.toml b/templates/auth/wrangler.toml index ee21ad2..e67bbfc 100644 --- a/templates/auth/wrangler.toml +++ b/templates/auth/wrangler.toml @@ -2,21 +2,13 @@ name = "starbasedb_auth" main = "./src/index.ts" compatibility_date = "2024-09-25" -# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. -# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects -#[[durable_objects.bindings]] -#name = "DATABASE_DURABLE_OBJECT" -#class_name = "DatabaseDurableObject" - -# Durable Object migrations. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations -#[[migrations]] -#tag = "v1" -#new_sqlite_classes = ["DatabaseDurableObject"] # Array of new classes - [durable_objects] bindings = [{ name = "DATABASE_DURABLE_OBJECT", class_name = "DatabaseDurableObject", script_name = "starbasedb" }] [vars] -REQUIRE_EMAIL_CONFIRM = 1 \ No newline at end of file +REQUIRE_EMAIL_CONFIRM = 1 +PASSWORD_REQUIRE_LENGTH = 13 +PASSWORD_REQUIRE_UPPERCASE = true +PASSWORD_REQUIRE_LOWERCASE = true +PASSWORD_REQUIRE_NUMBER = false +PASSWORD_REQUIRE_SPECIAL = true \ No newline at end of file From 2a43c6916fa754ca3f72cf153054811008c1a0e5 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Thu, 17 Oct 2024 21:30:56 -0400 Subject: [PATCH 03/11] Add basic login support --- templates/auth/src/email/index.ts | 78 +++++++++++++++++++++ templates/auth/src/index.ts | 111 ++---------------------------- templates/auth/src/utils.ts | 40 ++++++++++- 3 files changed, 124 insertions(+), 105 deletions(-) create mode 100644 templates/auth/src/email/index.ts diff --git a/templates/auth/src/email/index.ts b/templates/auth/src/email/index.ts new file mode 100644 index 0000000..2c3dd2f --- /dev/null +++ b/templates/auth/src/email/index.ts @@ -0,0 +1,78 @@ +import { createResponse, encryptPassword, verifyPassword } from "../utils"; + +export async function signup(stub: any, env: any, body: any) { + if ((!body.email && !body.username) || !body.password) { + return new Response(JSON.stringify({error: "Missing required fields"}), {status: 400, headers: {'Content-Type': 'application/json'}}); + } + + const isValidPassword = verifyPassword(env, body.password); + if (!isValidPassword) { + const errorMessage = `Password must be at least ${env.PASSWORD_REQUIRE_LENGTH} characters, ` + + `contain at least one uppercase letter, ` + + `one lowercase letter, ` + + `one number, and ` + + `one special character`; + return createResponse(undefined, errorMessage, 400); + } + + // Check to see if the username or email already exists + let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]); + if (verifyUserResponse.result.length > 0) { + return createResponse(undefined, "Username or email already exists", 400); + } + + // Create the user + const encryptedPassword = await encryptPassword(body.password); + let createUserResponse = await stub.executeExternalQuery( + `INSERT INTO auth_users (username, password, email) + VALUES (?, ?, ?) + RETURNING id, username, email`, + [body.username, encryptedPassword, body.email] + ); + if (createUserResponse.result.length === 0) { + return createResponse(undefined, "Failed to create user", 500); + } + + // Create a session for the user + const sessionToken = crypto.randomUUID(); + let createSessionResponse = await stub.executeExternalQuery( + `INSERT INTO auth_sessions (user_id, session_token) + VALUES (?, ?) + RETURNING user_id, session_token, created_at`, + [createUserResponse.result[0].id, sessionToken] + ); + if (createSessionResponse.result.length === 0) { + return createResponse(undefined, "Failed to create session", 500); + } + + return createResponse(createSessionResponse.result[0], undefined, 200); +} + +export async function login(stub: any, body: any) { + if ((!body.email && !body.username) || !body.password) { + return createResponse(undefined, "Missing required fields", 400); + } + + const encryptedPassword = await encryptPassword(body.password); + let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE (username = ? OR email = ?) AND password = ?`, [body.username, body.email, encryptedPassword]); + if (verifyUserResponse.result.length === 0) { + return createResponse(undefined, "User not found", 404); + } + + const user = verifyUserResponse.result[0]; + + // Create a session for the user + const sessionToken = crypto.randomUUID(); + let createSessionResponse = await stub.executeExternalQuery( + `INSERT INTO auth_sessions (user_id, session_token) + VALUES (?, ?) + RETURNING user_id, session_token, created_at`, + [user.id, sessionToken] + ); + + if (createSessionResponse.result.length === 0) { + return createResponse(undefined, "Failed to create session", 500); + } + + return createResponse(createSessionResponse.result[0], undefined, 200); +} diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index 681203a..27426b2 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -1,5 +1,6 @@ import { WorkerEntrypoint } from "cloudflare:workers"; -import { createResponse } from "./utils"; +import { createResponse, encryptPassword, verifyPassword } from "./utils"; +import { login, signup } from "./email"; const DURABLE_OBJECT_ID = 'sql-durable-object'; @@ -19,6 +20,7 @@ export default class AuthEntrypoint extends WorkerEntrypoint { email TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP DEFAULT NULL, + email_confirmed_at TIMESTAMP DEFAULT NULL, CHECK ((username IS NOT NULL AND email IS NULL) OR (username IS NULL AND email IS NOT NULL) OR (username IS NOT NULL AND email IS NOT NULL)) ); `; @@ -48,111 +50,12 @@ export default class AuthEntrypoint extends WorkerEntrypoint { await this.setupAuthTables(); if (verb === "POST" && pathname === "/auth/signup") { - return this.handleSignup(body); - } - - return new Response(null, {status: 405}); - } - - verifyPassword(password: string): boolean { - if (password.length < this.env.PASSWORD_REQUIRE_LENGTH) { - return false; - } - - if (this.env.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) { - return false; - } - - if (this.env.PASSWORD_REQUIRE_LOWERCASE && !/[a-z]/.test(password)) { - return false; + return signup(this.stub, this.env, body); + } else if (verb === "POST" && pathname === "/auth/login") { + return login(this.stub, body); } - if (this.env.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) { - return false; - } - - if (this.env.PASSWORD_REQUIRE_SPECIAL && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { - return false; - } - - return true; - } - - async encryptPassword(password: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(password); - const hash = await crypto.subtle.digest('SHA-256', data); - return btoa(String.fromCharCode(...new Uint8Array(hash))); - } - - async decryptPassword(encryptedPassword: string): Promise { - const decoder = new TextDecoder(); - const data = new Uint8Array(atob(encryptedPassword).split('').map(c => c.charCodeAt(0))); - const hash = await crypto.subtle.digest('SHA-256', data); - return decoder.decode(hash); - } - - async handleSignup(body: any) { - console.log("Handling Signup: ", body); - - // Check if the email and password are provided - // Only email or username is required, not both - if ((!body.email && !body.username) || !body.password) { - return new Response(JSON.stringify({error: "Missing required fields"}), {status: 400}); - } - - const isValidPassword = this.verifyPassword(body.password); - console.log("Password is valid: ", isValidPassword); - if (!isValidPassword) { - const errorMessage = `Password must be at least ${this.env.PASSWORD_REQUIRE_LENGTH} characters, ` + - `contain at least one uppercase letter, ` + - `one lowercase letter, ` + - `one number, and ` + - `one special character`; - return createResponse(undefined, errorMessage, 400); - } - - // // Check to see if the username or email already exists - let verifyUserResponse = await this.stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]); - console.log("Verify User Response: ", JSON.stringify(verifyUserResponse)); - if (verifyUserResponse.result.length > 0) { - return new Response(JSON.stringify({error: "Username or email already exists"}), {status: 400}); - } - - // // Create the user - const encryptedPassword = await this.encryptPassword(body.password); - console.log("Encrypted Password: ", encryptedPassword); - let createUserResponse = await this.stub.executeExternalQuery( - `INSERT INTO auth_users (username, password, email) - VALUES (?, ?, ?) - RETURNING id, username, email`, - [body.username, encryptedPassword, body.email] - ); - console.log("Create User Response: ", JSON.stringify(createUserResponse)); - if (createUserResponse.result.length === 0) { - return new Response(JSON.stringify({error: "Failed to create user"}), {status: 500}); - } - - // // Create a session for the user - const sessionToken = crypto.randomUUID(); - let createSessionResponse = await this.stub.executeExternalQuery( - `INSERT INTO auth_sessions (user_id, session_token) - VALUES (?, ?) - RETURNING id, user_id, session_token, created_at`, - [createUserResponse.result[0].id, sessionToken] - ); - console.log("Create Session Response: ", JSON.stringify(createSessionResponse)); - if (createSessionResponse.result.length === 0) { - return new Response(JSON.stringify({error: "Failed to create session"}), {status: 500}); - } - - // Make a request to the binded database - let response = await this.stub.executeExternalQuery('SELECT * FROM auth_users', []); - return new Response(JSON.stringify({ - allUsers: response, - newUser: createUserResponse.result[0], - newSession: createSessionResponse.result[0] - }), {status: 200, headers: {'Content-Type': 'application/json'}}); + return new Response(null, {status: 405}); } async handleVerifyEmail(request: Request) { diff --git a/templates/auth/src/utils.ts b/templates/auth/src/utils.ts index dca17be..4958143 100644 --- a/templates/auth/src/utils.ts +++ b/templates/auth/src/utils.ts @@ -16,4 +16,42 @@ export function createResponse(result: any, error: string | undefined, status: n error, status, }); -}; \ No newline at end of file +}; + +export function verifyPassword(env: any, password: string): boolean { + if (password.length < env.PASSWORD_REQUIRE_LENGTH) { + return false; + } + + if (env.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) { + return false; + } + + if (env.PASSWORD_REQUIRE_LOWERCASE && !/[a-z]/.test(password)) { + return false; + } + + if (env.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) { + return false; + } + + if (env.PASSWORD_REQUIRE_SPECIAL && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + return false; + } + + return true; +} + +export async function encryptPassword(password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(password); + const hash = await crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode(...new Uint8Array(hash))); +} + +export async function decryptPassword(encryptedPassword: string): Promise { + const decoder = new TextDecoder(); + const data = new Uint8Array(atob(encryptedPassword).split('').map(c => c.charCodeAt(0))); + const hash = await crypto.subtle.digest('SHA-256', data); + return decoder.decode(hash); +} \ No newline at end of file From 0fb6ca459c3a4789f86848094bd1e35366aa4850 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Fri, 18 Oct 2024 10:28:36 -0400 Subject: [PATCH 04/11] Remove handleLogin from index --- templates/auth/src/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index 27426b2..baba85c 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -74,10 +74,6 @@ export default class AuthEntrypoint extends WorkerEntrypoint { return new Response(`${request.json()}`, {status: 200}); } - async handleLogin(request: Request) { - return new Response(`${request.json()}`, {status: 200}); - } - async handleLogout(request: Request) { return new Response(`${request.json()}`, {status: 200}); } From 2572139b4920719553e56297ec742bdd207f3f8a Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Mon, 21 Oct 2024 21:01:14 -0400 Subject: [PATCH 05/11] Document auth routes --- templates/auth/package.json | 16 +++++----- templates/auth/src/email/index.ts | 10 ++++--- templates/auth/src/index.ts | 49 ++++++++++++++++--------------- templates/auth/wrangler.toml | 5 ++++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/templates/auth/package.json b/templates/auth/package.json index 82d112a..72210cb 100644 --- a/templates/auth/package.json +++ b/templates/auth/package.json @@ -3,14 +3,14 @@ "version": "0.0.0", "private": true, "scripts": { - "deploy": "wrangler deploy", - "dev": "wrangler dev", - "start": "wrangler dev", - "cf-typegen": "wrangler types" + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "cf-typegen": "wrangler types" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240925.0", - "typescript": "^5.5.2", - "wrangler": "^3.60.3" + "@cloudflare/workers-types": "^4.20240925.0", + "typescript": "^5.5.2", + "wrangler": "^3.60.3" } -} \ No newline at end of file +} diff --git a/templates/auth/src/email/index.ts b/templates/auth/src/email/index.ts index 2c3dd2f..139ff03 100644 --- a/templates/auth/src/email/index.ts +++ b/templates/auth/src/email/index.ts @@ -8,10 +8,10 @@ export async function signup(stub: any, env: any, body: any) { const isValidPassword = verifyPassword(env, body.password); if (!isValidPassword) { const errorMessage = `Password must be at least ${env.PASSWORD_REQUIRE_LENGTH} characters, ` + - `contain at least one uppercase letter, ` + - `one lowercase letter, ` + - `one number, and ` + - `one special character`; + `${env.PASSWORD_REQUIRE_UPPERCASE ? "contain at least one uppercase letter, " : ""}` + + `${env.PASSWORD_REQUIRE_LOWERCASE ? "contain at least one lowercase letter, " : ""}` + + `${env.PASSWORD_REQUIRE_NUMBER ? "contain at least one number, " : ""}` + + `${env.PASSWORD_REQUIRE_SPECIAL ? "contain at least one special character, " : ""}`; return createResponse(undefined, errorMessage, 400); } @@ -29,6 +29,7 @@ export async function signup(stub: any, env: any, body: any) { RETURNING id, username, email`, [body.username, encryptedPassword, body.email] ); + if (createUserResponse.result.length === 0) { return createResponse(undefined, "Failed to create user", 500); } @@ -41,6 +42,7 @@ export async function signup(stub: any, env: any, body: any) { RETURNING user_id, session_token, created_at`, [createUserResponse.result[0].id, sessionToken] ); + if (createSessionResponse.result.length === 0) { return createResponse(undefined, "Failed to create session", 500); } diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index baba85c..9959926 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -1,6 +1,6 @@ import { WorkerEntrypoint } from "cloudflare:workers"; -import { createResponse, encryptPassword, verifyPassword } from "./utils"; -import { login, signup } from "./email"; +import { login as emailLogin, signup as emailSignup } from "./email"; +import { createResponse } from "./utils"; const DURABLE_OBJECT_ID = 'sql-durable-object'; @@ -10,7 +10,10 @@ export default class AuthEntrypoint extends WorkerEntrypoint { // Currently, entrypoints without a named handler are not supported async fetch() { return new Response(null, {status: 404}); } - // TEMPORARY: Setup auth tables via a shell script instead of in here. + /** + * Sets up the auth tables if they don't exist + * @returns + */ async setupAuthTables() { const createUserTableQuery = ` CREATE TABLE IF NOT EXISTS auth_users ( @@ -41,6 +44,13 @@ export default class AuthEntrypoint extends WorkerEntrypoint { return response; } + /** + * Handles the auth requests, forwards to the appropriate handler + * @param pathname + * @param verb + * @param body + * @returns + */ async handleAuth(pathname: string, verb: string, body: any) { console.log('Handling Auth in Service Binding: ', body) @@ -50,31 +60,24 @@ export default class AuthEntrypoint extends WorkerEntrypoint { await this.setupAuthTables(); if (verb === "POST" && pathname === "/auth/signup") { - return signup(this.stub, this.env, body); + return emailSignup(this.stub, this.env, body); } else if (verb === "POST" && pathname === "/auth/login") { - return login(this.stub, body); + return emailLogin(this.stub, body); } return new Response(null, {status: 405}); } - async handleVerifyEmail(request: Request) { - return new Response(`${request.json()}`, {status: 200}); - } - - async handleResendEmail(request: Request) { - return new Response(`${request.json()}`, {status: 200}); - } - - async handleForgotPassword(request: Request) { - return new Response(`${request.json()}`, {status: 200}); - } - - async handleResetPassword(request: Request) { - return new Response(`${request.json()}`, {status: 200}); - } - - async handleLogout(request: Request) { - return new Response(`${request.json()}`, {status: 200}); + /** + * Handles logging out a user by invalidating all sessions for the user + * @param request + * @param body + * @returns + */ + async handleLogout(request: Request, body: any) { + await this.stub.executeExternalQuery(`UPDATE auth_sessions SET deleted_at = CURRENT_TIMESTAMP WHERE user_id = ?`, [body.user_id]); + return createResponse(JSON.stringify({ + success: true, + }), undefined, 200); } } diff --git a/templates/auth/wrangler.toml b/templates/auth/wrangler.toml index e67bbfc..2871011 100644 --- a/templates/auth/wrangler.toml +++ b/templates/auth/wrangler.toml @@ -5,6 +5,11 @@ compatibility_date = "2024-09-25" [durable_objects] bindings = [{ name = "DATABASE_DURABLE_OBJECT", class_name = "DatabaseDurableObject", script_name = "starbasedb" }] +# Allows for us to send user management emails such as email confirmation. +send_email = [ + {name = ""} +] + [vars] REQUIRE_EMAIL_CONFIRM = 1 PASSWORD_REQUIRE_LENGTH = 13 From 0be5dd011154770b6481826168e1601fa139ebd8 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 14:14:53 -0400 Subject: [PATCH 06/11] Include installation README --- src/api/index.ts | 14 +++++ src/index.ts | 6 ++ templates/auth/src/README.md | 65 ++++++++++++++++++++++ templates/auth/src/email/index.ts | 91 +++++++++++++++++-------------- templates/auth/src/index.ts | 46 ++-------------- templates/auth/src/migration.sql | 34 ++++++++++++ templates/auth/wrangler.toml | 5 -- wrangler.toml | 1 + 8 files changed, 174 insertions(+), 88 deletions(-) create mode 100644 src/api/index.ts create mode 100644 templates/auth/src/README.md create mode 100644 templates/auth/src/migration.sql diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..baf2500 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,14 @@ +// This file is a template for adding your own API endpoints. +// You can access these endpoints at the following URL: +// https://starbasedb.YOUR-IDENTIFIER.workers.dev/api/your/path/here + +export async function handleApiRequest(request: Request): Promise { + const url = new URL(request.url); + + // EXAMPLE: + // if (request.method === 'GET' && url.pathname === '/api/your/path/here') { + // return new Response('Success', { status: 200 }); + // } + + return new Response('Not found', { status: 404 }); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ed50896..7e80a18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { exportTableToJsonRoute } from './export/json'; import { exportTableToCsvRoute } from './export/csv'; import { importDumpRoute } from './import/dump'; import { importTableFromJsonRoute } from './import/json'; +import { handleApiRequest } from "./api"; const DURABLE_OBJECT_ID = 'sql-durable-object'; @@ -16,6 +17,7 @@ interface Env { DATABASE_DURABLE_OBJECT: DurableObjectNamespace; STUDIO_USER?: string; STUDIO_PASS?: string; + // ## DO NOT REMOVE: INSERT TEMPLATE INTERFACE ## AUTH: { handleAuth(pathname: string, verb: string, body: any): Promise; } @@ -189,6 +191,8 @@ export class DatabaseDurableObject extends DurableObject { return createResponse(undefined, 'Table name is required', 400); } return importTableFromJsonRoute(this.sql, this.operationQueue, this.ctx, this.processingOperation, tableName, request); + } else if (url.pathname.startsWith('/api')) { + return await handleApiRequest(request); } else { return createResponse(undefined, 'Unknown operation', 400); } @@ -289,6 +293,8 @@ export default { let id: DurableObjectId = env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); let stub = env.DATABASE_DURABLE_OBJECT.get(id); + // ## DO NOT REMOVE: INSERT TEMPLATE ROUTING LOGIC ## + /** * If the pathname starts with /auth, we want to pass the request off to another Worker * that is responsible for handling authentication. diff --git a/templates/auth/src/README.md b/templates/auth/src/README.md new file mode 100644 index 0000000..cca9d08 --- /dev/null +++ b/templates/auth/src/README.md @@ -0,0 +1,65 @@ +# Installation Guide +Follow the below steps to deploy the user authentication template into your existing +StarbaseDB instance. These steps will alter your StarbaseDB application logic so that +it includes capabilities for handling the routing of `/auth` routes to a new Cloudflare +Worker instance that will be deployed – which will handle all application logic for +user authentication. + +## Step-by-step Instructions + +### Add service bindings to your StarbaseDB wrangler.toml +This will let your StarbaseDB instance know that we are deploying another Worker +and it should use that for our authentication application routing logic. + +``` +[[services]] +binding = "AUTH" +service = "starbasedb_auth" +entrypoint = "AuthEntrypoint" +``` + +### Add AUTH to Env interface in `./src/index.ts` +Updates your `./src/index.ts` inside your StarbaseDB project so that your project +can now have a proper type-safe way of calling functionality that exists in this +new Cloudflare Worker that handles authentication. + +``` +AUTH: { + handleAuth(pathname: string, verb: string, body: any): Promise; +} +``` + +### Add routing logic in default export in `./src/index.ts` +We will add the below block of code in our `export default` section of our +StarbaseDB so that we can pick up on any `/auth` routes and immediately redirect +them to the new Cloudflare Worker. + +``` +if (pathname.startsWith('/auth')) { + const body = await request.json(); + return await env.AUTH.handleAuth(pathname, request.method, body); +} +``` + +### Execute SQL statements in `migration.sql` to create required tables +This will create the tables and constraints for user signup/login, and sessions +required for the authentication operations to succeed. + +### Run typegen in main project +With our newly added service bindings in our StarbaseDB `wrangler.toml` file we can +now generate an updated typegen output so our project knows that `AUTH` exists. +``` +npm run cf-typegen +``` + +### Deploy template project to Cloudflare +Next, we will deploy our new authentication logic to a new Cloudflare Worker instance. +``` +cd ./templates/auth +npm run deploy +``` + +### Deploy updates in our main StarbaseDB +With all of the changes we have made to our StarbaseDB instance we can now deploy +the updates so that all of the new authentication application logic can exist and +be accessible. \ No newline at end of file diff --git a/templates/auth/src/email/index.ts b/templates/auth/src/email/index.ts index 139ff03..0c7fb19 100644 --- a/templates/auth/src/email/index.ts +++ b/templates/auth/src/email/index.ts @@ -1,53 +1,60 @@ import { createResponse, encryptPassword, verifyPassword } from "../utils"; export async function signup(stub: any, env: any, body: any) { - if ((!body.email && !body.username) || !body.password) { - return new Response(JSON.stringify({error: "Missing required fields"}), {status: 400, headers: {'Content-Type': 'application/json'}}); - } + try { + if ((!body.email && !body.username) || !body.password) { + return createResponse(undefined, "Missing required fields", 400); + } - const isValidPassword = verifyPassword(env, body.password); - if (!isValidPassword) { - const errorMessage = `Password must be at least ${env.PASSWORD_REQUIRE_LENGTH} characters, ` + - `${env.PASSWORD_REQUIRE_UPPERCASE ? "contain at least one uppercase letter, " : ""}` + - `${env.PASSWORD_REQUIRE_LOWERCASE ? "contain at least one lowercase letter, " : ""}` + - `${env.PASSWORD_REQUIRE_NUMBER ? "contain at least one number, " : ""}` + - `${env.PASSWORD_REQUIRE_SPECIAL ? "contain at least one special character, " : ""}`; - return createResponse(undefined, errorMessage, 400); - } + const isValidPassword = verifyPassword(env, body.password); + if (!isValidPassword) { + const errorMessage = `Password must be at least ${env.PASSWORD_REQUIRE_LENGTH} characters ` + + `${env.PASSWORD_REQUIRE_UPPERCASE ? ", contain at least one uppercase letter " : ""}` + + `${env.PASSWORD_REQUIRE_LOWERCASE ? ", contain at least one lowercase letter " : ""}` + + `${env.PASSWORD_REQUIRE_NUMBER ? ", contain at least one number " : ""}` + + `${env.PASSWORD_REQUIRE_SPECIAL ? ", contain at least one special character " : ""}`; + return createResponse(undefined, errorMessage, 400); + } - // Check to see if the username or email already exists - let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]); - if (verifyUserResponse.result.length > 0) { - return createResponse(undefined, "Username or email already exists", 400); - } + // Check to see if the username or email already exists + let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]); + if (verifyUserResponse?.result?.length > 0) { + return createResponse(undefined, "Username or email already exists", 400); + } - // Create the user - const encryptedPassword = await encryptPassword(body.password); - let createUserResponse = await stub.executeExternalQuery( - `INSERT INTO auth_users (username, password, email) - VALUES (?, ?, ?) - RETURNING id, username, email`, - [body.username, encryptedPassword, body.email] - ); + // Create the user + const encryptedPassword = await encryptPassword(body.password); + let createUserResponse = await stub.executeExternalQuery( + `INSERT INTO auth_users (username, password, email) + VALUES (?, ?, ?) + RETURNING id, username, email`, + [body.username, encryptedPassword, body.email] + ); - if (createUserResponse.result.length === 0) { - return createResponse(undefined, "Failed to create user", 500); - } + console.log('Flag 6') + if (createUserResponse?.result?.length === 0) { + return createResponse(undefined, "Failed to create user", 500); + } - // Create a session for the user - const sessionToken = crypto.randomUUID(); - let createSessionResponse = await stub.executeExternalQuery( - `INSERT INTO auth_sessions (user_id, session_token) - VALUES (?, ?) - RETURNING user_id, session_token, created_at`, - [createUserResponse.result[0].id, sessionToken] - ); + console.log('Flag 7') + // Create a session for the user + const sessionToken = crypto.randomUUID(); + let createSessionResponse = await stub.executeExternalQuery( + `INSERT INTO auth_sessions (user_id, session_token) + VALUES (?, ?) + RETURNING user_id, session_token, created_at`, + [createUserResponse.result[0].id, sessionToken] + ); - if (createSessionResponse.result.length === 0) { - return createResponse(undefined, "Failed to create session", 500); - } + if (createSessionResponse?.result?.length === 0) { + return createResponse(undefined, "Failed to create session", 500); + } - return createResponse(createSessionResponse.result[0], undefined, 200); + return createResponse(createSessionResponse.result[0], undefined, 200); + } catch (error) { + console.error('Signup Error:', error); + return createResponse(undefined, "Username or email already exists", 500); + } } export async function login(stub: any, body: any) { @@ -57,7 +64,7 @@ export async function login(stub: any, body: any) { const encryptedPassword = await encryptPassword(body.password); let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE (username = ? OR email = ?) AND password = ?`, [body.username, body.email, encryptedPassword]); - if (verifyUserResponse.result.length === 0) { + if (verifyUserResponse?.result?.length === 0) { return createResponse(undefined, "User not found", 404); } @@ -72,7 +79,7 @@ export async function login(stub: any, body: any) { [user.id, sessionToken] ); - if (createSessionResponse.result.length === 0) { + if (createSessionResponse?.result?.length === 0) { return createResponse(undefined, "Failed to create session", 500); } diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index 9959926..c762c94 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -10,40 +10,6 @@ export default class AuthEntrypoint extends WorkerEntrypoint { // Currently, entrypoints without a named handler are not supported async fetch() { return new Response(null, {status: 404}); } - /** - * Sets up the auth tables if they don't exist - * @returns - */ - async setupAuthTables() { - const createUserTableQuery = ` - CREATE TABLE IF NOT EXISTS auth_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE, - password TEXT NOT NULL, - email TEXT UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL, - email_confirmed_at TIMESTAMP DEFAULT NULL, - CHECK ((username IS NOT NULL AND email IS NULL) OR (username IS NULL AND email IS NOT NULL) OR (username IS NOT NULL AND email IS NOT NULL)) - ); - `; - - const createSessionTableQuery = ` - CREATE TABLE IF NOT EXISTS auth_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - session_token TEXT NOT NULL UNIQUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL, - FOREIGN KEY (user_id) REFERENCES auth_users (id) - ); - `; - - // Make a request to the binded database - let response = await this.stub.executeExternalQuery(`${createUserTableQuery} ${createSessionTableQuery}`, []); - return response; - } - /** * Handles the auth requests, forwards to the appropriate handler * @param pathname @@ -52,17 +18,15 @@ export default class AuthEntrypoint extends WorkerEntrypoint { * @returns */ async handleAuth(pathname: string, verb: string, body: any) { - console.log('Handling Auth in Service Binding: ', body) - let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); this.stub = this.env.DATABASE_DURABLE_OBJECT.get(id); - await this.setupAuthTables(); - if (verb === "POST" && pathname === "/auth/signup") { - return emailSignup(this.stub, this.env, body); + return await emailSignup(this.stub, this.env, body); } else if (verb === "POST" && pathname === "/auth/login") { - return emailLogin(this.stub, body); + return await emailLogin(this.stub, body); + } else if (verb === "POST" && pathname === "/auth/logout") { + return await this.handleLogout(body); } return new Response(null, {status: 405}); @@ -74,7 +38,7 @@ export default class AuthEntrypoint extends WorkerEntrypoint { * @param body * @returns */ - async handleLogout(request: Request, body: any) { + async handleLogout(body: any) { await this.stub.executeExternalQuery(`UPDATE auth_sessions SET deleted_at = CURRENT_TIMESTAMP WHERE user_id = ?`, [body.user_id]); return createResponse(JSON.stringify({ success: true, diff --git a/templates/auth/src/migration.sql b/templates/auth/src/migration.sql new file mode 100644 index 0000000..c81261d --- /dev/null +++ b/templates/auth/src/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS auth_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT COLLATE NOCASE, + password TEXT NOT NULL, + email TEXT COLLATE NOCASE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + email_confirmed_at TIMESTAMP DEFAULT NULL, + UNIQUE(username), + UNIQUE(email), + CHECK ((username IS NOT NULL AND email IS NULL) OR (username IS NULL AND email IS NOT NULL) OR (username IS NOT NULL AND email IS NOT NULL)) +); + +CREATE TRIGGER IF NOT EXISTS prevent_username_email_overlap +BEFORE INSERT ON auth_users +BEGIN + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM auth_users + WHERE (NEW.username IS NOT NULL AND (NEW.username = username OR NEW.username = email)) + OR (NEW.email IS NOT NULL AND (NEW.email = username OR NEW.email = email)) + ) + THEN RAISE(ABORT, 'Username or email already exists') + END; +END; + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + session_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES auth_users (id) +); diff --git a/templates/auth/wrangler.toml b/templates/auth/wrangler.toml index 2871011..e67bbfc 100644 --- a/templates/auth/wrangler.toml +++ b/templates/auth/wrangler.toml @@ -5,11 +5,6 @@ compatibility_date = "2024-09-25" [durable_objects] bindings = [{ name = "DATABASE_DURABLE_OBJECT", class_name = "DatabaseDurableObject", script_name = "starbasedb" }] -# Allows for us to send user management emails such as email confirmation. -send_email = [ - {name = ""} -] - [vars] REQUIRE_EMAIL_CONFIRM = 1 PASSWORD_REQUIRE_LENGTH = 13 diff --git a/wrangler.toml b/wrangler.toml index f1323ca..3b645df 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,6 +5,7 @@ compatibility_date = "2024-09-25" account_id = "" # Service Bindings +## DO NOT REMOVE: INSERT TEMPLATE SERVICE BINDINGS ## [[services]] binding = "AUTH" service = "starbasedb_auth" From 60bfc45f9fb26bea0cc60a4cab86f942c517c321 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 14:19:04 -0400 Subject: [PATCH 07/11] Remove pre-inserted template code --- src/index.ts | 16 ++-------------- wrangler.toml | 10 +++------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7e80a18..31befef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,10 +17,7 @@ interface Env { DATABASE_DURABLE_OBJECT: DurableObjectNamespace; STUDIO_USER?: string; STUDIO_PASS?: string; - // ## DO NOT REMOVE: INSERT TEMPLATE INTERFACE ## - AUTH: { - handleAuth(pathname: string, verb: string, body: any): Promise; - } + // ## DO NOT REMOVE: TEMPLATE INTERFACE ## } export class DatabaseDurableObject extends DurableObject { @@ -293,16 +290,7 @@ export default { let id: DurableObjectId = env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID); let stub = env.DATABASE_DURABLE_OBJECT.get(id); - // ## DO NOT REMOVE: INSERT TEMPLATE ROUTING LOGIC ## - - /** - * If the pathname starts with /auth, we want to pass the request off to another Worker - * that is responsible for handling authentication. - */ - if (pathname.startsWith('/auth')) { - const body = await request.json(); - return await env.AUTH.handleAuth(pathname, request.method, body); - } + // ## DO NOT REMOVE: TEMPLATE ROUTING ## /** * Pass the fetch request directly to the Durable Object, which will handle the request diff --git a/wrangler.toml b/wrangler.toml index 3b645df..3d00b1d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,11 +5,7 @@ compatibility_date = "2024-09-25" account_id = "" # Service Bindings -## DO NOT REMOVE: INSERT TEMPLATE SERVICE BINDINGS ## -[[services]] -binding = "AUTH" -service = "starbasedb_auth" -entrypoint = "AuthEntrypoint" +## DO NOT REMOVE: TEMPLATE SERVICES ## # Workers Logs # Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ @@ -35,5 +31,5 @@ AUTHORIZATION_TOKEN = "ABC123" # Uncomment the section below to create a user for logging into your database UI. # You can access the Studio UI at: https://your_endpoint/studio -STUDIO_USER = "admin" -STUDIO_PASS = "123456" +# STUDIO_USER = "admin" +# STUDIO_PASS = "123456" From f5c32f80ce89c75c830819b4ee83be1951b0cda3 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 15:07:15 -0400 Subject: [PATCH 08/11] Update auth README --- templates/auth/src/README.md | 61 ++++++++++++++++++++++++++++-------- worker-configuration.d.ts | 1 - 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/templates/auth/src/README.md b/templates/auth/src/README.md index cca9d08..209ca82 100644 --- a/templates/auth/src/README.md +++ b/templates/auth/src/README.md @@ -7,6 +7,46 @@ user authentication. ## Step-by-step Instructions +### Execute SQL statements in `migration.sql` to create required tables +This will create the tables and constraints for user signup/login, and sessions. You can do this in the Studio user interface or by hitting your query endpoint in your StarbaseDB instance. + +```sql +CREATE TABLE IF NOT EXISTS auth_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT COLLATE NOCASE, + password TEXT NOT NULL, + email TEXT COLLATE NOCASE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + email_confirmed_at TIMESTAMP DEFAULT NULL, + UNIQUE(username), + UNIQUE(email), + CHECK ((username IS NOT NULL AND email IS NULL) OR (username IS NULL AND email IS NOT NULL) OR (username IS NOT NULL AND email IS NOT NULL)) +); + +CREATE TRIGGER IF NOT EXISTS prevent_username_email_overlap +BEFORE INSERT ON auth_users +BEGIN + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM auth_users + WHERE (NEW.username IS NOT NULL AND (NEW.username = username OR NEW.username = email)) + OR (NEW.email IS NOT NULL AND (NEW.email = username OR NEW.email = email)) + ) + THEN RAISE(ABORT, 'Username or email already exists') + END; +END; + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + session_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES auth_users (id) +); +``` + ### Add service bindings to your StarbaseDB wrangler.toml This will let your StarbaseDB instance know that we are deploying another Worker and it should use that for our authentication application routing logic. @@ -41,25 +81,20 @@ if (pathname.startsWith('/auth')) { } ``` -### Execute SQL statements in `migration.sql` to create required tables -This will create the tables and constraints for user signup/login, and sessions -required for the authentication operations to succeed. - -### Run typegen in main project -With our newly added service bindings in our StarbaseDB `wrangler.toml` file we can -now generate an updated typegen output so our project knows that `AUTH` exists. -``` -npm run cf-typegen -``` - ### Deploy template project to Cloudflare Next, we will deploy our new authentication logic to a new Cloudflare Worker instance. ``` cd ./templates/auth -npm run deploy +npm i && npm run deploy ``` ### Deploy updates in our main StarbaseDB With all of the changes we have made to our StarbaseDB instance we can now deploy the updates so that all of the new authentication application logic can exist and -be accessible. \ No newline at end of file +be accessible. +``` +cd ../.. +npm run deploy +``` + +**NOTE:** You will want to deploy your new service worker for authentication before deploying updates to your StarbaseDB instance, because the StarbaseDB instance will rely on the authentication worker being available (see the service bindings we added in the wrangler.toml file for reference). \ No newline at end of file diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 8c9d1bc..311f5ac 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -3,5 +3,4 @@ interface Env { AUTHORIZATION_TOKEN: "ABC123"; DATABASE_DURABLE_OBJECT: DurableObjectNamespace; - AUTH: Fetcher; } From c76723f4a2ea9f3b2830ebb420e491f9ae1ab7c9 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 15:08:18 -0400 Subject: [PATCH 09/11] Update auth README --- templates/auth/src/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/auth/src/README.md b/templates/auth/src/README.md index 209ca82..f7090b9 100644 --- a/templates/auth/src/README.md +++ b/templates/auth/src/README.md @@ -94,7 +94,7 @@ the updates so that all of the new authentication application logic can exist an be accessible. ``` cd ../.. -npm run deploy +npm run cf-typegen && npm run deploy ``` **NOTE:** You will want to deploy your new service worker for authentication before deploying updates to your StarbaseDB instance, because the StarbaseDB instance will rely on the authentication worker being available (see the service bindings we added in the wrangler.toml file for reference). \ No newline at end of file From bcb7322f21fda7f627a784eb389701f213270d2a Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 15:11:42 -0400 Subject: [PATCH 10/11] Remove logs --- templates/auth/src/email/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/auth/src/email/index.ts b/templates/auth/src/email/index.ts index 0c7fb19..1071ffb 100644 --- a/templates/auth/src/email/index.ts +++ b/templates/auth/src/email/index.ts @@ -31,12 +31,10 @@ export async function signup(stub: any, env: any, body: any) { [body.username, encryptedPassword, body.email] ); - console.log('Flag 6') if (createUserResponse?.result?.length === 0) { return createResponse(undefined, "Failed to create user", 500); } - console.log('Flag 7') // Create a session for the user const sessionToken = crypto.randomUUID(); let createSessionResponse = await stub.executeExternalQuery( From 0e9ef9e30144248cede53a1de9764485b312b8d1 Mon Sep 17 00:00:00 2001 From: Brayden Wilmoth Date: Wed, 23 Oct 2024 15:26:41 -0400 Subject: [PATCH 11/11] Fix typesafety in Env declaration --- templates/auth/src/index.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/templates/auth/src/index.ts b/templates/auth/src/index.ts index c762c94..eed77bd 100644 --- a/templates/auth/src/index.ts +++ b/templates/auth/src/index.ts @@ -4,7 +4,11 @@ import { createResponse } from "./utils"; const DURABLE_OBJECT_ID = 'sql-durable-object'; -export default class AuthEntrypoint extends WorkerEntrypoint { +interface Env { + DATABASE_DURABLE_OBJECT: DurableObjectNamespace; +} + +export default class AuthEntrypoint extends WorkerEntrypoint { private stub: any; // Currently, entrypoints without a named handler are not supported @@ -44,4 +48,19 @@ export default class AuthEntrypoint extends WorkerEntrypoint { success: true, }), undefined, 200); } + + /** + * Checks if a session is valid by checking if the session token exists and is not deleted + * @param sessionToken + * @returns + */ + async isSessionValid(sessionToken: string) { + let result = await this.stub.executeExternalQuery( + `SELECT * FROM auth_sessions + WHERE session_token = ? + AND deleted_at IS NULL`, + [sessionToken] + ); + return result.result.length > 0; + } }