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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 });
}
46 changes: 44 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@ import { exportTableToCsvRoute } from './export/csv';
import { importDumpRoute } from './import/dump';
import { importTableFromJsonRoute } from './import/json';
import { importTableFromCsvRoute } from './import/csv';
import { handleApiRequest } from "./api";

const DURABLE_OBJECT_ID = 'sql-durable-object';

interface Env {
AUTHORIZATION_TOKEN: string;
DATABASE_DURABLE_OBJECT: DurableObjectNamespace;
STUDIO_USER?: string;
STUDIO_PASS?: string;
// ## DO NOT REMOVE: TEMPLATE INTERFACE ##
}

export class DatabaseDurableObject extends DurableObject {
// Durable storage for the SQL database
public sql: SqlStorage;
Expand All @@ -38,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 @@ -48,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 @@ -158,6 +195,8 @@ export class DatabaseDurableObject extends DurableObject {
return createResponse(undefined, 'Table name is required', 400);
}
return importTableFromCsvRoute(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);
}
Expand Down Expand Up @@ -214,6 +253,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 @@ -222,7 +262,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 @@ -257,6 +297,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: TEMPLATE ROUTING ##

/**
* 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"
}
}
100 changes: 100 additions & 0 deletions templates/auth/src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 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

### 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.

```
[[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<Response>;
}
```

### 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);
}
```

### Deploy template project to Cloudflare
Next, we will deploy our new authentication logic to a new Cloudflare Worker instance.
```
cd ./templates/auth
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.
```
cd ../..
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).
85 changes: 85 additions & 0 deletions templates/auth/src/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { createResponse, encryptPassword, verifyPassword } from "../utils";

export async function signup(stub: any, env: any, body: any) {
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);
}

// 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);
} catch (error) {
console.error('Signup Error:', error);
return createResponse(undefined, "Username or email already exists", 500);
}
}

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);
}
66 changes: 66 additions & 0 deletions templates/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import { login as emailLogin, signup as emailSignup } from "./email";
import { createResponse } from "./utils";

const DURABLE_OBJECT_ID = 'sql-durable-object';

interface Env {
DATABASE_DURABLE_OBJECT: DurableObjectNamespace;
}

export default class AuthEntrypoint extends WorkerEntrypoint<Env> {
private stub: any;

// Currently, entrypoints without a named handler are not supported
async fetch() { return new Response(null, {status: 404}); }

/**
* Handles the auth requests, forwards to the appropriate handler
* @param pathname
* @param verb
* @param body
* @returns
*/
async handleAuth(pathname: string, verb: string, body: any) {
let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID);
this.stub = this.env.DATABASE_DURABLE_OBJECT.get(id);

if (verb === "POST" && pathname === "/auth/signup") {
return await emailSignup(this.stub, this.env, body);
} else if (verb === "POST" && pathname === "/auth/login") {
return await emailLogin(this.stub, body);
} else if (verb === "POST" && pathname === "/auth/logout") {
return await this.handleLogout(body);
}

return new Response(null, {status: 405});
}

/**
* Handles logging out a user by invalidating all sessions for the user
* @param request
* @param body
* @returns
*/
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,
}), 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;
}
}
Loading