mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 05:41:59 +00:00
feat: add sign in feature
This commit is contained in:
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787835952782,
|
||||||
|
"tag": "0000_cooing_rumiko_fujikawa",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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)}"`);
|
||||||
+32
-5
@@ -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>
|
||||||
|
|
||||||
<a className="nav-cta" href="#status">
|
<div className="nav-actions">
|
||||||
View live status
|
{sessionQuery.data?.user ? (
|
||||||
<ArrowIcon />
|
<button
|
||||||
</a>
|
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">
|
||||||
|
View live status
|
||||||
|
<ArrowIcon />
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { LandingPage } from "../App";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user