create new auth files
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { MEMBER_SECRET } from '$app/env/private';
|
||||||
|
import { createSuperClient, createPbClient } from '$lib/server/pocketbase';
|
||||||
|
import { famUsername, handle } from '@shared/slugify';
|
||||||
|
|
||||||
|
const OTP_TTL_MS = 20 * 60 * 1000; // 20 minutes
|
||||||
|
|
||||||
|
// A child member's PB password is derived from (secret + famSlug + handle), so
|
||||||
|
// the server can authWithPassword at join time. The user never sees or types it;
|
||||||
|
// OTP is the access gate.
|
||||||
|
export function derivePassword(famSlug: string, handleName: string) {
|
||||||
|
return `${String(MEMBER_SECRET)}${famSlug}${handleName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOtp() {
|
||||||
|
const n = randomBytes(3).readUIntBE(0, 3) % 1_000_000;
|
||||||
|
return n.toString().padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create (or fetch existing) a child users record. The PB username is the
|
||||||
|
// composite `{famSlug}:{handle}` (globally unique auth identity); `name` keeps
|
||||||
|
// the raw display name. The password is derived from (secret + famSlug + handle)
|
||||||
|
// so the server can authWithPassword at join time.
|
||||||
|
export async function createChild(opts: {
|
||||||
|
famId: string;
|
||||||
|
famSlug: string;
|
||||||
|
name: string;
|
||||||
|
colour?: string;
|
||||||
|
}) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const handleName = handle(opts.name);
|
||||||
|
const username = famUsername(opts.famSlug, handleName);
|
||||||
|
const password = derivePassword(opts.famSlug, handleName);
|
||||||
|
|
||||||
|
let user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFirstListItem(`famId='${opts.famId}' && username='${username}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await pb.collection('users').create({
|
||||||
|
username,
|
||||||
|
name: opts.name,
|
||||||
|
color: opts.colour || '#6366f1',
|
||||||
|
password,
|
||||||
|
passwordConfirm: password,
|
||||||
|
famId: opts.famId,
|
||||||
|
role: 'child'
|
||||||
|
});
|
||||||
|
} else if (!user.name || !user.color) {
|
||||||
|
user = await pb.collection('users').update(user.id, {
|
||||||
|
name: user.name || opts.name,
|
||||||
|
color: user.color || opts.colour || '#6366f1'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) throw new Error('Failed to create child');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin grants access to a child: creates the users auth record (or re-issues
|
||||||
|
// OTP if they already exist) + upserts their user_configs. Returns the OTP and
|
||||||
|
// shareable join link (using the whitespace-free handle) for QR display.
|
||||||
|
export async function issueAccess(opts: {
|
||||||
|
famId: string;
|
||||||
|
famSlug: string;
|
||||||
|
name: string;
|
||||||
|
colour?: string;
|
||||||
|
}) {
|
||||||
|
const { famId, famSlug, name, colour } = opts;
|
||||||
|
const username = handle(name);
|
||||||
|
const otp = generateOtp();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const user = await createChild({ famId, famSlug, name, colour });
|
||||||
|
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
let config = await pb
|
||||||
|
.collection('user_configs')
|
||||||
|
.getFirstListItem(`famId='${famId}' && userId='${user.id}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
await pb.collection('user_configs').update(config.id, { otp, colour, updatedAt });
|
||||||
|
} else {
|
||||||
|
await pb.collection('user_configs').create({ famId, userId: user.id, otp, colour, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code,
|
||||||
|
// the 20-minute window, and that the account is a child, then authenticates via
|
||||||
|
// authWithPassword and returns a fresh PB JWT. Throws on any failure.
|
||||||
|
export async function redeemOtp(opts: { famSlug: string; username: string; otp: string }) {
|
||||||
|
const { famSlug, username, otp } = opts;
|
||||||
|
const handleName = handle(username); // normalize whatever was in the URL
|
||||||
|
const fullUsername = famUsername(famSlug, handleName);
|
||||||
|
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
|
||||||
|
const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`);
|
||||||
|
if (!fam) throw new Error('Invalid join link');
|
||||||
|
|
||||||
|
let user = await pb
|
||||||
|
.collection('users')
|
||||||
|
.getFirstListItem(`famId='${fam.id}' && username='${fullUsername}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.role !== 'child') throw new Error('Invalid join link');
|
||||||
|
|
||||||
|
let config = await pb
|
||||||
|
.collection('user_configs')
|
||||||
|
.getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!config || config.otp !== otp) throw new Error('Invalid code');
|
||||||
|
|
||||||
|
const issued = Date.parse(config.updatedAt || '');
|
||||||
|
if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired');
|
||||||
|
|
||||||
|
const authPb = createPbClient();
|
||||||
|
await authPb
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(fullUsername, derivePassword(famSlug, handleName));
|
||||||
|
return authPb.authStore.token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { SERVER_IP } from '$app/env/public';
|
||||||
|
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
|
||||||
|
|
||||||
|
export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`;
|
||||||
|
|
||||||
|
// Server-side PB client, pre-authenticated as the given user's token.
|
||||||
|
// Used for CRUD as the authenticated user so PB collection rules apply
|
||||||
|
// (famId scoping) instead of running everything as superuser.
|
||||||
|
export function createPbClient(token?: string) {
|
||||||
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
|
if (token) pb.authStore.save(token, null);
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated PB client for the current request's admin/member session.
|
||||||
|
// Requires an active session; redirects to /login otherwise.
|
||||||
|
export function pbUser(event: RequestEvent) {
|
||||||
|
if (!event.locals.user || !event.locals.pbToken) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
return createPbClient(event.locals.pbToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser PB client (memoized). Reserved for server-only privileged
|
||||||
|
// operations that must bypass collection rules: creating child users, minting
|
||||||
|
// OTP-login tokens, and verifying OTPs against the superuser-only user_configs.
|
||||||
|
let superClient: PocketBase | null = null;
|
||||||
|
export async function createSuperClient() {
|
||||||
|
if (superClient) return superClient;
|
||||||
|
const pb = new PocketBase(PB_ENDPOINT);
|
||||||
|
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
|
||||||
|
superClient = pb;
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser CRUD facade, built on the memoized SDK superuser client. Replaces
|
||||||
|
// the old raw-fetch `pb-admin.ts`/`ensureToken` path so all server PB access
|
||||||
|
// (authenticated user + superuser) lives in this one module.
|
||||||
|
export const pbAdmin = {
|
||||||
|
async getList(collection: string, filter = '') {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
const options: { filter?: string } = {};
|
||||||
|
if (filter) options.filter = filter;
|
||||||
|
return pb.collection(collection).getFullList(options);
|
||||||
|
},
|
||||||
|
async getOne(collection: string, id: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).getOne(id);
|
||||||
|
},
|
||||||
|
async create(collection: string, data: Record<string, unknown>) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).create(data);
|
||||||
|
},
|
||||||
|
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).update(id, data);
|
||||||
|
},
|
||||||
|
async remove(collection: string, id: string) {
|
||||||
|
const pb = await createSuperClient();
|
||||||
|
return pb.collection(collection).delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// The PocketBase JWT lives in a single cookie shared by:
|
||||||
|
// - the server hooks (authRefresh -> locals.user)
|
||||||
|
// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe)
|
||||||
|
// httpOnly keeps the token out of reach of browser JS/XSS; the client receives
|
||||||
|
// the token server-side via the layout load (pbToken) and seeds pb.authStore.
|
||||||
|
// Secure flag is set in prod so it's only sent over HTTPS.
|
||||||
|
export const SESSION_COOKIE = 'pb_token';
|
||||||
|
const MAX_AGE = 60 * 60 * 24; // 24h, PB token exp is the real ceiling
|
||||||
|
|
||||||
|
export function setSessionCookie(cookies: Cookies, token: string) {
|
||||||
|
cookies.set(SESSION_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: MAX_AGE,
|
||||||
|
secure: import.meta.env.PROD
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionCookie(cookies: Cookies) {
|
||||||
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username?: string;
|
||||||
|
role: 'parent' | 'child';
|
||||||
|
famId: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user