-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #26 from Brayden/bwilmoth/template-auth
User Auth Template
- Loading branch information
Showing
12 changed files
with
445 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.