feat: email+password auth with db-backed sessions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
692ffa97c5
commit
5f8746cfae
@@ -0,0 +1,6 @@
|
||||
export default function Auth({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ maxWidth: 420, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)" }}>
|
||||
<p className="label">SQL Server qAI</p>{children}
|
||||
<p className="colophon">ShowPlan XML in · Jev-ranked remediation out · self-hosted</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { signIn } from "@/lib/actions";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
export default function Login() {
|
||||
return <>
|
||||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Sign in</h1>
|
||||
<AuthForm action={signIn} submit="Sign in" fields={[
|
||||
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
|
||||
{ name: "password", label: "Password", type: "password", autoComplete: "current-password" }]} />
|
||||
<p><a href="/signup">Create an account</a></p>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { signUp } from "@/lib/actions";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
export default function Signup() {
|
||||
return <>
|
||||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Create account</h1>
|
||||
<AuthForm action={signUp} submit="Create account" fields={[
|
||||
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
|
||||
{ name: "password", label: "Password", type: "password", autoComplete: "new-password",
|
||||
help: "10 characters minimum." }]} />
|
||||
<p><a href="/login">Sign in instead</a></p>
|
||||
</>;
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
export default function Console({ children }: { children: React.ReactNode }) {
|
||||
import { requireUser } from "@/lib/auth";
|
||||
|
||||
export default async function Console({ children }: { children: React.ReactNode }) {
|
||||
await requireUser();
|
||||
return <div className="shell">
|
||||
<aside className="rail" aria-label="Previous analyses">qAI</aside>
|
||||
<main className="main">{children}</main>
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
export default function Home() { redirect("/login"); }
|
||||
import { sessionUser } from "@/lib/auth";
|
||||
export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { useActionState } from "react";
|
||||
|
||||
export type FormResult = { ok?: boolean; error?: string } | null;
|
||||
|
||||
export function AuthForm({ action, submit, fields }: {
|
||||
action: (state: FormResult, fd: FormData) => Promise<FormResult>;
|
||||
submit: string;
|
||||
fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[];
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<FormResult, FormData>(action, null);
|
||||
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-md)" }}>
|
||||
{fields.map((f) => <div key={f.name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
|
||||
<label className="label" htmlFor={f.name}>{f.label}</label>
|
||||
<input className="field" id={f.name} name={f.name} type={f.type} autoComplete={f.autoComplete} required />
|
||||
{f.help && <span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{f.help}</span>}
|
||||
</div>)}
|
||||
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
|
||||
<button className="btn btn-primary" disabled={pending}>{submit}</button>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis } from "@/lib/db";
|
||||
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk } from "@/lib/auth";
|
||||
|
||||
export async function signUp(_: unknown, fd: FormData) {
|
||||
const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
|
||||
if (!r.ok) return r;
|
||||
await startSession(r.id);
|
||||
redirect("/setup");
|
||||
}
|
||||
export async function signIn(_: unknown, fd: FormData) {
|
||||
const e = String(fd.get("email") ?? "").trim().toLowerCase();
|
||||
const u = emailOk(e) ? userByEmail(e) : null;
|
||||
if (!u || !verifyPassword(String(fd.get("password") ?? ""), u.pass))
|
||||
return { ok: false, error: "Email or password is wrong." };
|
||||
await startSession(u.id);
|
||||
redirect(getSettings(u.id)?.onboarded === 1 ? "/app" : "/setup");
|
||||
}
|
||||
export async function signOut() { await endSession(); redirect("/login"); }
|
||||
|
||||
export async function rename(_: unknown, fd: FormData) {
|
||||
const u = await requireUser();
|
||||
const id = String(fd.get("id") ?? "");
|
||||
const title = String(fd.get("title") ?? "").trim().slice(0, 120);
|
||||
if (id && title) renameAnalysis(id, u.id, title);
|
||||
revalidatePath("/app");
|
||||
}
|
||||
export async function remove(_: unknown, fd: FormData) {
|
||||
const u = await requireUser();
|
||||
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
|
||||
revalidatePath("/app");
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "./db.ts";
|
||||
|
||||
export const SESSION_COOKIE = "qai_s";
|
||||
export const SESSION_DAYS = 30;
|
||||
export const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e);
|
||||
export const passwordOk = (p: string) => p.length >= 10; // trust boundary: signup + password change only
|
||||
|
||||
export function hashPassword(pw: string): string {
|
||||
const salt = randomBytes(16);
|
||||
return `${salt.toString("hex")}:${scryptSync(pw, salt, 64).toString("hex")}`;
|
||||
}
|
||||
export function verifyPassword(pw: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
try {
|
||||
const a = scryptSync(pw, Buffer.from(salt, "hex"), 64);
|
||||
const b = Buffer.from(hash, "hex");
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
} catch { return false; }
|
||||
}
|
||||
export const hashToken = (t: string) => createHash("sha256").update(t).digest("hex");
|
||||
|
||||
export function signUpNewUser(email: string, password: string): { ok: true; id: string } | { ok: false; error: string } {
|
||||
const e = email.trim().toLowerCase();
|
||||
if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
|
||||
if (!passwordOk(password)) return { ok: false, error: "Use at least 10 characters." };
|
||||
if (userByEmail(e)) return { ok: false, error: "That email already has an account." };
|
||||
// ponytail: no rate limiting — single-container tool. Add a token bucket before exposing this to a network.
|
||||
return { ok: true, id: createUser(e, hashPassword(password)) };
|
||||
}
|
||||
|
||||
// next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the
|
||||
// request-scoped binding must not be touched at import time.
|
||||
const jar = async () => (await import("next/headers")).cookies();
|
||||
|
||||
export async function startSession(userId: string) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
|
||||
(await jar()).set(SESSION_COOKIE, token, {
|
||||
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
|
||||
path: "/", maxAge: SESSION_DAYS * 86400 });
|
||||
}
|
||||
export async function endSession() {
|
||||
const store = await jar();
|
||||
const t = store.get(SESSION_COOKIE)?.value;
|
||||
if (t) dropSession(hashToken(t));
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
export async function sessionUser(): Promise<{ id: string; email: string } | null> {
|
||||
const t = (await jar()).get(SESSION_COOKIE)?.value;
|
||||
if (!t) return null;
|
||||
const id = sessionUserId(hashToken(t));
|
||||
if (!id) return null;
|
||||
const row = userEmail(id);
|
||||
return row ? { id, email: row.email } : null;
|
||||
}
|
||||
export async function requireUser(): Promise<{ id: string; email: string }> {
|
||||
const { redirect } = await import("next/navigation"); // lazy for the same reason as jar()
|
||||
const u = await sessionUser();
|
||||
if (!u) { redirect("/login"); throw new Error("redirecting to /login"); } // redirect throws; this only satisfies the type
|
||||
return u;
|
||||
}
|
||||
export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1;
|
||||
@@ -29,7 +29,8 @@ export const db = (g.__qaiDb ??= (() => {
|
||||
})());
|
||||
|
||||
const now = () => Date.now();
|
||||
const one = <T>(sql: string, ...p: unknown[]): T | null => (db.prepare(sql).get(...p) as T) ?? null;
|
||||
type Row = string | number | null;
|
||||
const one = <T>(sql: string, ...p: Row[]): T | null => (db.prepare(sql).get(...p) as T | undefined) ?? null;
|
||||
// node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans.
|
||||
|
||||
export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string;
|
||||
|
||||
Generated
+8
-4
@@ -17,7 +17,7 @@
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
@@ -1319,11 +1319,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"version": "24.13.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
|
||||
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -5419,7 +5421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-auth-`);
|
||||
const { hashPassword, verifyPassword, hashToken } = await import("../lib/auth.ts");
|
||||
|
||||
test("password hash round-trips and rejects", () => {
|
||||
const h = hashPassword("correct horse battery staple");
|
||||
assert.ok(h.includes(":"), "salt:hash shape");
|
||||
assert.ok(verifyPassword("correct horse battery staple", h));
|
||||
assert.ok(!verifyPassword("wrong", h));
|
||||
});
|
||||
test("malformed stored hash never verifies", () => {
|
||||
for (const bad of ["", "nosep", "a:", ":b", "zz:zz"]) assert.ok(!verifyPassword("x", bad));
|
||||
});
|
||||
test("token hash is stable, 64 hex", () => {
|
||||
assert.equal(hashToken("abc"), hashToken("abc"));
|
||||
assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
|
||||
});
|
||||
+13
-4
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -19,8 +23,11 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
@@ -30,5 +37,7 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user