Spaces:
Running
Running
Commit ·
b11153a
1
Parent(s): 3327d9d
fix(ui): shared safe-next validation, no lockout on success, logout origin check, tab/feed polish
Browse files
src/hooks.server.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
import { dev } from '$app/environment';
|
|
|
|
| 2 |
import { SESSION_COOKIE, verifySessionToken } from '$lib/server/auth/session';
|
| 3 |
import { getApp } from '$lib/server/app';
|
| 4 |
import { error, redirect, type Handle } from '@sveltejs/kit';
|
| 5 |
|
| 6 |
const PUBLIC_PATHS = new Set(['/login']);
|
| 7 |
const ALLOWED_WHILE_PASSWORD_CHANGE = new Set(['/me/password', '/logout']);
|
| 8 |
-
const SAFE_NEXT = /^\/([^/\\]|$)/;
|
| 9 |
|
| 10 |
export const handle: Handle = async ({ event, resolve }) => {
|
| 11 |
const app = await getApp();
|
|
@@ -28,7 +28,8 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
| 28 |
|
| 29 |
if (!user) {
|
| 30 |
if (!PUBLIC_PATHS.has(path)) {
|
| 31 |
-
const
|
|
|
|
| 32 |
redirect(303, `/login${next}`);
|
| 33 |
}
|
| 34 |
} else {
|
|
|
|
| 1 |
import { dev } from '$app/environment';
|
| 2 |
+
import { SAFE_NEXT } from '$lib/server/auth/safe-next';
|
| 3 |
import { SESSION_COOKIE, verifySessionToken } from '$lib/server/auth/session';
|
| 4 |
import { getApp } from '$lib/server/app';
|
| 5 |
import { error, redirect, type Handle } from '@sveltejs/kit';
|
| 6 |
|
| 7 |
const PUBLIC_PATHS = new Set(['/login']);
|
| 8 |
const ALLOWED_WHILE_PASSWORD_CHANGE = new Set(['/me/password', '/logout']);
|
|
|
|
| 9 |
|
| 10 |
export const handle: Handle = async ({ event, resolve }) => {
|
| 11 |
const app = await getApp();
|
|
|
|
| 28 |
|
| 29 |
if (!user) {
|
| 30 |
if (!PUBLIC_PATHS.has(path)) {
|
| 31 |
+
const fullPath = event.url.pathname + event.url.search;
|
| 32 |
+
const next = SAFE_NEXT.test(path) ? `?next=${encodeURIComponent(fullPath)}` : '';
|
| 33 |
redirect(303, `/login${next}`);
|
| 34 |
}
|
| 35 |
} else {
|
src/lib/components/TabBar.svelte
CHANGED
|
@@ -12,11 +12,22 @@
|
|
| 12 |
{ href: '/me', label: 'Me', icon: '👤' }
|
| 13 |
]);
|
| 14 |
|
| 15 |
-
const isActive = (href: string) =>
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
</script>
|
| 18 |
|
| 19 |
<nav
|
|
|
|
| 20 |
class="fixed inset-x-0 bottom-0 z-10 border-t border-gray-200 bg-white pb-[env(safe-area-inset-bottom)]"
|
| 21 |
>
|
| 22 |
<ul class="mx-auto flex max-w-lg justify-around">
|
|
|
|
| 12 |
{ href: '/me', label: 'Me', icon: '👤' }
|
| 13 |
]);
|
| 14 |
|
| 15 |
+
const isActive = (href: string) => {
|
| 16 |
+
const path = page.url.pathname;
|
| 17 |
+
if (href === '/') {
|
| 18 |
+
return (
|
| 19 |
+
path === '/' ||
|
| 20 |
+
(path.startsWith('/expenses/') && path !== '/expenses/new') ||
|
| 21 |
+
path.startsWith('/settlements/')
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
if (href === '/expenses/new') return path === '/expenses/new';
|
| 25 |
+
return path.startsWith(href);
|
| 26 |
+
};
|
| 27 |
</script>
|
| 28 |
|
| 29 |
<nav
|
| 30 |
+
aria-label="Primary"
|
| 31 |
class="fixed inset-x-0 bottom-0 z-10 border-t border-gray-200 bg-white pb-[env(safe-area-inset-bottom)]"
|
| 32 |
>
|
| 33 |
<ul class="mx-auto flex max-w-lg justify-around">
|
src/lib/server/auth/rate-limit.ts
CHANGED
|
@@ -34,4 +34,8 @@ export class RateLimiter {
|
|
| 34 |
this.hits.set(key, recent);
|
| 35 |
return true;
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
|
|
|
| 34 |
this.hits.set(key, recent);
|
| 35 |
return true;
|
| 36 |
}
|
| 37 |
+
|
| 38 |
+
reset(key: string): void {
|
| 39 |
+
this.hits.delete(key);
|
| 40 |
+
}
|
| 41 |
}
|
src/lib/server/auth/safe-next.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SAFE_NEXT = /^\/([^/\\]|$)/;
|
| 2 |
+
|
| 3 |
+
export function safeNext(next: string | null, fallback = '/'): string {
|
| 4 |
+
return next !== null && SAFE_NEXT.test(next) ? next : fallback;
|
| 5 |
+
}
|
src/routes/+page.server.ts
CHANGED
|
@@ -22,17 +22,18 @@ export const load: PageServerLoad = ({ locals }) => {
|
|
| 22 |
...ledger.listExpenses().map((e): FeedItem => {
|
| 23 |
const paid = e.paidBy === me.id ? e.amountCents : 0;
|
| 24 |
const share = e.shares[me.id] ?? 0;
|
|
|
|
| 25 |
return {
|
| 26 |
kind: 'expense', id: e.id, date: e.date, createdAt: e.createdAt,
|
| 27 |
title: e.description,
|
| 28 |
-
subtitle: `${name(e.paidBy)} paid · ${
|
| 29 |
amountCents: e.amountCents, myDeltaCents: paid - share, hasPhoto: !!e.photo
|
| 30 |
};
|
| 31 |
}),
|
| 32 |
...ledger.listSettlements().map((s): FeedItem => ({
|
| 33 |
kind: 'settlement', id: s.id, date: s.date, createdAt: s.createdAt,
|
| 34 |
title: `${name(s.from)} paid ${name(s.to)}`,
|
| 35 |
-
subtitle: s.note
|
| 36 |
amountCents: s.amountCents,
|
| 37 |
myDeltaCents: s.from === me.id ? s.amountCents : s.to === me.id ? -s.amountCents : 0,
|
| 38 |
hasPhoto: false
|
|
|
|
| 22 |
...ledger.listExpenses().map((e): FeedItem => {
|
| 23 |
const paid = e.paidBy === me.id ? e.amountCents : 0;
|
| 24 |
const share = e.shares[me.id] ?? 0;
|
| 25 |
+
const peopleCount = Object.keys(e.shares).length;
|
| 26 |
return {
|
| 27 |
kind: 'expense', id: e.id, date: e.date, createdAt: e.createdAt,
|
| 28 |
title: e.description,
|
| 29 |
+
subtitle: `${name(e.paidBy)} paid · ${peopleCount} ${peopleCount === 1 ? 'person' : 'people'}`,
|
| 30 |
amountCents: e.amountCents, myDeltaCents: paid - share, hasPhoto: !!e.photo
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
...ledger.listSettlements().map((s): FeedItem => ({
|
| 34 |
kind: 'settlement', id: s.id, date: s.date, createdAt: s.createdAt,
|
| 35 |
title: `${name(s.from)} paid ${name(s.to)}`,
|
| 36 |
+
subtitle: s.note?.trim() || 'Settlement',
|
| 37 |
amountCents: s.amountCents,
|
| 38 |
myDeltaCents: s.from === me.id ? s.amountCents : s.to === me.id ? -s.amountCents : 0,
|
| 39 |
hasPhoto: false
|
src/routes/login/+page.server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { dev } from '$app/environment';
|
|
|
|
| 2 |
import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
|
| 3 |
import { fail, redirect } from '@sveltejs/kit';
|
| 4 |
import type { Actions } from './$types';
|
|
@@ -16,6 +17,8 @@ export const actions: Actions = {
|
|
| 16 |
const user = await locals.app.users.authenticate(username, password);
|
| 17 |
if (!user) return fail(400, { error: 'Invalid username or password', username });
|
| 18 |
|
|
|
|
|
|
|
| 19 |
const token = createSessionToken(
|
| 20 |
{ uid: user.id, sv: user.sessionVersion },
|
| 21 |
locals.app.config.sessionSecret
|
|
@@ -28,7 +31,6 @@ export const actions: Actions = {
|
|
| 28 |
maxAge: SESSION_TTL_SECONDS
|
| 29 |
});
|
| 30 |
|
| 31 |
-
|
| 32 |
-
redirect(303, next && next.startsWith('/') && !next.startsWith('//') ? next : '/');
|
| 33 |
}
|
| 34 |
};
|
|
|
|
| 1 |
import { dev } from '$app/environment';
|
| 2 |
+
import { safeNext } from '$lib/server/auth/safe-next';
|
| 3 |
import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
|
| 4 |
import { fail, redirect } from '@sveltejs/kit';
|
| 5 |
import type { Actions } from './$types';
|
|
|
|
| 17 |
const user = await locals.app.users.authenticate(username, password);
|
| 18 |
if (!user) return fail(400, { error: 'Invalid username or password', username });
|
| 19 |
|
| 20 |
+
locals.app.loginLimiter.reset(username.toLowerCase());
|
| 21 |
+
|
| 22 |
const token = createSessionToken(
|
| 23 |
{ uid: user.id, sv: user.sessionVersion },
|
| 24 |
locals.app.config.sessionSecret
|
|
|
|
| 31 |
maxAge: SESSION_TTL_SECONDS
|
| 32 |
});
|
| 33 |
|
| 34 |
+
redirect(303, safeNext(url.searchParams.get('next')));
|
|
|
|
| 35 |
}
|
| 36 |
};
|
src/routes/logout/+server.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
import { SESSION_COOKIE } from '$lib/server/auth/session';
|
| 2 |
-
import { redirect, type RequestHandler } from '@sveltejs/kit';
|
| 3 |
|
| 4 |
-
export const POST: RequestHandler = ({ cookies }) => {
|
|
|
|
| 5 |
cookies.delete(SESSION_COOKIE, { path: '/' });
|
| 6 |
redirect(303, '/login');
|
| 7 |
};
|
|
|
|
| 1 |
import { SESSION_COOKIE } from '$lib/server/auth/session';
|
| 2 |
+
import { error, redirect, type RequestHandler } from '@sveltejs/kit';
|
| 3 |
|
| 4 |
+
export const POST: RequestHandler = ({ cookies, request, url }) => {
|
| 5 |
+
if (request.headers.get('origin') !== url.origin) error(403, 'Forbidden');
|
| 6 |
cookies.delete(SESSION_COOKIE, { path: '/' });
|
| 7 |
redirect(303, '/login');
|
| 8 |
};
|
tests/unit/auth/auth.test.ts
CHANGED
|
@@ -66,6 +66,19 @@ describe('RateLimiter', () => {
|
|
| 66 |
expect(rl.allow('bob')).toBe(true);
|
| 67 |
});
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
it('evicts stale keys once the tracked-key cap is exceeded', () => {
|
| 70 |
let now = 0;
|
| 71 |
const rl = new RateLimiter(5, 1_000, () => now);
|
|
|
|
| 66 |
expect(rl.allow('bob')).toBe(true);
|
| 67 |
});
|
| 68 |
|
| 69 |
+
it('reset() clears a key so it is immediately allowed again', () => {
|
| 70 |
+
let now = 0;
|
| 71 |
+
const rl = new RateLimiter(5, 60_000, () => now);
|
| 72 |
+
expect(rl.allow('bob')).toBe(true);
|
| 73 |
+
expect(rl.allow('bob')).toBe(true);
|
| 74 |
+
expect(rl.allow('bob')).toBe(true);
|
| 75 |
+
expect(rl.allow('bob')).toBe(true);
|
| 76 |
+
expect(rl.allow('bob')).toBe(true);
|
| 77 |
+
expect(rl.allow('bob')).toBe(false);
|
| 78 |
+
rl.reset('bob');
|
| 79 |
+
expect(rl.allow('bob')).toBe(true);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
it('evicts stale keys once the tracked-key cap is exceeded', () => {
|
| 83 |
let now = 0;
|
| 84 |
const rl = new RateLimiter(5, 1_000, () => now);
|