From 4faf0929b1efe06e64e92a44c1980824572a3327 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:13:34 +0800 Subject: [PATCH] allow password creations --- .env.example | 2 +- README.md | 4 +- src/lib/server/api.ts | 149 +++++++++--- src/lib/server/db.ts | 9 + src/lib/server/email.ts | 32 ++- src/lib/stores/auth.ts | 30 ++- src/routes/auth/login/+page.svelte | 377 ++++++++++++++++++++++++----- 7 files changed, 483 insertions(+), 120 deletions(-) diff --git a/.env.example b/.env.example index 11d600a..8a85be1 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,4 @@ DATABASE_PATH=data/dsec.db RESEND_API_KEY="" # Optional "from" address for verification emails. -RESEND_FROM=DSEC Notebook +RESEND_FROM="DSEC Notebook " diff --git a/README.md b/README.md index cb9d35c..103e995 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A centralised resource hub for Deakin University students studying **SIT** (IT, - 💬 **Comments** — discuss notes directly. - 👍 **Voting** — upvote or downvote notes and questions. - 🔍 **Search** — find notes by title or content. -- 🎓 **Deakin-only sign in** — only `@deakin.edu.au` email addresses can contribute. +- 🎓 **Deakin-only accounts** — only `@deakin.edu.au` email addresses can contribute. Create an account with a password after verifying your email, then sign in with email and password. - 🗂️ **Units & topics** — browse content by Deakin unit code (e.g. `SIT102`, `SIT192`) or topic (e.g. Algorithms, Mathematics). - 💾 **Persistent storage** — all data is stored in a local SQLite database. @@ -118,7 +118,7 @@ src/ - The frontend calls a single JSON API endpoint (`POST /api`) with a function name and arguments. - The server dispatches those calls to handlers in `src/lib/server/api.ts`, backed by SQLite. - On first run, the database is created automatically and seeded with common Deakin SIT/Math units and CS/maths topics. -- Authentication is email-verified: signing in with a `@deakin.edu.au` address sends a 6-digit code via Resend, which the user must enter to prove they own the inbox. Verified users are created or reused and given a session token stored in `localStorage`. +- Authentication is email-verified: to create an account you sign in with a `@deakin.edu.au` address, a 6-digit code is sent via Resend, and after entering the code you set a password. Once created, you sign in with just your email and password. A "forgot password" flow sends a reset code to your Deakin address so you can set a new password. Passwords are stored as salted scrypt hashes, and sessions are stored as tokens in `localStorage`. ## Data model diff --git a/src/lib/server/api.ts b/src/lib/server/api.ts index b5dcb02..de56d0e 100644 --- a/src/lib/server/api.ts +++ b/src/lib/server/api.ts @@ -1,6 +1,13 @@ import { getDb } from "./db"; -import { createHash, randomInt, randomUUID } from "node:crypto"; -import { sendVerificationCode } from "./email"; +import { + createHash, + randomBytes, + randomInt, + randomUUID, + scryptSync, + timingSafeEqual, +} from "node:crypto"; +import { sendPasswordResetCode, sendVerificationCode } from "./email"; const DEAKIN_DOMAIN = "deakin.edu.au"; const CODE_TTL_MS = 10 * 60 * 1000; @@ -30,6 +37,28 @@ function hashCode(code: string): string { return createHash("sha256").update(code).digest("hex"); } +function hashPassword(password: string): string { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(":"); + if (!salt || !hash) return false; + const candidate = scryptSync(password, salt, 64); + const expected = Buffer.from(hash, "hex"); + if (candidate.length !== expected.length) return false; + return timingSafeEqual(candidate, expected); +} + +function validatePassword(password: string): string { + if (!password || password.length < 8) { + throw new Error("Password must be at least 8 characters"); + } + return password; +} + function normalizeEmail(email: string): string { const normalized = email.trim().toLowerCase(); const emailDomain = normalized.split("@")[1]?.toLowerCase(); @@ -55,38 +84,13 @@ function mapQuestion(row: Record): Record { // ---- users ---- -function createOrReuseUser(db: Db, email: string, name: string) { - const existing = db - .prepare( - "SELECT id AS _id, email, name, sessionToken, createdAt AS _creationTime FROM users WHERE email = ?", - ) - .get(email) as Record | undefined; - +function issueSession(db: Db, user: { _id: string; name: string }) { const token = generateToken(); - - if (existing) { - db.prepare("UPDATE users SET sessionToken = ?, name = ? WHERE id = ?").run( - token, - name, - existing._id, - ); - return { userId: existing._id, token, name }; - } - - const id = newId(); - db.prepare( - "INSERT INTO users (id, email, name, sessionToken, createdAt) VALUES (?, ?, ?, ?, ?)", - ).run(id, email, name, token, Date.now()); - return { userId: id, token, name }; + db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id); + return { userId: user._id, token, name: user.name }; } -async function authRequestCode(db: Db, args: { email: string; name: string }) { - const email = normalizeEmail(args.email); - if (!args.name || !args.name.trim()) { - throw new Error("Name is required"); - } - const name = args.name.trim(); - +async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") { const existing = db .prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?") .get(email) as { email: string; createdAt: number } | undefined; @@ -111,7 +115,11 @@ async function authRequestCode(db: Db, args: { email: string; name: string }) { } try { - await sendVerificationCode(email, code); + if (kind === "reset") { + await sendPasswordResetCode(email, code); + } else { + await sendVerificationCode(email, code); + } } catch (err) { db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email); throw err; @@ -119,9 +127,7 @@ async function authRequestCode(db: Db, args: { email: string; name: string }) { return { ok: true }; } -function authVerifyCode(db: Db, args: { email: string; code: string }) { - const email = normalizeEmail(args.email); - +function verifyAndConsumeCode(db: Db, email: string, code: string): { name: string } { const pending = db .prepare( "SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?", @@ -134,7 +140,7 @@ function authVerifyCode(db: Db, args: { email: string; code: string }) { if (Date.now() > pending.expiresAt) throw new Error("Verification code has expired"); if (pending.attempts >= MAX_ATTEMPTS) throw new Error("Too many attempts. Request a new code."); - if (hashCode(args.code) !== pending.codeHash) { + if (hashCode(code) !== pending.codeHash) { db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run( email, ); @@ -142,7 +148,71 @@ function authVerifyCode(db: Db, args: { email: string; code: string }) { } db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email); - return createOrReuseUser(db, email, pending.name); + return { name: pending.name }; +} + +async function authRequestCode(db: Db, args: { email: string; name: string }) { + const email = normalizeEmail(args.email); + if (!args.name || !args.name.trim()) { + throw new Error("Name is required"); + } + return await sendCode(db, email, args.name.trim(), "verification"); +} + +function authSignup(db: Db, args: { email: string; code: string; password: string }) { + const email = normalizeEmail(args.email); + const password = validatePassword(args.password); + const pending = verifyAndConsumeCode(db, email, args.code); + + const existing = db.prepare("SELECT id AS _id FROM users WHERE email = ?").get(email) as + | { _id: string } + | undefined; + if (existing) throw new Error("An account already exists for this email. Sign in instead."); + + const id = newId(); + db.prepare( + "INSERT INTO users (id, email, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)", + ).run(id, email, pending.name, hashPassword(password), Date.now()); + + return issueSession(db, { _id: id, name: pending.name }); +} + +function authSignin(db: Db, args: { email: string; password: string }) { + const email = normalizeEmail(args.email); + const user = db + .prepare("SELECT id AS _id, name, passwordHash FROM users WHERE email = ?") + .get(email) as { _id: string; name: string; passwordHash: string | null } | undefined; + + if (!user || !user.passwordHash) throw new Error("No account found for this email"); + if (!verifyPassword(args.password, user.passwordHash)) throw new Error("Incorrect password"); + + return issueSession(db, { _id: user._id, name: user.name }); +} + +async function authForgotPassword(db: Db, args: { email: string }) { + const email = normalizeEmail(args.email); + const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as + | { _id: string; name: string } + | undefined; + if (!user) throw new Error("No account found for this email"); + return await sendCode(db, email, user.name, "reset"); +} + +function authResetPassword(db: Db, args: { email: string; code: string; password: string }) { + const email = normalizeEmail(args.email); + const password = validatePassword(args.password); + verifyAndConsumeCode(db, email, args.code); + + const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as + | { _id: string; name: string } + | undefined; + if (!user) throw new Error("No account found for this email"); + + db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run( + hashPassword(password), + user._id, + ); + return issueSession(db, { _id: user._id, name: user.name }); } function usersGetByToken(db: Db, args: { token: string }) { @@ -499,7 +569,10 @@ type Handler = (db: Db, args: any) => any; const handlers: Record = { "auth:requestCode": authRequestCode, - "auth:verifyCode": authVerifyCode, + "auth:signup": authSignup, + "auth:signin": authSignin, + "auth:forgotPassword": authForgotPassword, + "auth:resetPassword": authResetPassword, "users:getByToken": usersGetByToken, "topics:getBySlug": topicsGetBySlug, "topics:getAll": topicsGetAll, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index a6ff345..b8e46e5 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -15,6 +15,7 @@ function createSchema(database: DatabaseSync) { email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, sessionToken TEXT, + passwordHash TEXT, createdAt INTEGER NOT NULL ); @@ -204,6 +205,13 @@ const SEED_UNITS = [ { code: "MIS798", name: "Business Process Management" }, ]; +function migrate(database: DatabaseSync) { + const columns = database.prepare("PRAGMA table_info(users)").all() as { name: string }[]; + if (!columns.some((c) => c.name === "passwordHash")) { + database.exec("ALTER TABLE users ADD COLUMN passwordHash TEXT"); + } +} + function seed(database: DatabaseSync) { const existing = database.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number }; if (existing.c > 0) return; @@ -233,6 +241,7 @@ export function getDb(): DatabaseSync { mkdirSync(dirname(DB_PATH), { recursive: true }); db = new DatabaseSync(DB_PATH); createSchema(db); + migrate(db); seed(db); } return db; diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index 7f9f0d8..a8e39f8 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -4,24 +4,40 @@ import { RESEND_API_KEY, RESEND_FROM } from "$env/static/private"; const API_KEY = RESEND_API_KEY; const FROM = RESEND_FROM; -export async function sendVerificationCode(email: string, code: string): Promise { +async function send(email: string, subject: string, text: string): Promise { if (!API_KEY) { - throw new Error("Server error: API error is not configured"); - } + throw new Error("Server error: API key is not configured"); + } - if (!FROM) { - throw new Error("Server error: Email is not configured"); - } + if (!FROM) { + throw new Error("Server error: From address is not configured"); + } const resend = new Resend(API_KEY); const { error } = await resend.emails.send({ from: FROM, to: email, - subject: "Your DSEC Notebook verification code", - text: `Your verification code is ${code}. It expires in 10 minutes.`, + subject, + text, }); if (error) { throw new Error(error.message); } } + +export function sendVerificationCode(email: string, code: string): Promise { + return send( + email, + "Your DSEC Notebook verification code", + `Your verification code is ${code}. It expires in 10 minutes.`, + ); +} + +export function sendPasswordResetCode(email: string, code: string): Promise { + return send( + email, + "Your DSEC Notebook password reset code", + `Your password reset code is ${code}. It expires in 10 minutes. If you did not request this, you can ignore this email.`, + ); +} diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 5b14c6d..900ae63 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -25,12 +25,7 @@ export async function initAuth() { } } -export async function requestCode(email: string, name: string) { - return await mutation("auth:requestCode", { email, name }); -} - -export async function verifyCode(email: string, code: string) { - const result = await mutation("auth:verifyCode", { email, code }); +function setSession(result: { userId: string; token: string; name: string }, email: string) { localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: result.token })); currentUser.set({ _id: result.userId, @@ -43,6 +38,29 @@ export async function verifyCode(email: string, code: string) { return result; } +export async function requestCode(email: string, name: string) { + return await mutation("auth:requestCode", { email, name }); +} + +export async function signUp(email: string, code: string, password: string) { + const result = await mutation("auth:signup", { email, code, password }); + return setSession(result, email); +} + +export async function signIn(email: string, password: string) { + const result = await mutation("auth:signin", { email, password }); + return setSession(result, email); +} + +export async function forgotPassword(email: string) { + return await mutation("auth:forgotPassword", { email }); +} + +export async function resetPassword(email: string, code: string, password: string) { + const result = await mutation("auth:resetPassword", { email, code, password }); + return setSession(result, email); +} + export function getToken(): string | null { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return null; diff --git a/src/routes/auth/login/+page.svelte b/src/routes/auth/login/+page.svelte index de5330d..5a63d67 100644 --- a/src/routes/auth/login/+page.svelte +++ b/src/routes/auth/login/+page.svelte @@ -1,33 +1,114 @@ @@ -66,23 +144,17 @@

Account

-

Sign in

+

+ {mode === "signin" + ? "Sign in" + : mode === "signup" + ? "Create account" + : "Reset password"} +

Use your Deakin email to contribute.

- {#if step === "email"} + {#if mode === "signin"}
-
- - -
-
-

Must be an @deakin.edu.au address

- {#if error} -

{error}

- {/if} - - - -

- By signing in, you agree that your contributions are public. -

-
- {:else} -
-

- We sent a 6-digit code to {email}. It expires in - 10 minutes. -

-
- + @@ -135,13 +186,209 @@ {/if} - +
+ + +
+ {:else if mode === "signup"} + {#if !sent} +
+
+ + +
+ +
+ + +

Must be an @deakin.edu.au address

+
+ + {#if error} +

{error}

+ {/if} + + + + +
+ {:else} +
+

+ We sent a 6-digit code to {email}. It expires + in 10 minutes. +

+ +
+ + +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + + + + +
+ {/if} + {:else} + {#if !sent} +
+
+ + +

+ We'll email a reset code to your @deakin.edu.au address. +

+
+ + {#if error} +

{error}

+ {/if} + + + + +
+ {:else} +
+

+ We sent a 6-digit code to {email}. It expires + in 10 minutes. +

+ +
+ + +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + + + + +
+ {/if} {/if}