Loading…
My Bookings
+Your sessions with Lisi Lou Photography
+diff --git a/api/.env.example b/api/.env.example index c4dc187..6df5aca 100644 --- a/api/.env.example +++ b/api/.env.example @@ -19,8 +19,12 @@ CONTRACT_SIG_Y=700 CONTRACT_SIG_PAGE=1 # OIDC / Authentik (issue #10) +# Issuer is the Authentik provider URL, e.g. https://auth.jerodrigged.com/application/o/lisilou/ +# Redirect URI must be registered on the provider, e.g. https://lisilou.jerodrigged.com/api/auth/callback SESSION_SECRET= OIDC_ISSUER= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_REDIRECT_URI= +# Authentik group whose members get admin access (default: lisilou-admin) +OIDC_ADMIN_GROUP=lisilou-admin diff --git a/api/auth.js b/api/auth.js new file mode 100644 index 0000000..44381e4 --- /dev/null +++ b/api/auth.js @@ -0,0 +1,238 @@ +/** + * Authentik OIDC authentication (issue #10). + * + * Authorization-code flow with PKCE for a confidential client, implemented with + * Node.js built-ins only (fetch + crypto) — same zero-dependency approach as + * google-calendar.js. Sessions are stateless HMAC-signed cookies. + * + * Env vars (all required for OIDC to activate; otherwise routes return 503 and + * the legacy ADMIN_SECRET bearer check in server.js keeps working): + * OIDC_ISSUER — e.g. "https://auth.jerodrigged.com/application/o/lisilou/" + * OIDC_CLIENT_ID + * OIDC_CLIENT_SECRET + * OIDC_REDIRECT_URI — e.g. "https://lisilou.jerodrigged.com/api/auth/callback" + * SESSION_SECRET — HMAC key for session cookies (any long random string) + * OIDC_ADMIN_GROUP — optional, Authentik group that grants admin (default "lisilou-admin") + * + * Authentik setup (one-time, in the Authentik admin UI): + * 1. Create an OAuth2/OpenID Provider (confidential client, redirect URI above). + * 2. Create an Application "LisiLou Portfolio" bound to that provider. + * 3. Create a group (default name "lisilou-admin") and add the photographer. + * 4. Copy client ID/secret into api/.env on the server. + */ + +'use strict'; +const crypto = require('crypto'); +const express = require('express'); + +const SESSION_COOKIE = 'lisilou_sess'; +const TXN_COOKIE = 'lisilou_oidc_txn'; +const SESSION_TTL_S = 8 * 60 * 60; // 8 hours +const TXN_TTL_S = 10 * 60; // 10 minutes to complete the login round-trip + +let _discoveryCache = null; // { config, fetchedAt } + +function configured() { + return Boolean( + process.env.OIDC_ISSUER && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_REDIRECT_URI && + process.env.SESSION_SECRET + ); +} + +// ── Cookie signing ──────────────────────────────────────────────────────────── + +function b64url(buf) { + return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function b64urlDecode(str) { + return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); +} + +function sign(payloadObj) { + const payload = b64url(JSON.stringify(payloadObj)); + const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest(); + return `${payload}.${b64url(mac)}`; +} + +function verify(token) { + if (!token || !process.env.SESSION_SECRET) return null; + const dot = token.lastIndexOf('.'); + if (dot < 1) return null; + const payload = token.slice(0, dot); + const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest(); + const expected = b64url(mac); + const given = token.slice(dot + 1); + if (expected.length !== given.length || + !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) return null; + try { + const obj = JSON.parse(b64urlDecode(payload)); + if (!obj.exp || obj.exp < Math.floor(Date.now() / 1000)) return null; + return obj; + } catch { + return null; + } +} + +function parseCookies(req) { + const out = {}; + const header = req.headers.cookie; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq > 0) out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim()); + } + return out; +} + +function isSecureDeployment() { + return (process.env.OIDC_REDIRECT_URI || '').startsWith('https://'); +} + +function cookieAttrs(maxAgeS) { + const secure = isSecureDeployment() ? '; Secure' : ''; + return `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeS}${secure}`; +} + +function setCookie(res, name, value, maxAgeS) { + const prev = res.getHeader('Set-Cookie'); + const cookie = `${name}=${encodeURIComponent(value)}${cookieAttrs(maxAgeS)}`; + res.setHeader('Set-Cookie', prev ? [].concat(prev, cookie) : cookie); +} + +function clearCookie(res, name) { + setCookie(res, name, '', 0); +} + +// ── Session access (used by server.js middleware) ───────────────────────────── + +function getSession(req) { + return verify(parseCookies(req)[SESSION_COOKIE]); +} + +// ── OIDC provider discovery ─────────────────────────────────────────────────── + +async function discover() { + if (_discoveryCache && Date.now() - _discoveryCache.fetchedAt < 60 * 60 * 1000) { + return _discoveryCache.config; + } + const issuer = process.env.OIDC_ISSUER.replace(/\/$/, ''); + const res = await fetch(`${issuer}/.well-known/openid-configuration`); + if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`); + const config = await res.json(); + _discoveryCache = { config, fetchedAt: Date.now() }; + return config; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +const router = express.Router(); + +// Begin login. ?redirect=/dashboard controls where the user lands afterwards. +router.get('/api/auth/login', async (req, res) => { + if (!configured()) return res.status(503).json({ error: 'SSO not configured' }); + try { + const config = await discover(); + const state = b64url(crypto.randomBytes(24)); + const verifier = b64url(crypto.randomBytes(48)); + const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); + // Only allow same-site relative redirect targets + const redirect = (req.query.redirect || '/').startsWith('/') && !String(req.query.redirect || '/').startsWith('//') + ? (req.query.redirect || '/') : '/'; + + setCookie(res, TXN_COOKIE, sign({ + state, verifier, redirect, + exp: Math.floor(Date.now() / 1000) + TXN_TTL_S, + }), TXN_TTL_S); + + const url = new URL(config.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID); + url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', challenge); + url.searchParams.set('code_challenge_method', 'S256'); + res.redirect(url.toString()); + } catch (err) { + console.error('[auth] login failed:', err.message); + res.status(502).json({ error: 'SSO provider unavailable' }); + } +}); + +router.get('/api/auth/callback', async (req, res) => { + if (!configured()) return res.status(503).json({ error: 'SSO not configured' }); + const txn = verify(parseCookies(req)[TXN_COOKIE]); + clearCookie(res, TXN_COOKIE); + if (!txn || !req.query.code || req.query.state !== txn.state) { + return res.status(400).send('Login session expired or invalid. Try again.'); + } + try { + const config = await discover(); + const tokenRes = await fetch(config.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: req.query.code, + redirect_uri: process.env.OIDC_REDIRECT_URI, + client_id: process.env.OIDC_CLIENT_ID, + client_secret: process.env.OIDC_CLIENT_SECRET, + code_verifier: txn.verifier, + }), + }); + if (!tokenRes.ok) { + const body = await tokenRes.text(); + throw new Error(`token exchange failed: ${tokenRes.status} ${body.slice(0, 200)}`); + } + const tokens = await tokenRes.json(); + + // Claims come from the userinfo endpoint over TLS directly from the issuer, + // so a local JWT signature check is not required for this trust model. + const uiRes = await fetch(config.userinfo_endpoint, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + if (!uiRes.ok) throw new Error(`userinfo failed: ${uiRes.status}`); + const claims = await uiRes.json(); + + const adminGroup = process.env.OIDC_ADMIN_GROUP || 'lisilou-admin'; + const groups = Array.isArray(claims.groups) ? claims.groups : []; + + setCookie(res, SESSION_COOKIE, sign({ + sub: claims.sub, + email: claims.email || null, + name: claims.name || claims.preferred_username || null, + admin: groups.includes(adminGroup), + exp: Math.floor(Date.now() / 1000) + SESSION_TTL_S, + }), SESSION_TTL_S); + + res.redirect(txn.redirect || '/'); + } catch (err) { + console.error('[auth] callback failed:', err.message); + res.status(502).send('Login failed. Try again.'); + } +}); + +router.post('/api/auth/logout', (req, res) => { + clearCookie(res, SESSION_COOKIE); + res.json({ ok: true }); +}); + +// Session probe for the frontend +router.get('/api/auth/me', (req, res) => { + const session = getSession(req); + if (!session) return res.json({ authenticated: false, ssoConfigured: configured() }); + res.json({ + authenticated: true, + ssoConfigured: true, + sub: session.sub, + email: session.email, + name: session.name, + admin: Boolean(session.admin), + }); +}); + +module.exports = { router, getSession, configured }; diff --git a/api/server.js b/api/server.js index 4a21c54..9eff8ff 100644 --- a/api/server.js +++ b/api/server.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const db = require('./db'); const { getBusyDates } = require('./google-calendar'); +const auth = require('./auth'); const app = express(); const PORT = process.env.PORT || 3001; @@ -32,14 +33,33 @@ function notify(event, booking, extra = {}) { }).catch(e => console.error(`[notify] ${event} failed:`, e.message)); } -// ── Admin auth ──────────────────────────────────────────────────────────────── -// TODO (#10): swap this bearer-token check for Authentik OIDC session validation -// when issue #10 lands. The middleware signature stays the same; only the check changes. +// ── Auth (issue #10) ────────────────────────────────────────────────────────── +// Admin access is granted by either: +// 1. an Authentik OIDC session whose user is in the admin group (see auth.js), or +// 2. the legacy ADMIN_SECRET bearer token — kept for n8n workflows and tests. +app.use(auth.router); + function requireAdmin(req, res, next) { + const session = auth.getSession(req); + if (session && session.admin) { + req.session = session; + return next(); + } const secret = process.env.ADMIN_SECRET; - if (!secret) return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET)' }); - const auth = req.headers.authorization || ''; - if (auth !== `Bearer ${secret}`) return res.status(401).json({ error: 'Unauthorized' }); + const header = req.headers.authorization || ''; + if (secret && header === `Bearer ${secret}`) return next(); + if (session) return res.status(403).json({ error: 'Admin access required' }); + if (!secret && !auth.configured()) { + return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET or OIDC_* vars)' }); + } + return res.status(401).json({ error: 'Unauthorized' }); +} + +// Any signed-in user (client portal) +function requireUser(req, res, next) { + const session = auth.getSession(req); + if (!session) return res.status(401).json({ error: 'Sign in required' }); + req.session = session; next(); } @@ -106,6 +126,11 @@ app.post('/api/bookings', (req, res) => { return res.status(400).json({ error: 'Missing required fields' }); } + // If the client is signed in, bind the booking to their OIDC identity so it + // shows up in the /my-bookings portal regardless of what email they typed. + const session = auth.getSession(req); + const sub = session ? session.sub : client_sub; + const ps = payment_status || 'pending'; // Record when the client indicated they sent payment const paymentNotifiedAt = ps === 'pending_confirmation' ? new Date().toISOString() : null; @@ -116,7 +141,7 @@ app.post('/api/bookings', (req, res) => { session_length, location, payment_status, payment_notified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - client_name, client_email, client_phone, client_sub, + client_name, client_email, client_phone, sub, session_date, session_type, session_length, location, ps, paymentNotifiedAt, ); @@ -210,7 +235,40 @@ app.post('/api/bookings/:id/sign', async (req, res) => { res.json({ ok: true, signed_at: now, pdf_path: pdfPath }); }); -// ── Admin routes (require ADMIN_SECRET bearer token) ────────────────────────── +// ── Client portal (issue #13) ───────────────────────────────────────────────── + +// Bookings belonging to the signed-in client, matched by OIDC subject or email. +app.get('/api/my-bookings', requireUser, (req, res) => { + const { sub, email } = req.session; + const bookings = db.prepare(` + SELECT id, created_at, session_date, session_type, session_length, location, + contract_signed_at, payment_status, status + FROM bookings + WHERE (client_sub = ? AND client_sub IS NOT NULL) + OR (client_email = ? AND client_email IS NOT NULL) + ORDER BY session_date DESC + `).all(sub, email || ''); + res.json(bookings); +}); + +// Signed contract download for the booking's owner (admin route also exists) +app.get('/api/my-bookings/:id/contract', requireUser, (req, res) => { + const id = parseInt(req.params.id, 10); + const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); + if (!booking) return res.status(404).json({ error: 'Not found' }); + const { sub, email } = req.session; + const owns = (booking.client_sub && booking.client_sub === sub) || + (booking.client_email && email && booking.client_email === email); + if (!owns) return res.status(403).json({ error: 'Not your booking' }); + if (!booking.contract_pdf_path) return res.status(404).json({ error: 'No signed contract' }); + const pdfPath = path.join(__dirname, booking.contract_pdf_path); + if (!fs.existsSync(pdfPath)) return res.status(404).json({ error: 'Contract file missing on disk' }); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="contract-booking-${id}.pdf"`); + res.sendFile(pdfPath); +}); + +// ── Admin routes (OIDC admin session or ADMIN_SECRET bearer token) ──────────── // Stats: counts + next upcoming bookings for the Overview panel app.get('/api/admin/stats', requireAdmin, (req, res) => { diff --git a/nginx.conf b/nginx.conf index 979e85b..909e794 100644 --- a/nginx.conf +++ b/nginx.conf @@ -65,6 +65,11 @@ http { try_files $uri /dashboard.html; } + # Client portal (issue #13) + location /my-bookings { + try_files $uri /my-bookings.html; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/src/dashboard.html b/src/dashboard.html index b213770..b174904 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -47,6 +47,12 @@ body { font-family: 'Nunito Sans', sans-serif; color: var(--text); background: v } .login-card input:focus { border-color: var(--primary); } .login-error { color: var(--red); font-size: .8rem; margin-bottom: .75rem; display: none; } +.login-divider { + display: flex; align-items: center; gap: .75rem; + color: var(--text-muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .1em; + margin-bottom: .75rem; +} +.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } /* ── App shell ─────────────────────────────────────── */ #app { display: none; flex-direction: column; min-height: 100vh; } @@ -230,8 +236,10 @@ tr.expanded td { background: var(--bg); }
Admin Dashboard
Incorrect passphrase.
+ + - + @@ -340,6 +348,7 @@ tr.expanded td { background: var(--bg); } diff --git a/src/my-bookings.html b/src/my-bookings.html new file mode 100644 index 0000000..8b2fa51 --- /dev/null +++ b/src/my-bookings.html @@ -0,0 +1,201 @@ + + + + + +Your sessions with Lisi Lou Photography
+Loading…