benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
194 lines · 9.0 KB · ~2.6k tok Raw History
1
/**
2
 * The app's own accounts: an email and a password, for people who have no
3
 * forge account. Whoever signs in this way is a `user` — exactly what the
4
 * forge would have signed for a person allowed to see the app — with an id
5
 * of `acct:<n>`, which no forge id can be, so the contract treats both kinds
6
 * of person the same and their vaults never meet. Admin is only ever the
7
 * forge's word.
8
 *
9
 * Passwords are hashed with argon2id (`Bun.password`). A session is a random
10
 * token in an HttpOnly cookie; only its SHA-256 is stored, so a copy of the
11
 * database opens nobody's vault. Signing in replaces nothing — each device
12
 * has its own session — and signing out ends that one.
13
 *
14
 * If `NOTES_SIGNUP_CODE` is set (Settings → App → Environment), creating an
15
 * account needs it: the switch for closing sign-up without a deploy.
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
/** The person an account signs in as. */
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
/** Whether `who` signed in with the app's own account (and not the forge). */
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
// Spent on a sign-in for an email nobody has, so it takes as long as a real one.
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
/** A fresh session for an account: the token for the cookie, and only its hash kept. */
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
  // Expired sessions go as new ones come.
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
/** The person a session cookie belongs to, or anonymous. */
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
/** A new password, given the current one. Every other session ends; this device gets a fresh one. */
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
/** The account, its sessions and every note in its vault — gone, given the password. */
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 space_member WHERE who_id = ?1").run(`acct:${id}`);
189
    db.query("DELETE FROM account WHERE id = ?1").run(id);
190
    return gone;
191
  })();
192
  record(db, { who, resource: "account", verb: "delete", target: id, outcome: "ok" });
193
  return ok({ notes });
194
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts