Skip to content

Commit 7f50602

Browse files
authored
Merge pull request #26 from Brayden/bwilmoth/template-auth
User Auth Template
2 parents 1f3cbe3 + 0e9ef9e commit 7f50602

File tree

12 files changed

+445
-5
lines changed

12 files changed

+445
-5
lines changed

src/api/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This file is a template for adding your own API endpoints.
2+
// You can access these endpoints at the following URL:
3+
// https://starbasedb.YOUR-IDENTIFIER.workers.dev/api/your/path/here
4+
5+
export async function handleApiRequest(request: Request): Promise<Response> {
6+
const url = new URL(request.url);
7+
8+
// EXAMPLE:
9+
// if (request.method === 'GET' && url.pathname === '/api/your/path/here') {
10+
// return new Response('Success', { status: 200 });
11+
// }
12+
13+
return new Response('Not found', { status: 404 });
14+
}

src/index.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,18 @@ import { exportTableToCsvRoute } from './export/csv';
99
import { importDumpRoute } from './import/dump';
1010
import { importTableFromJsonRoute } from './import/json';
1111
import { importTableFromCsvRoute } from './import/csv';
12+
import { handleApiRequest } from "./api";
1213

1314
const DURABLE_OBJECT_ID = 'sql-durable-object';
1415

16+
interface Env {
17+
AUTHORIZATION_TOKEN: string;
18+
DATABASE_DURABLE_OBJECT: DurableObjectNamespace;
19+
STUDIO_USER?: string;
20+
STUDIO_PASS?: string;
21+
// ## DO NOT REMOVE: TEMPLATE INTERFACE ##
22+
}
23+
1524
export class DatabaseDurableObject extends DurableObject {
1625
// Durable storage for the SQL database
1726
public sql: SqlStorage;
@@ -38,7 +47,7 @@ export class DatabaseDurableObject extends DurableObject {
3847
constructor(ctx: DurableObjectState, env: Env) {
3948
super(ctx, env);
4049
this.sql = ctx.storage.sql;
41-
50+
4251
// Initialize LiteREST for handling /lite routes
4352
this.liteREST = new LiteREST(
4453
ctx,
@@ -48,6 +57,34 @@ export class DatabaseDurableObject extends DurableObject {
4857
);
4958
}
5059

60+
/**
61+
* Execute a raw SQL query on the database, typically used for external requests
62+
* from other service bindings (e.g. auth). This serves as an exposed function for
63+
* other service bindings to query the database without having to have knowledge of
64+
* the current operation queue or processing state.
65+
*
66+
* @param sql - The SQL query to execute.
67+
* @param params - Optional parameters for the SQL query.
68+
* @returns A response containing the query result or an error message.
69+
*/
70+
async executeExternalQuery(sql: string, params: any[] | undefined): Promise<any> {
71+
try {
72+
const queries = [{ sql, params }];
73+
const response = await enqueueOperation(
74+
queries,
75+
false,
76+
false,
77+
this.operationQueue,
78+
() => processNextOperation(this.sql, this.operationQueue, this.ctx, this.processingOperation)
79+
);
80+
81+
return response;
82+
} catch (error: any) {
83+
console.error('Execute External Query Error:', error);
84+
return null;
85+
}
86+
}
87+
5188
async queryRoute(request: Request, isRaw: boolean): Promise<Response> {
5289
try {
5390
const contentType = request.headers.get('Content-Type') || '';
@@ -158,6 +195,8 @@ export class DatabaseDurableObject extends DurableObject {
158195
return createResponse(undefined, 'Table name is required', 400);
159196
}
160197
return importTableFromCsvRoute(this.sql, this.operationQueue, this.ctx, this.processingOperation, tableName, request);
198+
} else if (url.pathname.startsWith('/api')) {
199+
return await handleApiRequest(request);
161200
} else {
162201
return createResponse(undefined, 'Unknown operation', 400);
163202
}
@@ -214,6 +253,7 @@ export default {
214253
* @returns The response to be sent back to the client
215254
*/
216255
async fetch(request, env, ctx): Promise<Response> {
256+
const pathname = new URL(request.url).pathname;
217257
const isWebSocket = request.headers.get("Upgrade") === "websocket";
218258

219259
/**
@@ -222,7 +262,7 @@ export default {
222262
* Studio provides a user interface to interact with the SQLite database in the Durable
223263
* Object.
224264
*/
225-
if (env.STUDIO_USER && env.STUDIO_PASS && request.method === 'GET' && new URL(request.url).pathname === '/studio') {
265+
if (env.STUDIO_USER && env.STUDIO_PASS && request.method === 'GET' && pathname === '/studio') {
226266
return handleStudioRequest(request, {
227267
username: env.STUDIO_USER,
228268
password: env.STUDIO_PASS,
@@ -257,6 +297,8 @@ export default {
257297
let id: DurableObjectId = env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID);
258298
let stub = env.DATABASE_DURABLE_OBJECT.get(id);
259299

300+
// ## DO NOT REMOVE: TEMPLATE ROUTING ##
301+
260302
/**
261303
* Pass the fetch request directly to the Durable Object, which will handle the request
262304
* and return a response to be sent back to the client.

templates/auth/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "starbasedb-auth",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "wrangler deploy",
7+
"dev": "wrangler dev",
8+
"start": "wrangler dev",
9+
"cf-typegen": "wrangler types"
10+
},
11+
"devDependencies": {
12+
"@cloudflare/workers-types": "^4.20240925.0",
13+
"typescript": "^5.5.2",
14+
"wrangler": "^3.60.3"
15+
}
16+
}

templates/auth/src/README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Installation Guide
2+
Follow the below steps to deploy the user authentication template into your existing
3+
StarbaseDB instance. These steps will alter your StarbaseDB application logic so that
4+
it includes capabilities for handling the routing of `/auth` routes to a new Cloudflare
5+
Worker instance that will be deployed – which will handle all application logic for
6+
user authentication.
7+
8+
## Step-by-step Instructions
9+
10+
### Execute SQL statements in `migration.sql` to create required tables
11+
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.
12+
13+
```sql
14+
CREATE TABLE IF NOT EXISTS auth_users (
15+
id INTEGER PRIMARY KEY AUTOINCREMENT,
16+
username TEXT COLLATE NOCASE,
17+
password TEXT NOT NULL,
18+
email TEXT COLLATE NOCASE,
19+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
20+
deleted_at TIMESTAMP DEFAULT NULL,
21+
email_confirmed_at TIMESTAMP DEFAULT NULL,
22+
UNIQUE(username),
23+
UNIQUE(email),
24+
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))
25+
);
26+
27+
CREATE TRIGGER IF NOT EXISTS prevent_username_email_overlap
28+
BEFORE INSERT ON auth_users
29+
BEGIN
30+
SELECT CASE
31+
WHEN EXISTS (
32+
SELECT 1 FROM auth_users
33+
WHERE (NEW.username IS NOT NULL AND (NEW.username = username OR NEW.username = email))
34+
OR (NEW.email IS NOT NULL AND (NEW.email = username OR NEW.email = email))
35+
)
36+
THEN RAISE(ABORT, 'Username or email already exists')
37+
END;
38+
END;
39+
40+
CREATE TABLE IF NOT EXISTS auth_sessions (
41+
id INTEGER PRIMARY KEY AUTOINCREMENT,
42+
user_id INTEGER NOT NULL,
43+
session_token TEXT NOT NULL UNIQUE,
44+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
45+
deleted_at TIMESTAMP DEFAULT NULL,
46+
FOREIGN KEY (user_id) REFERENCES auth_users (id)
47+
);
48+
```
49+
50+
### Add service bindings to your StarbaseDB wrangler.toml
51+
This will let your StarbaseDB instance know that we are deploying another Worker
52+
and it should use that for our authentication application routing logic.
53+
54+
```
55+
[[services]]
56+
binding = "AUTH"
57+
service = "starbasedb_auth"
58+
entrypoint = "AuthEntrypoint"
59+
```
60+
61+
### Add AUTH to Env interface in `./src/index.ts`
62+
Updates your `./src/index.ts` inside your StarbaseDB project so that your project
63+
can now have a proper type-safe way of calling functionality that exists in this
64+
new Cloudflare Worker that handles authentication.
65+
66+
```
67+
AUTH: {
68+
handleAuth(pathname: string, verb: string, body: any): Promise<Response>;
69+
}
70+
```
71+
72+
### Add routing logic in default export in `./src/index.ts`
73+
We will add the below block of code in our `export default` section of our
74+
StarbaseDB so that we can pick up on any `/auth` routes and immediately redirect
75+
them to the new Cloudflare Worker.
76+
77+
```
78+
if (pathname.startsWith('/auth')) {
79+
const body = await request.json();
80+
return await env.AUTH.handleAuth(pathname, request.method, body);
81+
}
82+
```
83+
84+
### Deploy template project to Cloudflare
85+
Next, we will deploy our new authentication logic to a new Cloudflare Worker instance.
86+
```
87+
cd ./templates/auth
88+
npm i && npm run deploy
89+
```
90+
91+
### Deploy updates in our main StarbaseDB
92+
With all of the changes we have made to our StarbaseDB instance we can now deploy
93+
the updates so that all of the new authentication application logic can exist and
94+
be accessible.
95+
```
96+
cd ../..
97+
npm run cf-typegen && npm run deploy
98+
```
99+
100+
**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).

templates/auth/src/email/index.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createResponse, encryptPassword, verifyPassword } from "../utils";
2+
3+
export async function signup(stub: any, env: any, body: any) {
4+
try {
5+
if ((!body.email && !body.username) || !body.password) {
6+
return createResponse(undefined, "Missing required fields", 400);
7+
}
8+
9+
const isValidPassword = verifyPassword(env, body.password);
10+
if (!isValidPassword) {
11+
const errorMessage = `Password must be at least ${env.PASSWORD_REQUIRE_LENGTH} characters ` +
12+
`${env.PASSWORD_REQUIRE_UPPERCASE ? ", contain at least one uppercase letter " : ""}` +
13+
`${env.PASSWORD_REQUIRE_LOWERCASE ? ", contain at least one lowercase letter " : ""}` +
14+
`${env.PASSWORD_REQUIRE_NUMBER ? ", contain at least one number " : ""}` +
15+
`${env.PASSWORD_REQUIRE_SPECIAL ? ", contain at least one special character " : ""}`;
16+
return createResponse(undefined, errorMessage, 400);
17+
}
18+
19+
// Check to see if the username or email already exists
20+
let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE username = ? OR email = ?`, [body.username, body.email]);
21+
if (verifyUserResponse?.result?.length > 0) {
22+
return createResponse(undefined, "Username or email already exists", 400);
23+
}
24+
25+
// Create the user
26+
const encryptedPassword = await encryptPassword(body.password);
27+
let createUserResponse = await stub.executeExternalQuery(
28+
`INSERT INTO auth_users (username, password, email)
29+
VALUES (?, ?, ?)
30+
RETURNING id, username, email`,
31+
[body.username, encryptedPassword, body.email]
32+
);
33+
34+
if (createUserResponse?.result?.length === 0) {
35+
return createResponse(undefined, "Failed to create user", 500);
36+
}
37+
38+
// Create a session for the user
39+
const sessionToken = crypto.randomUUID();
40+
let createSessionResponse = await stub.executeExternalQuery(
41+
`INSERT INTO auth_sessions (user_id, session_token)
42+
VALUES (?, ?)
43+
RETURNING user_id, session_token, created_at`,
44+
[createUserResponse.result[0].id, sessionToken]
45+
);
46+
47+
if (createSessionResponse?.result?.length === 0) {
48+
return createResponse(undefined, "Failed to create session", 500);
49+
}
50+
51+
return createResponse(createSessionResponse.result[0], undefined, 200);
52+
} catch (error) {
53+
console.error('Signup Error:', error);
54+
return createResponse(undefined, "Username or email already exists", 500);
55+
}
56+
}
57+
58+
export async function login(stub: any, body: any) {
59+
if ((!body.email && !body.username) || !body.password) {
60+
return createResponse(undefined, "Missing required fields", 400);
61+
}
62+
63+
const encryptedPassword = await encryptPassword(body.password);
64+
let verifyUserResponse = await stub.executeExternalQuery(`SELECT * FROM auth_users WHERE (username = ? OR email = ?) AND password = ?`, [body.username, body.email, encryptedPassword]);
65+
if (verifyUserResponse?.result?.length === 0) {
66+
return createResponse(undefined, "User not found", 404);
67+
}
68+
69+
const user = verifyUserResponse.result[0];
70+
71+
// Create a session for the user
72+
const sessionToken = crypto.randomUUID();
73+
let createSessionResponse = await stub.executeExternalQuery(
74+
`INSERT INTO auth_sessions (user_id, session_token)
75+
VALUES (?, ?)
76+
RETURNING user_id, session_token, created_at`,
77+
[user.id, sessionToken]
78+
);
79+
80+
if (createSessionResponse?.result?.length === 0) {
81+
return createResponse(undefined, "Failed to create session", 500);
82+
}
83+
84+
return createResponse(createSessionResponse.result[0], undefined, 200);
85+
}

templates/auth/src/index.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { WorkerEntrypoint } from "cloudflare:workers";
2+
import { login as emailLogin, signup as emailSignup } from "./email";
3+
import { createResponse } from "./utils";
4+
5+
const DURABLE_OBJECT_ID = 'sql-durable-object';
6+
7+
interface Env {
8+
DATABASE_DURABLE_OBJECT: DurableObjectNamespace;
9+
}
10+
11+
export default class AuthEntrypoint extends WorkerEntrypoint<Env> {
12+
private stub: any;
13+
14+
// Currently, entrypoints without a named handler are not supported
15+
async fetch() { return new Response(null, {status: 404}); }
16+
17+
/**
18+
* Handles the auth requests, forwards to the appropriate handler
19+
* @param pathname
20+
* @param verb
21+
* @param body
22+
* @returns
23+
*/
24+
async handleAuth(pathname: string, verb: string, body: any) {
25+
let id: DurableObjectId = this.env.DATABASE_DURABLE_OBJECT.idFromName(DURABLE_OBJECT_ID);
26+
this.stub = this.env.DATABASE_DURABLE_OBJECT.get(id);
27+
28+
if (verb === "POST" && pathname === "/auth/signup") {
29+
return await emailSignup(this.stub, this.env, body);
30+
} else if (verb === "POST" && pathname === "/auth/login") {
31+
return await emailLogin(this.stub, body);
32+
} else if (verb === "POST" && pathname === "/auth/logout") {
33+
return await this.handleLogout(body);
34+
}
35+
36+
return new Response(null, {status: 405});
37+
}
38+
39+
/**
40+
* Handles logging out a user by invalidating all sessions for the user
41+
* @param request
42+
* @param body
43+
* @returns
44+
*/
45+
async handleLogout(body: any) {
46+
await this.stub.executeExternalQuery(`UPDATE auth_sessions SET deleted_at = CURRENT_TIMESTAMP WHERE user_id = ?`, [body.user_id]);
47+
return createResponse(JSON.stringify({
48+
success: true,
49+
}), undefined, 200);
50+
}
51+
52+
/**
53+
* Checks if a session is valid by checking if the session token exists and is not deleted
54+
* @param sessionToken
55+
* @returns
56+
*/
57+
async isSessionValid(sessionToken: string) {
58+
let result = await this.stub.executeExternalQuery(
59+
`SELECT * FROM auth_sessions
60+
WHERE session_token = ?
61+
AND deleted_at IS NULL`,
62+
[sessionToken]
63+
);
64+
return result.result.length > 0;
65+
}
66+
}

0 commit comments

Comments
 (0)