| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
import type { Database } from "bun:sqlite"; |
| 19 |
import { createHash, randomBytes } from "node:crypto"; |
| 20 |
import { record } from "./audit.ts"; |
| 21 |
import type { Result } from "./actions.ts"; |
| 22 |
import type { Who } from "./who.ts"; |
| 23 |
|
| 24 |
export const SESSION_DAYS = 30; |
| 25 |
export const PASSWORD_MIN = 8; |
| 26 |
export const PASSWORD_MAX = 200; |
| 27 |
const NAME_MAX = 60; |
| 28 |
|
| 29 |
export interface Account { |
| 30 |
readonly id: number; |
| 31 |
readonly email: string; |
| 32 |
readonly name: string; |
| 33 |
readonly created_at: string; |
| 34 |
} |
| 35 |
|
| 36 |
const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 37 |
const fail = <T>(error: "invalid" | "forbidden" | "signin_required", message: string): Result<T> => ({ |
| 38 |
ok: false, |
| 39 |
error, |
| 40 |
message, |
| 41 |
}); |
| 42 |
|
| 43 |
|
| 44 |
export const whoOf = (a: Pick<Account, "id" | "name">): Who & { role: "user" } => ({ |
| 45 |
role: "user", |
| 46 |
id: `acct:${a.id}`, |
| 47 |
name: a.name, |
| 48 |
}); |
| 49 |
|
| 50 |
|
| 51 |
export const isAccount = (who: Who): who is Who & { role: "user" } => |
| 52 |
who.role !== "anon" && who.id.startsWith("acct:"); |
| 53 |
|
| 54 |
const digest = (token: string): string => createHash("sha256").update(token).digest("hex"); |
| 55 |
|
| 56 |
const cleanEmail = (raw: string): string => raw.trim().toLowerCase(); |
| 57 |
const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,63}$/; |
| 58 |
|
| 59 |
|
| 60 |
let decoy: string | undefined; |
| 61 |
const decoyHash = async (): Promise<string> => (decoy ??= await Bun.password.hash("not a password at all")); |
| 62 |
|
| 63 |
function checkPassword(password: string): Result<string> { |
| 64 |
if (password.length < PASSWORD_MIN) return fail("invalid", `A password is at least ${PASSWORD_MIN} characters.`); |
| 65 |
if (password.length > PASSWORD_MAX) return fail("invalid", `A password is at most ${PASSWORD_MAX} characters.`); |
| 66 |
return ok(password); |
| 67 |
} |
| 68 |
|
| 69 |
|
| 70 |
function startSession(db: Database, accountId: number): string { |
| 71 |
const token = randomBytes(32).toString("base64url"); |
| 72 |
db.query( |
| 73 |
`INSERT INTO session (token_hash, account_id, expires_at) |
| 74 |
VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+${SESSION_DAYS} days'))`, |
| 75 |
).run(digest(token), accountId); |
| 76 |
|
| 77 |
db.query("DELETE FROM session WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')").run(); |
| 78 |
return token; |
| 79 |
} |
| 80 |
|
| 81 |
export async function signUp( |
| 82 |
db: Database, |
| 83 |
input: { email: string; password: string; name?: string; code?: string }, |
| 84 |
signupCode = process.env.NOTES_SIGNUP_CODE ?? "", |
| 85 |
): Promise<Result<{ account: Account; token: string }>> { |
| 86 |
const anon: Who = { role: "anon" }; |
| 87 |
if (signupCode !== "" && (input.code ?? "").trim() !== signupCode) { |
| 88 |
record(db, { who: anon, resource: "account", verb: "create", outcome: "forbidden" }); |
| 89 |
return fail("forbidden", "That sign-up code isn't right."); |
| 90 |
} |
| 91 |
const email = cleanEmail(input.email); |
| 92 |
if (!EMAIL.test(email)) return fail("invalid", "That doesn't look like an email address."); |
| 93 |
const password = checkPassword(input.password); |
| 94 |
if (!password.ok) return password; |
| 95 |
const name = (input.name ?? "").replace(/\s+/g, " ").trim().slice(0, NAME_MAX) || email.split("@")[0]!; |
| 96 |
const taken = db.query<{ id: number }, [string]>("SELECT id FROM account WHERE email = ?1").get(email); |
| 97 |
if (taken !== null) return fail("invalid", "There's already an account with that email. Log in instead."); |
| 98 |
const hash = await Bun.password.hash(password.value); |
| 99 |
const account = db |
| 100 |
.query<Account, [string, string, string]>( |
| 101 |
"INSERT INTO account (email, name, password_hash) VALUES (?1, ?2, ?3) RETURNING id, email, name, created_at", |
| 102 |
) |
| 103 |
.get(email, name, hash)!; |
| 104 |
record(db, { who: whoOf(account), resource: "account", verb: "create", target: account.id, outcome: "ok" }); |
| 105 |
return ok({ account, token: startSession(db, account.id) }); |
| 106 |
} |
| 107 |
|
| 108 |
export async function logIn( |
| 109 |
db: Database, |
| 110 |
input: { email: string; password: string }, |
| 111 |
): Promise<Result<{ account: Account; token: string }>> { |
| 112 |
const email = cleanEmail(input.email); |
| 113 |
const row = db |
| 114 |
.query<Account & { password_hash: string }, [string]>("SELECT * FROM account WHERE email = ?1") |
| 115 |
.get(email); |
| 116 |
const good = await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row?.password_hash ?? (await decoyHash())); |
| 117 |
if (row === null || !good) { |
| 118 |
record(db, { who: { role: "anon" }, resource: "session", verb: "create", outcome: "forbidden" }); |
| 119 |
return fail("forbidden", "That email and password don't match an account."); |
| 120 |
} |
| 121 |
const { password_hash: _, ...account } = row; |
| 122 |
record(db, { who: whoOf(account), resource: "session", verb: "create", target: account.id, outcome: "ok" }); |
| 123 |
return ok({ account, token: startSession(db, account.id) }); |
| 124 |
} |
| 125 |
|
| 126 |
|
| 127 |
export function sessionWho(db: Database, token: string | undefined): Who { |
| 128 |
if (token === undefined || token.length < 20 || token.length > 100) return { role: "anon" }; |
| 129 |
const row = db |
| 130 |
.query<{ id: number; name: string }, [string]>( |
| 131 |
`SELECT a.id, a.name FROM session s JOIN account a ON a.id = s.account_id |
| 132 |
WHERE s.token_hash = ?1 AND s.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, |
| 133 |
) |
| 134 |
.get(digest(token)); |
| 135 |
return row === null ? { role: "anon" } : whoOf(row); |
| 136 |
} |
| 137 |
|
| 138 |
export function logOut(db: Database, token: string | undefined): void { |
| 139 |
if (token !== undefined) db.query("DELETE FROM session WHERE token_hash = ?1").run(digest(token)); |
| 140 |
} |
| 141 |
|
| 142 |
const accountIdOf = (who: Who): number | undefined => |
| 143 |
isAccount(who) ? Number(who.id.slice("acct:".length)) : undefined; |
| 144 |
|
| 145 |
export function getAccount(db: Database, who: Who): Account | undefined { |
| 146 |
const id = accountIdOf(who); |
| 147 |
if (id === undefined) return undefined; |
| 148 |
return db.query<Account, [number]>("SELECT id, email, name, created_at FROM account WHERE id = ?1").get(id) ?? undefined; |
| 149 |
} |
| 150 |
|
| 151 |
|
| 152 |
export async function changePassword( |
| 153 |
db: Database, |
| 154 |
who: Who, |
| 155 |
input: { current: string; next: string }, |
| 156 |
): Promise<Result<{ token: string }>> { |
| 157 |
const id = accountIdOf(who); |
| 158 |
if (id === undefined) return fail("signin_required", "Log in with an email account to change its password."); |
| 159 |
const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id); |
| 160 |
if (row === null || !(await Bun.password.verify(input.current.slice(0, PASSWORD_MAX), row.password_hash))) { |
| 161 |
record(db, { who, resource: "account", verb: "update", target: id, outcome: "forbidden" }); |
| 162 |
return fail("forbidden", "Your current password isn't right."); |
| 163 |
} |
| 164 |
const next = checkPassword(input.next); |
| 165 |
if (!next.ok) return next; |
| 166 |
db.query("UPDATE account SET password_hash = ?1 WHERE id = ?2").run(await Bun.password.hash(next.value), id); |
| 167 |
db.query("DELETE FROM session WHERE account_id = ?1").run(id); |
| 168 |
record(db, { who, resource: "account", verb: "update", target: id, outcome: "ok" }); |
| 169 |
return ok({ token: startSession(db, id) }); |
| 170 |
} |
| 171 |
|
| 172 |
|
| 173 |
export async function deleteAccount( |
| 174 |
db: Database, |
| 175 |
who: Who, |
| 176 |
input: { password: string }, |
| 177 |
): Promise<Result<{ notes: number }>> { |
| 178 |
const id = accountIdOf(who); |
| 179 |
if (id === undefined) return fail("signin_required", "Log in with an email account to delete it."); |
| 180 |
const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id); |
| 181 |
if (row === null || !(await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row.password_hash))) { |
| 182 |
record(db, { who, resource: "account", verb: "delete", target: id, outcome: "forbidden" }); |
| 183 |
return fail("forbidden", "That password isn't right."); |
| 184 |
} |
| 185 |
const notes = db.transaction(() => { |
| 186 |
const gone = db.query("DELETE FROM note WHERE author_id = ?1").run(`acct:${id}`).changes; |
| 187 |
db.query("DELETE FROM session WHERE account_id = ?1").run(id); |
| 188 |
db.query("DELETE FROM account WHERE id = ?1").run(id); |
| 189 |
return gone; |
| 190 |
})(); |
| 191 |
record(db, { who, resource: "account", verb: "delete", target: id, outcome: "ok" }); |
| 192 |
return ok({ notes }); |
| 193 |
} |