fix: remove the email field

This commit is contained in:
2026-08-27 20:41:45 +07:00
parent 3a497fd539
commit a42af43ee7
13 changed files with 233 additions and 112 deletions
+27
View File
@@ -0,0 +1,27 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
DROP TABLE `sessions`;--> statement-breakpoint
DROP TABLE `login_attempts`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
CREATE TABLE `admin_credentials` (
`id` integer PRIMARY KEY NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`user_agent` text
);
--> statement-breakpoint
CREATE INDEX `sessions_expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint
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
PRAGMA foreign_keys=ON;
+142
View File
@@ -0,0 +1,142 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7773aaeb-a97b-4dd2-b5c7-312c83e1d6fb",
"prevId": "f37e2597-6362-40a8-9cf9-570efa9be326",
"tables": {
"admin_credentials": {
"name": "admin_credentials",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"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": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"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
},
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1787835952782,
"tag": "0000_cooing_rumiko_fujikawa",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1787837780070,
"tag": "0001_new_betty_brant",
"breakpoints": true
}
]
}
+3 -11
View File
@@ -1,7 +1,5 @@
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();
@@ -48,12 +46,6 @@ 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.");
@@ -62,9 +54,9 @@ if (password.length < 8) {
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});`,
"INSERT OR REPLACE INTO admin_credentials",
"(id, password_hash, created_at, updated_at)",
`VALUES (1, ${sqlValue(await hashPassword(password))}, ${now}, ${now});`,
].join(" ");
console.log("\nLocal database:");
+1 -1
View File
@@ -82,7 +82,7 @@ export function LandingPage() {
</div>
<div className="nav-actions">
{sessionQuery.data?.user ? (
{sessionQuery.data?.authenticated ? (
<button
className="nav-auth"
type="button"
+1 -7
View File
@@ -1,16 +1,10 @@
import { getJson, postJson } from "./http";
export type AuthUser = {
id: string;
email: string;
};
export type SessionResponse = {
user: AuthUser | null;
authenticated: boolean;
};
export type LoginInput = {
email: string;
password: string;
};
+5 -18
View File
@@ -3,19 +3,18 @@ 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]);
if (sessionQuery.data?.authenticated) navigate("/", { replace: true });
}, [sessionQuery.data?.authenticated]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
loginMutation.mutate(
{ email, password },
{ password },
{ onSuccess: () => navigate("/", { replace: true }) },
);
}
@@ -38,23 +37,10 @@ export function LoginPage() {
<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>
<span>Enter the admin password to manage your monitors.</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
@@ -65,6 +51,7 @@ export function LoginPage() {
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
autoFocus
/>
</div>
+1 -1
View File
@@ -32,7 +32,7 @@ export function useLogoutMutation() {
return useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.setQueryData<SessionResponse>(authKeys.session(), { user: null });
queryClient.setQueryData<SessionResponse>(authKeys.session(), { authenticated: false });
},
});
}
+7 -15
View File
@@ -1,24 +1,16 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { index, integer, sqliteTable, text } 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 adminCredentials = sqliteTable("admin_credentials", {
id: integer("id").primaryKey(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
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"),
+5 -5
View File
@@ -1,10 +1,10 @@
import { getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { getDb } from "../db/client";
import { getSessionUser, SESSION_COOKIE, type SessionUser } from "./session";
import { hasValidSession, SESSION_COOKIE } from "./session";
export type AuthVariables = {
user: SessionUser;
authenticated: true;
};
export const requireAuth = createMiddleware<{
@@ -14,9 +14,9 @@ export const requireAuth = createMiddleware<{
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);
const authenticated = await hasValidSession(getDb(context.env), token);
if (!authenticated) return context.json({ message: "Authentication required" }, 401);
context.set("user", user);
context.set("authenticated", true);
await next();
});
+5 -13
View File
@@ -1,16 +1,11 @@
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";
import { sessions } 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);
@@ -36,7 +31,6 @@ export function sessionCookieOptions(requestUrl: string): CookieOptions {
export async function createSession(
db: Database,
userId: string,
userAgent: string | null,
) {
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
@@ -44,7 +38,6 @@ export async function createSession(
await db.insert(sessions).values({
id: await sha256Hex(token),
userId,
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_DURATION_SECONDS * 1000),
userAgent,
@@ -53,14 +46,13 @@ export async function createSession(
return token;
}
export async function getSessionUser(
export async function hasValidSession(
db: Database,
token: string,
): Promise<SessionUser | null> {
): Promise<boolean> {
const [result] = await db
.select({ id: users.id, email: users.email })
.select({ id: sessions.id })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(
and(
eq(sessions.id, await sha256Hex(token)),
@@ -69,7 +61,7 @@ export async function getSessionUser(
)
.limit(1);
return result ?? null;
return result !== undefined;
}
export async function revokeSession(db: Database, token: string) {
+17 -22
View File
@@ -2,12 +2,12 @@ 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 { adminCredentials, loginAttempts } from "../db/schema";
import { verifyPassword } from "../lib/password";
import { requireAuth, type AuthVariables } from "../lib/require-auth";
import {
createSession,
getSessionUser,
hasValidSession,
revokeSession,
SESSION_COOKIE,
sessionCookieOptions,
@@ -15,10 +15,9 @@ import {
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_ATTEMPTS = 10;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ADMIN_CREDENTIAL_ID = 1;
type LoginBody = {
email?: unknown;
password?: unknown;
};
@@ -33,13 +32,11 @@ authRoutes.post("/api/auth/login", async (context) => {
}
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" },
{ message: "Enter a password of at least 8 characters" },
400,
);
}
@@ -64,34 +61,30 @@ authRoutes.post("/api/auth/login", async (context) => {
);
}
const email = body.email.trim().toLowerCase();
const [user] = await db
const [credential] = await db
.select({
id: users.id,
email: users.email,
passwordHash: users.passwordHash,
passwordHash: adminCredentials.passwordHash,
})
.from(users)
.where(eq(users.email, email))
.from(adminCredentials)
.where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID))
.limit(1);
const passwordMatches = user
? await verifyPassword(body.password, user.passwordHash)
const passwordMatches = credential
? await verifyPassword(body.password, credential.passwordHash)
: false;
if (!user || !passwordMatches) {
if (!credential || !passwordMatches) {
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() });
return context.json({ message: "Email or password is incorrect" }, 401);
return context.json({ message: "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 } });
return context.json({ authenticated: true });
});
authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
@@ -107,8 +100,10 @@ authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
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 });
const authenticated = token
? await hasValidSession(getDb(context.env), token)
: false;
return context.json({ authenticated });
});
export default authRoutes;
+12 -19
View File
@@ -2,23 +2,20 @@ 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"),
env.DB.prepare("DELETE FROM admin_credentials"),
]);
const now = Date.now();
await env.DB.prepare(
"INSERT INTO users (id, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO admin_credentials (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?)",
)
.bind(
"test-admin",
ADMIN_EMAIL,
await hashPassword(ADMIN_PASSWORD),
now,
now,
@@ -34,7 +31,7 @@ function login(password = ADMIN_PASSWORD, ipAddress = "198.51.100.10") {
"CF-Connecting-IP": ipAddress,
Origin: "https://example.com",
},
body: JSON.stringify({ email: ADMIN_EMAIL, password }),
body: JSON.stringify({ password }),
});
}
@@ -49,19 +46,17 @@ describe("authentication", () => {
});
beforeEach(seedAdmin);
it("logs in with the admin credentials and creates an HttpOnly session", async () => {
it("logs in with the admin password and creates an HttpOnly session", async () => {
const response = await login();
const body = await response.json<{ user: { id: string; email: string } }>();
const body = await response.json<{ authenticated: boolean }>();
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 });
expect(body).toEqual({ authenticated: true });
const session = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ?")
.bind("test-admin")
.first();
const session = await env.DB.prepare("SELECT id FROM sessions").first();
expect(session).not.toBeNull();
});
@@ -71,14 +66,14 @@ describe("authentication", () => {
expect(response.status).toBe(401);
expect(response.headers.get("Set-Cookie")).toBeNull();
expect(await response.json()).toEqual({
message: "Email or password is incorrect",
message: "Password is incorrect",
});
});
it("returns a nullable user from the session endpoint", async () => {
it("returns authentication state 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 });
expect(await anonymousResponse.json()).toEqual({ authenticated: false });
const loginResponse = await login();
const authenticatedResponse = await SELF.fetch("https://example.com/api/auth/me", {
@@ -86,9 +81,7 @@ describe("authentication", () => {
});
expect(authenticatedResponse.status).toBe(200);
expect(await authenticatedResponse.json()).toEqual({
user: { id: "test-admin", email: ADMIN_EMAIL },
});
expect(await authenticatedResponse.json()).toEqual({ authenticated: true });
});
it("revokes the persisted session on logout", async () => {
@@ -111,7 +104,7 @@ describe("authentication", () => {
const sessionResponse = await SELF.fetch("https://example.com/api/auth/me", {
headers: { Cookie: cookie },
});
expect(await sessionResponse.json()).toEqual({ user: null });
expect(await sessionResponse.json()).toEqual({ authenticated: false });
});
it("rate limits repeated failed login attempts by IP", async () => {