← back to Rentv
RENTV Path A: additive session-cookie login + CSRF + service-token, v0.21.0 — TK-10257
b6a773ffe7e76110ced9874396c42b40c2ac1c88 · 2026-08-05 15:03:29 -0700 · Steve Abrams
- session store (in-mem, 256-bit opaque cookie id), /login page + /api/login|logout|whoami (pre-gate)
- gate now: session cookie → Bearer service-token → Basic (nginx/loopback, UNCHANGED) → clean /login redirect for unauth HTML, 401 for API
- CSRF (double-submit X-CSRF-Token) enforced ONLY on session-auth mutations; Basic/service/open immune (loopback-safe); csrf.js fetch-patch injected in-head via sendPage
- guardrails: SameSite=Strict, HttpOnly+Secure(behind https), session-id regen on login, idle 8h + abs 30d expiry
- ADDITIVE: ships behind the still-up nginx wall with zero breakage; dropping the wall (Steve-gated Phase 2) activates the clean login as front door
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M package-lock.jsonM package.jsonA public/login.htmlM server.js
Diff
commit b6a773ffe7e76110ced9874396c42b40c2ac1c88
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 15:03:29 2026 -0700
RENTV Path A: additive session-cookie login + CSRF + service-token, v0.21.0 — TK-10257
- session store (in-mem, 256-bit opaque cookie id), /login page + /api/login|logout|whoami (pre-gate)
- gate now: session cookie → Bearer service-token → Basic (nginx/loopback, UNCHANGED) → clean /login redirect for unauth HTML, 401 for API
- CSRF (double-submit X-CSRF-Token) enforced ONLY on session-auth mutations; Basic/service/open immune (loopback-safe); csrf.js fetch-patch injected in-head via sendPage
- guardrails: SameSite=Strict, HttpOnly+Secure(behind https), session-id regen on login, idle 8h + abs 30d expiry
- ADDITIVE: ships behind the still-up nginx wall with zero breakage; dropping the wall (Steve-gated Phase 2) activates the clean login as front door
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
package-lock.json | 4 +--
package.json | 2 +-
public/login.html | 69 +++++++++++++++++++++++++++++++++++++++++++
server.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 156 insertions(+), 7 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 70719ec0..1eb90f46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rentv",
- "version": "0.20.1",
+ "version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rentv",
- "version": "0.20.1",
+ "version": "0.21.0",
"dependencies": {
"express": "^4.21.2",
"pg": "^8.22.0"
diff --git a/package.json b/package.json
index 4410cd46..02ad87a3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rentv",
- "version": "0.20.1",
+ "version": "0.21.0",
"private": true,
"main": "server.js",
"scripts": {
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 00000000..759cbadb
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,69 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Sign in — RENTV.com</title>
+<meta name="robots" content="noindex,nofollow">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:var(--sans);color:var(--ink);background:var(--wash);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
+ .card{background:#fff;border:1px solid var(--line);border-radius:16px;box-shadow:0 24px 60px rgba(20,23,26,.08);width:100%;max-width:400px;padding:36px 34px}
+ .logo{font-family:var(--serif);font-weight:700;font-size:30px;letter-spacing:-1px;text-align:center}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:10px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:3px;font-family:var(--sans)}
+ h1{font-family:var(--serif);font-size:22px;text-align:center;margin:22px 0 4px}
+ p.sub{text-align:center;color:var(--sub);font-size:13px;margin-bottom:22px}
+ label{display:block;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);margin:14px 0 5px}
+ input{width:100%;font-family:var(--sans);font-size:15px;padding:11px 13px;border:1px solid var(--line);border-radius:9px;background:#fff}
+ input:focus{outline:none;border-color:var(--ink)}
+ button{width:100%;margin-top:22px;font-family:var(--sans);font-size:15px;font-weight:700;padding:12px;border:none;border-radius:9px;background:var(--red);color:#fff;cursor:pointer}
+ button:disabled{opacity:.6;cursor:default}
+ .msg{margin-top:14px;font-size:13px;text-align:center;min-height:18px}
+ .msg.err{color:var(--red)}
+ .foot{margin-top:20px;text-align:center;font-size:11px;color:var(--sub)}
+</style>
+</head>
+<body>
+ <div class="card">
+ <div class="logo">REN<span>TV</span>.com<small>Commercial Real Estate News</small></div>
+ <h1>Sign in</h1>
+ <p class="sub">Enter your credentials to access the site.</p>
+ <form id="f" autocomplete="on">
+ <label for="u">Username</label>
+ <input id="u" name="username" autocomplete="username" autofocus required>
+ <label for="p">Password</label>
+ <input id="p" name="password" type="password" autocomplete="current-password" required>
+ <button id="btn" type="submit">Sign in</button>
+ <div class="msg" id="msg"></div>
+ </form>
+ <div class="foot">© RENTV.com — Commercial Real Estate News</div>
+ </div>
+<script>
+const $=s=>document.querySelector(s);
+const nextParam=new URLSearchParams(location.search).get('next')||'';
+function safeNext(role){
+ // only allow same-origin relative paths; default by role
+ if(nextParam && nextParam.startsWith('/') && !nextParam.startsWith('//')) return nextParam;
+ return role==='admin' ? '/backend' : '/';
+}
+$('#f').addEventListener('submit', async (e)=>{
+ e.preventDefault();
+ const btn=$('#btn'), msg=$('#msg'); msg.className='msg'; msg.textContent='';
+ btn.disabled=true; btn.textContent='Signing in…';
+ try{
+ const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({username:$('#u').value,password:$('#p').value})});
+ const j=await r.json();
+ if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
+ msg.textContent='Welcome — redirecting…';
+ location.href=safeNext(j.role);
+ }catch(err){
+ msg.className='msg err'; msg.textContent=err.message||'Sign-in failed';
+ btn.disabled=false; btn.textContent='Sign in';
+ }
+});
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 998ee5cf..980d5574 100644
--- a/server.js
+++ b/server.js
@@ -45,14 +45,88 @@ for (const [role, list] of Object.entries(ROLE_CREDS)) {
for (const c of list) CRED_ROLE.set('Basic ' + Buffer.from(c).toString('base64'), role);
}
app.get('/api/health', (_q, r) => r.json({ ok: true, at: new Date().toISOString() }));
-// Global authentication gate (any valid login passes) + role resolution onto req.role.
+// ── SESSION AUTH (Path A — TK-10257, DTD verdict A 2026-08-05). ADDITIVE over Basic-Auth:
+// Basic still works (the nginx wall's forwarded creds + every server-side/loopback caller),
+// so this ships behind the still-up nginx wall with zero breakage; dropping that wall
+// (Steve-gated) then makes the clean login page the front door. In-memory store — a single
+// admin re-logging-in after a deploy is acceptable; the cookie is a 256-bit opaque id looked
+// up server-side (unforgeable, no signing needed). Guardrails: SameSite=Strict + per-session
+// CSRF token on mutating routes, session-id regenerated on login, HttpOnly+Secure, idle+abs
+// expiry, plus a dedicated Bearer service-token path for loopback callers. ──
+const crypto = require('node:crypto');
+const SESS = new Map(); // id -> { role, user, created, seen, csrf }
+const SESS_IDLE_MS = 8 * 3600e3; // 8h idle timeout
+const SESS_ABS_MS = 30 * 24 * 3600e3; // 30d absolute lifetime
+const SERVICE_TOKEN = process.env.RENTV_SERVICE_TOKEN || ''; // optional dedicated loopback/service creds
+const newId = () => crypto.randomBytes(32).toString('hex');
+function parseCookies(req) {
+ const out = {}; for (const p of String(req.headers.cookie || '').split(';')) {
+ const i = p.indexOf('='); if (i < 0) continue; out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
+ } return out;
+}
+function sessionOf(req) {
+ const id = parseCookies(req).rentv_sess; if (!id) return null;
+ const s = SESS.get(id); if (!s) return null;
+ const now = Date.now();
+ if (now - s.seen > SESS_IDLE_MS || now - s.created > SESS_ABS_MS) { SESS.delete(id); return null; }
+ s.seen = now; s._id = id; return s;
+}
+setInterval(() => { const now = Date.now(); for (const [id, s] of SESS) if (now - s.seen > SESS_IDLE_MS || now - s.created > SESS_ABS_MS) SESS.delete(id); }, 3600e3).unref?.();
+const cookieFlags = (req) => `; HttpOnly; SameSite=Strict; Path=/${(req.headers['x-forwarded-proto'] === 'https' || req.secure) ? '; Secure' : ''}`;
+
+// Login page + endpoints — registered BEFORE the gate so they're reachable unauthenticated.
+app.get('/login', (_q, r) => sendPage(r, path.join(PUB, 'login.html')));
+app.get('/csrf.js', (_q, r) => {
+ r.type('application/javascript').set('Cache-Control', 'no-store').send(
+ "(function(){var m=document.cookie.match(/(?:^|;\\s*)rentv_csrf=([^;]+)/);var t=m?decodeURIComponent(m[1]):'';if(!t)return;var of=window.fetch;window.fetch=function(u,o){o=o||{};var meth=((o.method||'GET')+'').toUpperCase();if(['POST','PUT','DELETE','PATCH'].indexOf(meth)>=0){var h=new Headers(o.headers||{});h.set('X-CSRF-Token',t);o.headers=h;}return of(u,o);};})();");
+});
+app.post('/api/login', (req, res) => {
+ const b = req.body || {};
+ const cred = 'Basic ' + Buffer.from(String(b.username || '') + ':' + String(b.password || '')).toString('base64');
+ const role = CRED_ROLE.get(cred);
+ if (!role) return res.status(401).json({ ok: false, error: 'Invalid username or password' });
+ const id = newId(), csrf = newId(), now = Date.now(); // fresh id on every login = no session fixation
+ SESS.set(id, { role, user: String(b.username), created: now, seen: now, csrf });
+ const maxAge = Math.floor(SESS_ABS_MS / 1000), f = cookieFlags(req);
+ res.setHeader('Set-Cookie', [
+ `rentv_sess=${id}; Max-Age=${maxAge}${f}`,
+ `rentv_csrf=${csrf}; Max-Age=${maxAge}${f.replace('; HttpOnly', '')}`, // readable by csrf.js (double-submit)
+ ]);
+ res.json({ ok: true, role });
+});
+app.post('/api/logout', (req, res) => {
+ const id = parseCookies(req).rentv_sess; if (id) SESS.delete(id);
+ res.setHeader('Set-Cookie', ['rentv_sess=; Max-Age=0; HttpOnly; Path=/', 'rentv_csrf=; Max-Age=0; Path=/']);
+ res.json({ ok: true });
+});
+app.get('/api/whoami', (req, res) => { const s = sessionOf(req); res.json({ authed: !!s, role: s ? s.role : null, via: s ? 'session' : null }); });
+
+// Global authentication gate — session cookie → service token → Basic → clean-login redirect.
app.use((req, res, next) => {
- if (process.env.OPEN === '1') { req.role = 'admin'; return next(); } // local-preview bypass; prod never sets OPEN
- const role = CRED_ROLE.get(req.headers.authorization || '');
- if (role) { req.role = role; return next(); }
+ if (process.env.OPEN === '1') { req.role = 'admin'; req.authVia = 'open'; return next(); } // local-preview bypass
+ const s = sessionOf(req);
+ if (s) { req.role = s.role; req.authVia = 'session'; req.session = s; return next(); }
+ const authz = req.headers.authorization || '';
+ if (SERVICE_TOKEN && authz === 'Bearer ' + SERVICE_TOKEN) { req.role = 'admin'; req.authVia = 'service'; return next(); }
+ const role = CRED_ROLE.get(authz); // Basic — nginx-forwarded creds + existing callers
+ if (role) { req.role = role; req.authVia = 'basic'; return next(); }
+ // Unauthenticated: clean login page for a browser page-load; 401 for API/XHR/curl (Basic-compatible).
+ if (req.method === 'GET' && !req.path.startsWith('/api/') && String(req.headers.accept || '').includes('text/html')) {
+ return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
+ }
res.set('WWW-Authenticate', 'Basic realm="RENTV"');
return res.status(401).send('Authentication required');
});
+// CSRF — enforced ONLY for SESSION-authenticated mutations (Basic/service/open are CSRF-immune;
+// dormant while the nginx wall keeps everyone on Basic). Double-submit: X-CSRF-Token must equal
+// the session's csrf (csrf.js attaches it automatically to every mutating fetch).
+app.use((req, res, next) => {
+ if (req.authVia !== 'session') return next();
+ if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return next();
+ const tok = req.headers['x-csrf-token'] || '';
+ if (req.session && tok && tok === req.session.csrf) return next();
+ return res.status(403).json({ ok: false, error: 'CSRF token missing or invalid — reload the page' });
+});
// Admin-only gate: 403 for user-tier logins. Guards all INTERNAL data endpoints + shells.
function adminOnly(req, res, next) {
if (req.role === 'admin') return next();
@@ -108,6 +182,12 @@ function sendPage(res, absFile) {
html = html.indexOf('</head>') !== -1 ? html.replace('</head>', DAYNIGHT_PREAPPLY + '</head>') : DAYNIGHT_PREAPPLY + html;
html = closeBody() ? html.replace('</body>', DAYNIGHT_TAG + '</body>') : html + DAYNIGHT_TAG;
}
+ // CSRF fetch-patch (Path A) — attaches X-CSRF-Token to mutating fetches when a session csrf
+ // cookie is present (inert under Basic auth). In <head> so it patches window.fetch first.
+ if (html.indexOf('/csrf.js') === -1) {
+ const t = '<script src="/csrf.js"></script>';
+ html = html.indexOf('</head>') !== -1 ? html.replace('</head>', t + '</head>') : t + html;
+ }
if (!INTERNAL_PAGE.test(absFile) && html.indexOf('rentv-footer') === -1) {
html = closeBody() ? html.replace('</body>', FOOTER_TAG + '</body>') : html + FOOTER_TAG;
}
← bf2e528d Cody-gate: Deal Desk corpus /find is HONEST — the corpus IS
·
back to Rentv
·
auto-save: 2026-08-05T15:13:13 (8 files) — data/closings-ove 7701c773 →