mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: add sign in feature
This commit is contained in:
+32
-5
@@ -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() {
|
||||
<a href="#status">Status</a>
|
||||
</div>
|
||||
|
||||
<a className="nav-cta" href="#status">
|
||||
View live status
|
||||
<ArrowIcon />
|
||||
</a>
|
||||
<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">
|
||||
View live status
|
||||
<ArrowIcon />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -195,4 +217,9 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const pathname = usePathname();
|
||||
return pathname === "/login" ? <LoginPage /> : <LandingPage />;
|
||||
}
|
||||
|
||||
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>(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit = {},
|
||||
@@ -21,11 +34,25 @@ export async function getJson<T>(
|
||||
});
|
||||
|
||||
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<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 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; }
|
||||
|
||||
@@ -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 { 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,
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user