Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User Auth Template #26

Merged
merged 12 commits into from
Oct 23, 2024
52 changes: 50 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;
}
}

export class DatabaseDurableObject extends DurableObject {
// Durable storage for the SQL database
public sql: SqlStorage;
Expand All @@ -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,
Expand All @@ -47,6 +57,34 @@ 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): Promise<any> {
try {
const queries = [{ sql, params }];
const response = await enqueueOperation(
queries,
false,
false,
this.operationQueue,
() => processNextOperation(this.sql, this.operationQueue, this.ctx, this.processingOperation)
);

return response;
} catch (error: any) {
console.error('Execute External Query Error:', error);
return null;
}
}

async queryRoute(request: Request, isRaw: boolean): Promise<Response> {
try {
const contentType = request.headers.get('Content-Type') || '';
Expand Down Expand Up @@ -207,6 +245,7 @@ export default {
* @returns The response to be sent back to the client
*/
async fetch(request, env, ctx): Promise<Response> {
const pathname = new URL(request.url).pathname;
const isWebSocket = request.headers.get("Upgrade") === "websocket";

/**
Expand All @@ -215,7 +254,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,
Expand Down Expand Up @@ -250,6 +289,15 @@ 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')) {
const body = await request.json();
return await env.AUTH.handleAuth(pathname, request.method, body);
}

/**
* 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.
Expand Down
16 changes: 16 additions & 0 deletions templates/auth/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
78 changes: 78 additions & 0 deletions templates/auth/src/email/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
84 changes: 84 additions & 0 deletions templates/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import { createResponse, encryptPassword, verifyPassword } from "./utils";
import { login, signup } from "./email";

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() {
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;
}

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 signup(this.stub, this.env, body);
} else if (verb === "POST" && pathname === "/auth/login") {
return login(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 handleLogin(request: Request) {
return new Response(`${request.json()}`, {status: 200});
}

async handleLogout(request: Request) {
return new Response(`${request.json()}`, {status: 200});
}
}
57 changes: 57 additions & 0 deletions templates/auth/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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,
});
};

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<string> {
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<string> {
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);
}
11 changes: 11 additions & 0 deletions templates/auth/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Generated by Wrangler by running `wrangler types`

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 */;
}
14 changes: 14 additions & 0 deletions templates/auth/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name = "starbasedb_auth"
main = "./src/index.ts"
compatibility_date = "2024-09-25"

[durable_objects]
bindings = [{ name = "DATABASE_DURABLE_OBJECT", class_name = "DatabaseDurableObject", script_name = "starbasedb" }]

[vars]
REQUIRE_EMAIL_CONFIRM = 1
PASSWORD_REQUIRE_LENGTH = 13
PASSWORD_REQUIRE_UPPERCASE = true
PASSWORD_REQUIRE_LOWERCASE = true
PASSWORD_REQUIRE_NUMBER = false
PASSWORD_REQUIRE_SPECIAL = true
3 changes: 1 addition & 2 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

interface Env {
AUTHORIZATION_TOKEN: "ABC123";
STUDIO_USER?: string;
STUDIO_PASS?: string;
DATABASE_DURABLE_OBJECT: DurableObjectNamespace<import("./src/index").DatabaseDurableObject>;
AUTH: Fetcher;
}
10 changes: 8 additions & 2 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
STUDIO_USER = "admin"
Brayden marked this conversation as resolved.
Show resolved Hide resolved
STUDIO_PASS = "123456"