diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..f83ab8b --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/worker/db/schema.ts", + out: "./migrations", +}); diff --git a/migrations/0000_cooing_rumiko_fujikawa.sql b/migrations/0000_cooing_rumiko_fujikawa.sql new file mode 100644 index 0000000..e2fbf15 --- /dev/null +++ b/migrations/0000_cooing_rumiko_fujikawa.sql @@ -0,0 +1,26 @@ +CREATE TABLE `login_attempts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `ip_address` text NOT NULL, + `attempted_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `login_attempts_ip_attempted_at_idx` ON `login_attempts` (`ip_address`,`attempted_at`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `user_agent` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `sessions_expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `password_hash` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..eb723fb --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,178 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f37e2597-6362-40a8-9cf9-570efa9be326", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 0000000..59cad1a --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1787835952782, + "tag": "0000_cooing_rumiko_fujikawa", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index e4b05d3..3dcbee7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "test": "vitest run", "types": "wrangler types", "db:local": "wrangler d1 migrations apply uptime --local", - "db:remote": "wrangler d1 migrations apply uptime --remote" + "db:remote": "wrangler d1 migrations apply uptime --remote", + "admin:create": "bun scripts/create-admin.ts" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.54.1", diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts new file mode 100644 index 0000000..592be6c --- /dev/null +++ b/scripts/create-admin.ts @@ -0,0 +1,73 @@ +import { hashPassword } from "../src/worker/lib/password"; + +const EMAIL_PATTERN = /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; + +async function readPassword() { + if (!process.stdin.isTTY) { + return (await new Response(Bun.stdin.stream()).text()).trimEnd(); + } + + process.stdout.write("Password: "); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + return new Promise((resolve, reject) => { + let password = ""; + const cleanup = () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\n"); + }; + + process.stdin.on("data", (key: string) => { + if (key === "\u0003") { + cleanup(); + reject(new Error("Cancelled")); + return; + } + if (key === "\r" || key === "\n") { + cleanup(); + resolve(password); + return; + } + if (key === "\u007f" || key === "\b") { + password = password.slice(0, -1); + return; + } + password += key; + }); + }); +} + +function sqlValue(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +function shellDoubleQuoted(value: string) { + return value.replace(/[\\"$`]/g, "\\$&"); +} + +const email = process.argv[2]?.trim().toLowerCase(); +if (!email || !EMAIL_PATTERN.test(email)) { + console.error("Usage: bun run admin:create "); + process.exit(1); +} + +const password = await readPassword(); +if (password.length < 8) { + console.error("Password must contain at least 8 characters."); + process.exit(1); +} + +const now = Date.now(); +const statement = [ + "INSERT OR REPLACE INTO users", + "(id, email, password_hash, created_at, updated_at)", + `VALUES (${sqlValue(crypto.randomUUID())}, ${sqlValue(email)}, ${sqlValue(await hashPassword(password))}, ${now}, ${now});`, +].join(" "); + +console.log("\nLocal database:"); +console.log(`bunx wrangler d1 execute uptime --local --command "${shellDoubleQuoted(statement)}"`); +console.log("\nRemote database:"); +console.log(`bunx wrangler d1 execute uptime --remote --command "${shellDoubleQuoted(statement)}"`); diff --git a/src/client/App.tsx b/src/client/App.tsx index a0a78be..f688cfb 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,7 @@ import { useHealthQuery } from "./queries/health"; +import { navigate, usePathname } from "./lib/router"; +import { LoginPage } from "./pages/LoginPage"; +import { useLogoutMutation, useSessionQuery } from "./queries/auth"; type IconProps = { className?: string; @@ -54,8 +57,10 @@ function formatCheckedAt(timestamp: number) { }).format(new Date(timestamp)); } -function App() { +export function LandingPage() { const { data: health, error, isError, isFetching, isPending, refetch } = useHealthQuery(); + const sessionQuery = useSessionQuery(); + const logoutMutation = useLogoutMutation(); const hasHealth = health !== undefined; const isHealthy = hasHealth && health.ok && health.db?.ok === 1; const statusLabel = isPending ? "Checking" : isHealthy ? "Operational" : "Degraded"; @@ -76,10 +81,27 @@ function App() { Status - - View live status - - +
+ {sessionQuery.data?.user ? ( + + ) : ( + { + event.preventDefault(); + navigate("/login"); + }}>Sign in + )} + + View live status + + +
@@ -195,4 +217,9 @@ function App() { ); } +function App() { + const pathname = usePathname(); + return pathname === "/login" ? : ; +} + export default App; diff --git a/src/client/api/auth.ts b/src/client/api/auth.ts new file mode 100644 index 0000000..b99f96a --- /dev/null +++ b/src/client/api/auth.ts @@ -0,0 +1,30 @@ +import { getJson, postJson } from "./http"; + +export type AuthUser = { + id: string; + email: string; +}; + +export type SessionResponse = { + user: AuthUser | null; +}; + +export type LoginInput = { + email: string; + password: string; +}; + +export function getSession(signal?: AbortSignal) { + return getJson("/api/auth/me", { + signal, + credentials: "same-origin", + }); +} + +export function login(input: LoginInput) { + return postJson("/api/auth/login", input); +} + +export function logout() { + return postJson<{ ok: true }>("/api/auth/logout"); +} diff --git a/src/client/api/http.ts b/src/client/api/http.ts index 94afac8..0bd943f 100644 --- a/src/client/api/http.ts +++ b/src/client/api/http.ts @@ -8,6 +8,19 @@ export class ApiError extends Error { } } +async function getErrorMessage(response: Response) { + try { + const body = await response.json<{ message?: unknown }>(); + if (typeof body.message === "string" && body.message.length > 0) { + return body.message; + } + } catch { + // Fall back to the HTTP status when the response is not JSON. + } + + return `Request returned HTTP ${response.status}`; +} + export async function getJson( input: RequestInfo | URL, init: RequestInit = {}, @@ -21,11 +34,25 @@ export async function getJson( }); if (!response.ok) { - throw new ApiError( - `Request returned HTTP ${response.status}`, - response.status, - ); + throw new ApiError(await getErrorMessage(response), response.status); } return response.json() as Promise; } + +export function postJson( + input: RequestInfo | URL, + body?: unknown, + init: RequestInit = {}, +) { + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + + return getJson(input, { + ...init, + method: "POST", + headers, + credentials: "same-origin", + body: body === undefined ? undefined : JSON.stringify(body), + }); +} diff --git a/src/client/lib/router.tsx b/src/client/lib/router.tsx new file mode 100644 index 0000000..d3fcf7f --- /dev/null +++ b/src/client/lib/router.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from "react"; + +const subscribe = (listener: () => void) => { + window.addEventListener("popstate", listener); + return () => window.removeEventListener("popstate", listener); +}; + +const getPathname = () => window.location.pathname; +const getServerPathname = () => "/"; + +export function usePathname() { + return useSyncExternalStore(subscribe, getPathname, getServerPathname); +} + +export function navigate(path: string, options: { replace?: boolean } = {}) { + if (path === window.location.pathname) return; + const method = options.replace ? "replaceState" : "pushState"; + window.history[method](null, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); + window.scrollTo({ top: 0 }); +} diff --git a/src/client/pages/LandingPage.tsx b/src/client/pages/LandingPage.tsx new file mode 100644 index 0000000..4d9eeef --- /dev/null +++ b/src/client/pages/LandingPage.tsx @@ -0,0 +1 @@ +export { LandingPage } from "../App"; diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx new file mode 100644 index 0000000..d1cecf9 --- /dev/null +++ b/src/client/pages/LoginPage.tsx @@ -0,0 +1,84 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { navigate } from "../lib/router"; +import { useLoginMutation, useSessionQuery } from "../queries/auth"; + +export function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const sessionQuery = useSessionQuery(); + const loginMutation = useLoginMutation(); + + useEffect(() => { + if (sessionQuery.data?.user) navigate("/", { replace: true }); + }, [sessionQuery.data?.user]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + loginMutation.mutate( + { email, password }, + { onSuccess: () => navigate("/", { replace: true }) }, + ); + } + + const errorMessage = loginMutation.error instanceof Error + ? loginMutation.error.message + : "Unable to sign in"; + + return ( +
+ { + event.preventDefault(); + navigate("/"); + }} aria-label="Upwatch home"> + + upwatch + + +
+
+

Admin access

+

Sign in to Upwatch

+ Manage monitors and review incidents from one place. +
+ +
+
+ + setEmail(event.target.value)} + required + autoFocus + /> +
+ +
+ + setPassword(event.target.value)} + minLength={8} + required + /> +
+ + {loginMutation.isError && ( +

{errorMessage}

+ )} + + +
+
+ +

Protected by an encrypted, seven-day session.

+
+ ); +} diff --git a/src/client/queries/auth.ts b/src/client/queries/auth.ts new file mode 100644 index 0000000..2a67436 --- /dev/null +++ b/src/client/queries/auth.ts @@ -0,0 +1,38 @@ +import { queryOptions, useMutation, useQuery } from "@tanstack/react-query"; +import { getSession, login, logout, type LoginInput, type SessionResponse } from "../api/auth"; +import { queryClient } from "../lib/query-client"; + +export const authKeys = { + all: ["auth"] as const, + session: () => [...authKeys.all, "session"] as const, +}; + +export const sessionQueryOptions = () => + queryOptions({ + queryKey: authKeys.session(), + queryFn: ({ signal }) => getSession(signal), + staleTime: 60_000, + retry: false, + }); + +export function useSessionQuery() { + return useQuery(sessionQueryOptions()); +} + +export function useLoginMutation() { + return useMutation({ + mutationFn: (input: LoginInput) => login(input), + onSuccess: (session) => { + queryClient.setQueryData(authKeys.session(), session); + }, + }); +} + +export function useLogoutMutation() { + return useMutation({ + mutationFn: logout, + onSuccess: () => { + queryClient.setQueryData(authKeys.session(), { user: null }); + }, + }); +} diff --git a/src/client/styles.css b/src/client/styles.css index 62381aa..985775e 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -45,6 +45,13 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--primary-deep); .nav-links { display: flex; align-items: center; gap: 34px; font-size: 14px; color: #4d4d4d; } .nav-links a, .text-link { transition: color 160ms ease; } .nav-links a:hover, .text-link:hover { color: var(--primary-deep); } +.nav-actions { display: flex; align-items: center; justify-self: end; gap: 16px; } +.nav-auth { + padding: 0; border: 0; font-size: 13px; font-weight: 500; color: #525252; background: transparent; + cursor: pointer; transition: color 160ms ease; +} +.nav-auth:hover:not(:disabled) { color: var(--primary-deep); } +.nav-auth:disabled { cursor: wait; opacity: 0.55; } .nav-cta { display: inline-flex; align-items: center; justify-self: end; gap: 8px; min-height: 36px; padding: 0 14px; border: 1px solid #cfcfcf; border-radius: 6px; font-size: 13px; font-weight: 500; background: #fff; @@ -175,6 +182,57 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei .site-footer > div { gap: 28px; } .site-footer > div span { gap: 7px; } +.auth-page { + display: grid; + grid-template-rows: auto 1fr auto; + justify-items: center; + min-height: 100dvh; + padding: 32px 24px 24px; + background: + radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%), + linear-gradient(#fff, #fcfcfc); +} +.auth-page::before { + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background-image: radial-gradient(#dcdcdc 0.65px, transparent 0.65px); + background-size: 16px 16px; mask-image: linear-gradient(to bottom, transparent, black 24%, transparent 76%); content: ""; +} +.auth-brand { + position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; + font-size: 20px; font-weight: 600; letter-spacing: -0.6px; +} +.auth-brand-mark { display: grid; place-items: center; width: 26px; height: 26px; color: var(--primary-deep); font-size: 23px; } +.auth-card { + position: relative; z-index: 1; align-self: center; width: min(100%, 420px); padding: 36px; + border: 1px solid var(--hairline); border-radius: 12px; background: rgb(255 255 255 / 0.96); + box-shadow: 0 18px 55px rgb(24 74 52 / 0.08), 0 2px 8px rgb(0 0 0 / 0.04); + animation: enter-copy 500ms cubic-bezier(0.16, 1, 0.3, 1) both; +} +.auth-heading p { margin: 0 0 12px; font: 500 10px/1.4 "IBM Plex Mono", monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #55816e; } +.auth-heading h1 { margin: 0; font-size: 30px; line-height: 1.16; letter-spacing: -1.2px; } +.auth-heading > span { display: block; margin-top: 12px; font-size: 14px; line-height: 1.55; color: #626262; } +.auth-form { display: grid; gap: 20px; margin-top: 30px; } +.auth-field { display: grid; gap: 8px; } +.auth-field label { font-size: 13px; font-weight: 500; color: #353535; } +.auth-field input { + width: 100%; min-height: 42px; padding: 8px 12px; border: 1px solid #cfcfcf; border-radius: 6px; + font: inherit; font-size: 14px; color: var(--ink); background: #fff; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025); transition: border-color 160ms ease, box-shadow 160ms ease; +} +.auth-field input:hover { border-color: #a9a9a9; } +.auth-field input:focus { border-color: var(--primary-deep); outline: 0; box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14); } +.auth-error { margin: -4px 0 0; padding: 10px 12px; border: 1px solid #efcaca; border-radius: 6px; font-size: 12px; line-height: 1.45; color: #9f2f2f; background: #fff6f6; } +.auth-submit { + display: inline-flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; padding: 8px 16px; + border: 1px solid #35c586; border-radius: 6px; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--primary); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), inset 0 1px rgb(255 255 255 / 0.2); cursor: pointer; + transition: background 160ms ease, transform 160ms ease; +} +.auth-submit:hover:not(:disabled) { background: #36c487; transform: translateY(-1px); } +.auth-submit:active:not(:disabled) { background: var(--primary-deep); transform: translateY(1px); } +.auth-submit:disabled { cursor: wait; opacity: 0.62; } +.auth-footnote { position: relative; z-index: 1; margin: 0; font-size: 11px; color: #858585; } + @keyframes enter-copy { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } } @keyframes enter-stage { from { opacity: 0; transform: translateY(24px) rotateY(-2deg); } to { opacity: 1; transform: translateY(0) rotateY(0); } } @keyframes spin { to { transform: rotate(360deg); } } @@ -192,6 +250,8 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei .nav-container, .hero, .site-footer { width: min(100% - 32px, 1280px); } .nav-container { grid-template-columns: 1fr auto; height: 62px; } .nav-links { display: none; } + .nav-actions { gap: 10px; } + .nav-cta { display: none; } .hero { min-height: auto; padding: 58px 0 70px; } h1 { font-size: clamp(40px, 12vw, 56px); line-height: 1.04; letter-spacing: -2.25px; } .hero-lead { font-size: 16px; } @@ -208,6 +268,12 @@ h1 { max-width: 590px; margin: 0; font-size: clamp(48px, 4.65vw, 68px); font-wei .site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 24px 0; } } +@media (max-width: 520px) { + .auth-page { padding: 24px 16px; } + .auth-card { padding: 28px 22px; } + .auth-heading h1 { font-size: 27px; } +} + @media (max-width: 430px) { .announcement { font-size: 11px; } .hero-actions { align-items: flex-start; flex-direction: column; gap: 18px; } diff --git a/src/worker/db/client.ts b/src/worker/db/client.ts new file mode 100644 index 0000000..74d9dec --- /dev/null +++ b/src/worker/db/client.ts @@ -0,0 +1,6 @@ +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "./schema"; + +export const getDb = (env: Env) => drizzle(env.DB, { schema }); + +export type Database = ReturnType; diff --git a/src/worker/db/schema.ts b/src/worker/db/schema.ts new file mode 100644 index 0000000..2cbad6b --- /dev/null +++ b/src/worker/db/schema.ts @@ -0,0 +1,37 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + passwordHash: text("password_hash").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [uniqueIndex("users_email_unique").on(table.email)], +); + +export const sessions = sqliteTable( + "sessions", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + userAgent: text("user_agent"), + }, + (table) => [index("sessions_expires_at_idx").on(table.expiresAt)], +); + +export const loginAttempts = sqliteTable( + "login_attempts", + { + id: integer("id").primaryKey({ autoIncrement: true }), + ipAddress: text("ip_address").notNull(), + attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)], +); diff --git a/src/worker/index.ts b/src/worker/index.ts index 92bc08a..e3cc134 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,7 +1,12 @@ import { Hono } from "hono"; +import { csrf } from "hono/csrf"; +import authRoutes from "./routes/auth"; +import { cleanupExpiredAuthRecords } from "./scheduled/cleanup"; const app = new Hono<{ Bindings: Env }>(); +app.use("/api/auth/*", csrf()); + app.get("/api/health", async (context) => { const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{ ok: number; @@ -14,12 +19,15 @@ app.get("/api/health", async (context) => { }); }); +app.route("/", authRoutes); + export default { fetch: app.fetch, - scheduled(controller) { + async scheduled(controller, env) { + await cleanupExpiredAuthRecords(env); console.log( JSON.stringify({ - message: "scheduled smoke test", + message: "scheduled auth cleanup completed", cron: controller.cron, scheduledTime: controller.scheduledTime, }), diff --git a/src/worker/lib/password.ts b/src/worker/lib/password.ts new file mode 100644 index 0000000..526fc04 --- /dev/null +++ b/src/worker/lib/password.ts @@ -0,0 +1,84 @@ +const HASH_ALGORITHM = "SHA-256"; +const HASH_BYTES = 32; +const SALT_BYTES = 16; + +// Kept conservative for the Workers Free plan. Increase this after raising the +// Worker CPU limit and re-hash the admin password. +export const PBKDF2_ITERATIONS = 25_000; + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(value: string) { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(plain), + "PBKDF2", + false, + ["deriveBits"], + ); + + return new Uint8Array( + await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: HASH_ALGORITHM, + salt, + iterations, + }, + key, + HASH_BYTES * 8, + ), + ); +} + +export async function hashPassword(plain: string) { + const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); + const hash = await derivePassword(plain, Uint8Array.from(salt).buffer, PBKDF2_ITERATIONS); + + return `pbkdf2$sha256$${PBKDF2_ITERATIONS}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`; +} + +export async function verifyPassword(plain: string, stored: string) { + const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split("$"); + const iterations = Number(iterationValue); + + if ( + scheme !== "pbkdf2" || + digest !== "sha256" || + extra.length > 0 || + !Number.isSafeInteger(iterations) || + iterations < 1 || + iterations > 1_000_000 || + !saltValue || + !hashValue + ) { + return false; + } + + try { + const salt = base64ToBytes(saltValue); + const expected = base64ToBytes(hashValue); + if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false; + + const actual = await derivePassword( + plain, + Uint8Array.from(salt).buffer, + iterations, + ); + const subtle = crypto.subtle as SubtleCrypto & { + timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean; + }; + return subtle.timingSafeEqual(actual, expected); + } catch { + return false; + } +} diff --git a/src/worker/lib/require-auth.ts b/src/worker/lib/require-auth.ts new file mode 100644 index 0000000..8cfc813 --- /dev/null +++ b/src/worker/lib/require-auth.ts @@ -0,0 +1,22 @@ +import { getCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; +import { getDb } from "../db/client"; +import { getSessionUser, SESSION_COOKIE, type SessionUser } from "./session"; + +export type AuthVariables = { + user: SessionUser; +}; + +export const requireAuth = createMiddleware<{ + Bindings: Env; + Variables: AuthVariables; +}>(async (context, next) => { + const token = getCookie(context, SESSION_COOKIE); + if (!token) return context.json({ message: "Authentication required" }, 401); + + const user = await getSessionUser(getDb(context.env), token); + if (!user) return context.json({ message: "Authentication required" }, 401); + + context.set("user", user); + await next(); +}); diff --git a/src/worker/lib/session.ts b/src/worker/lib/session.ts new file mode 100644 index 0000000..0761097 --- /dev/null +++ b/src/worker/lib/session.ts @@ -0,0 +1,77 @@ +import { and, eq, gt } from "drizzle-orm"; +import type { CookieOptions } from "hono/utils/cookie"; +import type { Database } from "../db/client"; +import { sessions, users } from "../db/schema"; + +export const SESSION_COOKIE = "upwatch_session"; +export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60; + +export type SessionUser = { + id: string; + email: string; +}; + +function bytesToBase64Url(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +async function sha256Hex(value: string) { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), + ); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export function sessionCookieOptions(requestUrl: string): CookieOptions { + return { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: SESSION_DURATION_SECONDS, + secure: new URL(requestUrl).protocol === "https:", + }; +} + +export async function createSession( + db: Database, + userId: string, + userAgent: string | null, +) { + const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32))); + const now = new Date(); + + await db.insert(sessions).values({ + id: await sha256Hex(token), + userId, + createdAt: now, + expiresAt: new Date(now.getTime() + SESSION_DURATION_SECONDS * 1000), + userAgent, + }); + + return token; +} + +export async function getSessionUser( + db: Database, + token: string, +): Promise { + const [result] = await db + .select({ id: users.id, email: users.email }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where( + and( + eq(sessions.id, await sha256Hex(token)), + gt(sessions.expiresAt, new Date()), + ), + ) + .limit(1); + + return result ?? null; +} + +export async function revokeSession(db: Database, token: string) { + await db.delete(sessions).where(eq(sessions.id, await sha256Hex(token))); +} diff --git a/src/worker/routes/auth.ts b/src/worker/routes/auth.ts new file mode 100644 index 0000000..faf630f --- /dev/null +++ b/src/worker/routes/auth.ts @@ -0,0 +1,114 @@ +import { and, count, eq, gte } from "drizzle-orm"; +import { Hono } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { getDb } from "../db/client"; +import { loginAttempts, users } from "../db/schema"; +import { verifyPassword } from "../lib/password"; +import { requireAuth, type AuthVariables } from "../lib/require-auth"; +import { + createSession, + getSessionUser, + revokeSession, + SESSION_COOKIE, + sessionCookieOptions, +} from "../lib/session"; + +const LOGIN_WINDOW_MS = 15 * 60 * 1000; +const MAX_FAILED_ATTEMPTS = 10; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type LoginBody = { + email?: unknown; + password?: unknown; +}; + +const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); + +authRoutes.post("/api/auth/login", async (context) => { + let body: LoginBody; + try { + body = await context.req.json(); + } catch { + return context.json({ message: "Invalid request body" }, 400); + } + + if ( + typeof body.email !== "string" || + !EMAIL_PATTERN.test(body.email.trim()) || + typeof body.password !== "string" || + body.password.length < 8 + ) { + return context.json( + { message: "Enter a valid email and a password of at least 8 characters" }, + 400, + ); + } + + const db = getDb(context.env); + const ipAddress = context.req.header("CF-Connecting-IP") ?? "unknown"; + const cutoff = new Date(Date.now() - LOGIN_WINDOW_MS); + const [attemptResult] = await db + .select({ value: count() }) + .from(loginAttempts) + .where( + and( + eq(loginAttempts.ipAddress, ipAddress), + gte(loginAttempts.attemptedAt, cutoff), + ), + ); + + if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) { + return context.json( + { message: "Too many login attempts. Try again later" }, + 429, + ); + } + + const email = body.email.trim().toLowerCase(); + const [user] = await db + .select({ + id: users.id, + email: users.email, + passwordHash: users.passwordHash, + }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + const passwordMatches = user + ? await verifyPassword(body.password, user.passwordHash) + : false; + + if (!user || !passwordMatches) { + await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() }); + return context.json({ message: "Email or password is incorrect" }, 401); + } + + await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress)); + const token = await createSession( + db, + user.id, + context.req.header("User-Agent") ?? null, + ); + setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url)); + + return context.json({ user: { id: user.id, email: user.email } }); +}); + +authRoutes.post("/api/auth/logout", requireAuth, async (context) => { + const token = getCookie(context, SESSION_COOKIE); + if (token) await revokeSession(getDb(context.env), token); + + deleteCookie(context, SESSION_COOKIE, { + path: "/", + secure: new URL(context.req.url).protocol === "https:", + }); + return context.json({ ok: true }); +}); + +authRoutes.get("/api/auth/me", async (context) => { + const token = getCookie(context, SESSION_COOKIE); + const user = token ? await getSessionUser(getDb(context.env), token) : null; + return context.json({ user }); +}); + +export default authRoutes; diff --git a/src/worker/scheduled/cleanup.ts b/src/worker/scheduled/cleanup.ts new file mode 100644 index 0000000..7eb6f5c --- /dev/null +++ b/src/worker/scheduled/cleanup.ts @@ -0,0 +1,22 @@ +import { lt } from "drizzle-orm"; +import { getDb } from "../db/client"; +import { loginAttempts, sessions } from "../db/schema"; + +const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000; + +export async function cleanupExpiredAuthRecords(env: Env) { + const db = getDb(env); + const now = new Date(); + + await db.batch([ + db.delete(sessions).where(lt(sessions.expiresAt, now)), + db + .delete(loginAttempts) + .where( + lt( + loginAttempts.attemptedAt, + new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS), + ), + ), + ]); +} diff --git a/test/auth.spec.ts b/test/auth.spec.ts new file mode 100644 index 0000000..e5f994d --- /dev/null +++ b/test/auth.spec.ts @@ -0,0 +1,129 @@ +import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { hashPassword } from "../src/worker/lib/password"; + +const ADMIN_EMAIL = "admin@example.com"; +const ADMIN_PASSWORD = "correct-horse-battery-staple"; + +async function seedAdmin() { + await env.DB.batch([ + env.DB.prepare("DELETE FROM login_attempts"), + env.DB.prepare("DELETE FROM sessions"), + env.DB.prepare("DELETE FROM users"), + ]); + + const now = Date.now(); + await env.DB.prepare( + "INSERT INTO users (id, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind( + "test-admin", + ADMIN_EMAIL, + await hashPassword(ADMIN_PASSWORD), + now, + now, + ) + .run(); +} + +function login(password = ADMIN_PASSWORD, ipAddress = "198.51.100.10") { + return SELF.fetch("https://example.com/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "CF-Connecting-IP": ipAddress, + Origin: "https://example.com", + }, + body: JSON.stringify({ email: ADMIN_EMAIL, password }), + }); +} + +function cookieFrom(response: Response) { + return response.headers.get("Set-Cookie")?.split(";", 1)[0] ?? ""; +} + +describe("authentication", () => { + beforeAll(async () => { + const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] }; + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + }); + beforeEach(seedAdmin); + + it("logs in with the admin credentials and creates an HttpOnly session", async () => { + const response = await login(); + const body = await response.json<{ user: { id: string; email: string } }>(); + + expect(response.status).toBe(200); + expect(response.headers.get("Set-Cookie")).toContain("upwatch_session="); + expect(response.headers.get("Set-Cookie")).toContain("HttpOnly"); + expect(response.headers.get("Set-Cookie")).toContain("SameSite=Lax"); + expect(body.user).toEqual({ id: "test-admin", email: ADMIN_EMAIL }); + + const session = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ?") + .bind("test-admin") + .first(); + expect(session).not.toBeNull(); + }); + + it("rejects an incorrect password without setting a cookie", async () => { + const response = await login("incorrect-password"); + + expect(response.status).toBe(401); + expect(response.headers.get("Set-Cookie")).toBeNull(); + expect(await response.json()).toEqual({ + message: "Email or password is incorrect", + }); + }); + + it("returns a nullable user from the session endpoint", async () => { + const anonymousResponse = await SELF.fetch("https://example.com/api/auth/me"); + expect(anonymousResponse.status).toBe(200); + expect(await anonymousResponse.json()).toEqual({ user: null }); + + const loginResponse = await login(); + const authenticatedResponse = await SELF.fetch("https://example.com/api/auth/me", { + headers: { Cookie: cookieFrom(loginResponse) }, + }); + + expect(authenticatedResponse.status).toBe(200); + expect(await authenticatedResponse.json()).toEqual({ + user: { id: "test-admin", email: ADMIN_EMAIL }, + }); + }); + + it("revokes the persisted session on logout", async () => { + const loginResponse = await login(); + const cookie = cookieFrom(loginResponse); + const logoutResponse = await SELF.fetch("https://example.com/api/auth/logout", { + method: "POST", + headers: { + Cookie: cookie, + Origin: "https://example.com", + }, + }); + + expect(logoutResponse.status).toBe(200); + expect(await logoutResponse.json()).toEqual({ ok: true }); + const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions") + .first<{ count: number }>(); + expect(sessionCount?.count).toBe(0); + + const sessionResponse = await SELF.fetch("https://example.com/api/auth/me", { + headers: { Cookie: cookie }, + }); + expect(await sessionResponse.json()).toEqual({ user: null }); + }); + + it("rate limits repeated failed login attempts by IP", async () => { + const responses: Response[] = []; + for (let attempt = 0; attempt < 11; attempt += 1) { + responses.push(await login("incorrect-password", "203.0.113.42")); + } + + expect(responses.slice(0, 10).every((response) => response.status === 401)).toBe(true); + expect(responses[10].status).toBe(429); + expect(await responses[10].json()).toEqual({ + message: "Too many login attempts. Try again later", + }); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 195e6c0..78c41b4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,10 +1,15 @@ -import { cloudflareTest } from "@cloudflare/vitest-plugin"; +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin"; import { defineConfig } from "vitest/config"; +const migrations = await readD1Migrations("./migrations"); + export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { + bindings: { TEST_MIGRATIONS: migrations }, + }, }), ], });