feat: add sign in feature

This commit is contained in:
2026-08-27 20:29:23 +07:00
parent 0ff553bf27
commit 3a497fd539
24 changed files with 1109 additions and 13 deletions
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/worker/db/schema.ts",
out: "./migrations",
});
@@ -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`);
+178
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1787835952782,
"tag": "0000_cooing_rumiko_fujikawa",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -10,7 +10,8 @@
"test": "vitest run", "test": "vitest run",
"types": "wrangler types", "types": "wrangler types",
"db:local": "wrangler d1 migrations apply uptime --local", "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": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.54.1", "@cloudflare/vite-plugin": "^1.54.1",
+73
View File
@@ -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<string>((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 <email>");
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)}"`);
+28 -1
View File
@@ -1,4 +1,7 @@
import { useHealthQuery } from "./queries/health"; import { useHealthQuery } from "./queries/health";
import { navigate, usePathname } from "./lib/router";
import { LoginPage } from "./pages/LoginPage";
import { useLogoutMutation, useSessionQuery } from "./queries/auth";
type IconProps = { type IconProps = {
className?: string; className?: string;
@@ -54,8 +57,10 @@ function formatCheckedAt(timestamp: number) {
}).format(new Date(timestamp)); }).format(new Date(timestamp));
} }
function App() { export function LandingPage() {
const { data: health, error, isError, isFetching, isPending, refetch } = useHealthQuery(); const { data: health, error, isError, isFetching, isPending, refetch } = useHealthQuery();
const sessionQuery = useSessionQuery();
const logoutMutation = useLogoutMutation();
const hasHealth = health !== undefined; const hasHealth = health !== undefined;
const isHealthy = hasHealth && health.ok && health.db?.ok === 1; const isHealthy = hasHealth && health.ok && health.db?.ok === 1;
const statusLabel = isPending ? "Checking" : isHealthy ? "Operational" : "Degraded"; const statusLabel = isPending ? "Checking" : isHealthy ? "Operational" : "Degraded";
@@ -76,10 +81,27 @@ function App() {
<a href="#status">Status</a> <a href="#status">Status</a>
</div> </div>
<div className="nav-actions">
{sessionQuery.data?.user ? (
<button
className="nav-auth"
type="button"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
{logoutMutation.isPending ? "Signing out…" : "Sign out"}
</button>
) : (
<a className="nav-auth" href="/login" onClick={(event) => {
event.preventDefault();
navigate("/login");
}}>Sign in</a>
)}
<a className="nav-cta" href="#status"> <a className="nav-cta" href="#status">
View live status View live status
<ArrowIcon /> <ArrowIcon />
</a> </a>
</div>
</nav> </nav>
</header> </header>
@@ -195,4 +217,9 @@ function App() {
); );
} }
function App() {
const pathname = usePathname();
return pathname === "/login" ? <LoginPage /> : <LandingPage />;
}
export default App; export default App;
+30
View File
@@ -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<SessionResponse>("/api/auth/me", {
signal,
credentials: "same-origin",
});
}
export function login(input: LoginInput) {
return postJson<SessionResponse>("/api/auth/login", input);
}
export function logout() {
return postJson<{ ok: true }>("/api/auth/logout");
}
+31 -4
View File
@@ -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<T>( export async function getJson<T>(
input: RequestInfo | URL, input: RequestInfo | URL,
init: RequestInit = {}, init: RequestInit = {},
@@ -21,11 +34,25 @@ export async function getJson<T>(
}); });
if (!response.ok) { if (!response.ok) {
throw new ApiError( throw new ApiError(await getErrorMessage(response), response.status);
`Request returned HTTP ${response.status}`,
response.status,
);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export function postJson<T>(
input: RequestInfo | URL,
body?: unknown,
init: RequestInit = {},
) {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
return getJson<T>(input, {
...init,
method: "POST",
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
}
+21
View File
@@ -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 });
}
+1
View File
@@ -0,0 +1 @@
export { LandingPage } from "../App";
+84
View File
@@ -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<HTMLFormElement>) {
event.preventDefault();
loginMutation.mutate(
{ email, password },
{ onSuccess: () => navigate("/", { replace: true }) },
);
}
const errorMessage = loginMutation.error instanceof Error
? loginMutation.error.message
: "Unable to sign in";
return (
<main className="auth-page">
<a className="auth-brand" href="/" onClick={(event) => {
event.preventDefault();
navigate("/");
}} aria-label="Upwatch home">
<span className="auth-brand-mark" aria-hidden="true">ϟ</span>
<span>upwatch</span>
</a>
<section className="auth-card" aria-labelledby="login-title">
<div className="auth-heading">
<p>Admin access</p>
<h1 id="login-title">Sign in to Upwatch</h1>
<span>Manage monitors and review incidents from one place.</span>
</div>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-field">
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoFocus
/>
</div>
<div className="auth-field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
</div>
{loginMutation.isError && (
<p className="auth-error" role="alert">{errorMessage}</p>
)}
<button className="auth-submit" type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? "Signing in…" : "Sign in"}
</button>
</form>
</section>
<p className="auth-footnote">Protected by an encrypted, seven-day session.</p>
</main>
);
}
+38
View File
@@ -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<SessionResponse>(authKeys.session(), { user: null });
},
});
}
+66
View File
@@ -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 { display: flex; align-items: center; gap: 34px; font-size: 14px; color: #4d4d4d; }
.nav-links a, .text-link { transition: color 160ms ease; } .nav-links a, .text-link { transition: color 160ms ease; }
.nav-links a:hover, .text-link:hover { color: var(--primary-deep); } .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 { .nav-cta {
display: inline-flex; align-items: center; justify-self: end; gap: 8px; min-height: 36px; padding: 0 14px; 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; 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 { gap: 28px; }
.site-footer > div span { gap: 7px; } .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-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 enter-stage { from { opacity: 0; transform: translateY(24px) rotateY(-2deg); } to { opacity: 1; transform: translateY(0) rotateY(0); } }
@keyframes spin { to { transform: rotate(360deg); } } @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, .hero, .site-footer { width: min(100% - 32px, 1280px); }
.nav-container { grid-template-columns: 1fr auto; height: 62px; } .nav-container { grid-template-columns: 1fr auto; height: 62px; }
.nav-links { display: none; } .nav-links { display: none; }
.nav-actions { gap: 10px; }
.nav-cta { display: none; }
.hero { min-height: auto; padding: 58px 0 70px; } .hero { min-height: auto; padding: 58px 0 70px; }
h1 { font-size: clamp(40px, 12vw, 56px); line-height: 1.04; letter-spacing: -2.25px; } h1 { font-size: clamp(40px, 12vw, 56px); line-height: 1.04; letter-spacing: -2.25px; }
.hero-lead { font-size: 16px; } .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; } .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) { @media (max-width: 430px) {
.announcement { font-size: 11px; } .announcement { font-size: 11px; }
.hero-actions { align-items: flex-start; flex-direction: column; gap: 18px; } .hero-actions { align-items: flex-start; flex-direction: column; gap: 18px; }
+6
View File
@@ -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<typeof getDb>;
+37
View File
@@ -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)],
);
+10 -2
View File
@@ -1,7 +1,12 @@
import { Hono } from "hono"; 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 }>(); const app = new Hono<{ Bindings: Env }>();
app.use("/api/auth/*", csrf());
app.get("/api/health", async (context) => { app.get("/api/health", async (context) => {
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{ const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
ok: number; ok: number;
@@ -14,12 +19,15 @@ app.get("/api/health", async (context) => {
}); });
}); });
app.route("/", authRoutes);
export default { export default {
fetch: app.fetch, fetch: app.fetch,
scheduled(controller) { async scheduled(controller, env) {
await cleanupExpiredAuthRecords(env);
console.log( console.log(
JSON.stringify({ JSON.stringify({
message: "scheduled smoke test", message: "scheduled auth cleanup completed",
cron: controller.cron, cron: controller.cron,
scheduledTime: controller.scheduledTime, scheduledTime: controller.scheduledTime,
}), }),
+84
View File
@@ -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;
}
}
+22
View File
@@ -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();
});
+77
View File
@@ -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<SessionUser | null> {
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)));
}
+114
View File
@@ -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<LoginBody>();
} 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;
+22
View File
@@ -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),
),
),
]);
}
+129
View File
@@ -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",
});
});
});
+6 -1
View File
@@ -1,10 +1,15 @@
import { cloudflareTest } from "@cloudflare/vitest-plugin"; import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const migrations = await readD1Migrations("./migrations");
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
cloudflareTest({ cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" }, wrangler: { configPath: "./wrangler.jsonc" },
miniflare: {
bindings: { TEST_MIGRATIONS: migrations },
},
}), }),
], ],
}); });