feat(auth): generated one-time passwords for new accounts
This commit is contained in:
parent
d1fdc72d29
commit
ed048c5bb3
2 changed files with 70 additions and 0 deletions
34
src/lib/auth/initial-password.test.ts
Normal file
34
src/lib/auth/initial-password.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { ALPHABET, generateInitialPassword } from "@/lib/auth/initial-password";
|
||||
|
||||
describe("generateInitialPassword", () => {
|
||||
it("produces three groups of four, hyphen separated", () => {
|
||||
expect(generateInitialPassword()).toMatch(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("clears PocketBase's eight-character minimum", () => {
|
||||
expect(generateInitialPassword().length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("omits characters that are misread when typed from a screen", () => {
|
||||
// The owner reads this aloud or copies it by hand into an email, so 0/O
|
||||
// and 1/l/I must not both be possible.
|
||||
for (const banned of ["0", "o", "1", "l", "i"]) {
|
||||
expect(ALPHABET).not.toContain(banned);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws only from the declared alphabet", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
for (const char of generateInitialPassword().replace(/-/g, "")) {
|
||||
expect(ALPHABET).toContain(char);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not repeat across calls", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 200; i++) seen.add(generateInitialPassword());
|
||||
expect(seen.size).toBe(200);
|
||||
});
|
||||
});
|
||||
36
src/lib/auth/initial-password.ts
Normal file
36
src/lib/auth/initial-password.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* A one-time password for a newly issued client-manager account.
|
||||
*
|
||||
* Generated rather than chosen, because the alternative is the owner inventing
|
||||
* one password and reusing it for every client they onboard.
|
||||
*
|
||||
* Uses the Web Crypto API, not Math.random. Math.random is seeded from
|
||||
* predictable state and is not a credential source; the two are one character
|
||||
* apart to write and worlds apart in consequence.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deliberately excludes 0/o, 1/l/i and u/v — the pairs that get misread when
|
||||
* the owner copies this into an email or reads it over the phone.
|
||||
*/
|
||||
export const ALPHABET = "23456789abcdefghjkmnpqrstwxyz";
|
||||
|
||||
const GROUPS = 3;
|
||||
const GROUP_LENGTH = 4;
|
||||
|
||||
export function generateInitialPassword(): string {
|
||||
const bytes = new Uint32Array(GROUPS * GROUP_LENGTH);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
const chars = Array.from(
|
||||
bytes,
|
||||
(value) => ALPHABET[value % ALPHABET.length]
|
||||
);
|
||||
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < GROUPS; i++) {
|
||||
groups.push(chars.slice(i * GROUP_LENGTH, (i + 1) * GROUP_LENGTH).join(""));
|
||||
}
|
||||
|
||||
return groups.join("-");
|
||||
}
|
||||
Loading…
Reference in a new issue