Replace admin username/password with a per-launch API key, and disable the admin panel by default
Two hardening changes beyond just a password: - The admin panel no longer uses stored credentials at all. The backend generates a random API key on every startup and prints it to its own console (never through the DB-backed logger, since that's only reachable from inside the panel this key protects). Every /api/admin/* request must carry it as an X-Api-Key header, checked with a timing-safe comparison on every call — there's no session to create or steal, and restarting the backend invalidates the previous key immediately. The old admin_users and sessions tables, scrypt password hashing, and cookie-based session plumbing are removed entirely (dropped via migration for existing installs, not left behind unused). The login page keeps its existing layout but now asks for this key and explains where to find it, storing it in the browser's localStorage rather than relying on a server session. - The admin panel (the masthead's cog icon and the /admin/* pages themselves) is now disabled by default on every deployment, gated by a new frontend-only ADMIN_PANEL_ENABLED env var. This is a separate, UI-only visibility control — the API key above is what actually protects the backend regardless of this flag.
This commit is contained in:
@@ -3,8 +3,9 @@ FRONTEND_ORIGIN=http://localhost:5173
|
||||
DB_PATH=./data/homefeed.db
|
||||
MEDIA_DIR=./data/media
|
||||
|
||||
# Seeded once on first run — change the password after logging in.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-immediately
|
||||
# There's no admin username/password to configure here — the backend generates a
|
||||
# random API key on every startup and prints it to the console. Copy that key into
|
||||
# the admin login page (every /api/admin/* request requires it as an X-Api-Key
|
||||
# header). It changes on every restart, so check the console output each time.
|
||||
|
||||
NODE_ENV=development
|
||||
|
||||
+12
-3
@@ -10,7 +10,6 @@ contract the frontend already consumes from the mock backend — plus the full
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — at minimum set ADMIN_PASSWORD to something real
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
@@ -18,6 +17,14 @@ npm run dev
|
||||
Runs on `:4000` by default. Point the frontend's `VITE_BACKEND_URL` at it instead of
|
||||
the mock backend and everything else keeps working unchanged — same API contract.
|
||||
|
||||
On startup, the console prints an admin API key — a fresh random value generated
|
||||
every time the process starts (see `api/apiKey.ts`), not stored anywhere and not
|
||||
configurable via `.env`. Every `/api/admin/*` request must send it as an
|
||||
`X-Api-Key` header (enforced in `api/auth.ts`); the admin login page just asks for
|
||||
this key and stashes it in the browser's `localStorage` rather than issuing its own
|
||||
session. Restarting the backend invalidates the previous key — check the console
|
||||
each time.
|
||||
|
||||
You'll also need a running Ollama instance (see `AI_SERVICE_HOST`/`AI_SERVICE_PORT` in
|
||||
the admin panel's Connections tab, or `PATCH /api/admin/settings` directly) with at
|
||||
minimum an embedding model (e.g. `nomic-embed-text`) and a generation model (e.g.
|
||||
@@ -88,8 +95,10 @@ real RSS parsing, real HTTP calls to a stub Ollama server, real media download t
|
||||
disk, real tag dedup across separate synthesis calls):
|
||||
|
||||
- SQLite schema + repository layer for every entity in `homefeed-data-schema.md`
|
||||
- Session auth (scrypt password hashing, httpOnly cookie, CORS locked to the
|
||||
configured frontend origin) protecting all `/api/admin/*` routes
|
||||
- Per-launch API key auth (random key printed to the console on every startup,
|
||||
checked via a timing-safe comparison against an `X-Api-Key` header on every
|
||||
request, CORS locked to the configured frontend origin) protecting all
|
||||
`/api/admin/*` routes
|
||||
- RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter
|
||||
(configurable field mapping)
|
||||
- Poller respecting per-source poll intervals
|
||||
|
||||
Generated
-34
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.1",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"fastify": "^5.10.0",
|
||||
@@ -696,26 +695,6 @@
|
||||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz",
|
||||
"integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^2.0.0",
|
||||
"fastify-plugin": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz",
|
||||
@@ -1295,19 +1274,6 @@
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.1",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"fastify": "^5.10.0",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Generated once per process start — not persisted, not configurable via env. Every
|
||||
// restart invalidates the previous key, which is the whole point: the only way to
|
||||
// learn the current key is to have console/log access to the running process (see
|
||||
// index.ts's startup banner), which is a meaningfully different trust boundary than a
|
||||
// password someone could guess or brute-force over the network.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export const ADMIN_API_KEY = randomBytes(24).toString('hex');
|
||||
+22
-35
@@ -1,44 +1,31 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getAdminUserByUsername, createSession, isSessionValid, deleteSession } from '../storage/db/auth.js';
|
||||
import { verifyPassword } from './password.js';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { ADMIN_API_KEY } from './apiKey.js';
|
||||
|
||||
const SESSION_COOKIE = 'homefeed_session';
|
||||
function isValidKey(provided: string | undefined): boolean {
|
||||
if (!provided) return false;
|
||||
// Buffers of mismatched length would make timingSafeEqual throw rather than
|
||||
// return false — checking length first keeps this a normal "wrong key" case for
|
||||
// any header of a different length rather than a runtime error.
|
||||
const providedBuf = Buffer.from(provided);
|
||||
const expectedBuf = Buffer.from(ADMIN_API_KEY);
|
||||
if (providedBuf.length !== expectedBuf.length) return false;
|
||||
return timingSafeEqual(providedBuf, expectedBuf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards every /api/admin/* route with the process's current API key (see
|
||||
* api/apiKey.ts) — there's no session or login endpoint anymore: the key itself is
|
||||
* the credential, checked on every single request, exactly the way a bot or curl
|
||||
* script hitting these routes unauthenticated is meant to be stopped cold.
|
||||
*/
|
||||
export async function registerAuth(app: FastifyInstance) {
|
||||
app.post('/api/admin/login', async (req, reply) => {
|
||||
const { username, password } = req.body as { username?: string; password?: string };
|
||||
if (!username || !password) return reply.code(400).send({ error: 'username and password required' });
|
||||
|
||||
const user = getAdminUserByUsername(username);
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
// Deliberately generic — doesn't reveal whether the username exists.
|
||||
return reply.code(401).send({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
const session = createSession(req.ip ?? null);
|
||||
reply.setCookie(SESSION_COOKIE, session.id, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
path: '/',
|
||||
expires: new Date(session.expiresAt)
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', async (req, reply) => {
|
||||
const sessionId = req.cookies[SESSION_COOKIE];
|
||||
if (sessionId) deleteSession(sessionId);
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Guards every /api/admin/* route except login itself.
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!req.url.startsWith('/api/admin/') || req.url === '/api/admin/login') return;
|
||||
if (!req.url.startsWith('/api/admin/')) return;
|
||||
|
||||
const sessionId = req.cookies[SESSION_COOKIE];
|
||||
if (!sessionId || !isSessionValid(sessionId)) {
|
||||
const header = req.headers['x-api-key'];
|
||||
const provided = Array.isArray(header) ? header[0] : header;
|
||||
if (!isValidKey(provided)) {
|
||||
return reply.code(401).send({ error: 'unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const KEY_LEN = 64;
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN);
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [saltHex, hashHex] = stored.split(':');
|
||||
if (!saltHex || !hashHex) return false;
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
const actual = scryptSync(password, salt, KEY_LEN);
|
||||
if (actual.length !== expected.length) return false;
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
+14
-8
@@ -1,10 +1,9 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { migrate } from './storage/db/index.js';
|
||||
import { ensureAdminUserSeeded } from './storage/db/auth.js';
|
||||
import { ADMIN_API_KEY } from './api/apiKey.js';
|
||||
import { registerAuth } from './api/auth.js';
|
||||
import { registerPublicRoutes } from './api/public.js';
|
||||
import { registerAdminRoutes } from './api/admin.js';
|
||||
@@ -15,12 +14,21 @@ const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
|
||||
const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';
|
||||
|
||||
function printApiKeyBanner() {
|
||||
const line = '='.repeat(64);
|
||||
// Deliberately console.log, not the DB-backed logger — the Logs tab in the admin
|
||||
// panel is itself behind this key, so printing there would be unreachable until
|
||||
// you already have the key. This is the one and only place it's ever surfaced.
|
||||
console.log(`\n${line}`);
|
||||
console.log(' Homefeed admin API key (required for every /api/admin/* request)');
|
||||
console.log(` ${ADMIN_API_KEY}`);
|
||||
console.log(' This key is generated fresh on every restart — it will not be the same next time.');
|
||||
console.log(`${line}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
migrate();
|
||||
ensureAdminUserSeeded(
|
||||
process.env.ADMIN_USERNAME || 'admin',
|
||||
process.env.ADMIN_PASSWORD || 'change-me-immediately'
|
||||
);
|
||||
printApiKeyBanner();
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
@@ -32,10 +40,8 @@ async function main() {
|
||||
// reaches a route handler.
|
||||
await app.register(cors, {
|
||||
origin: FRONTEND_ORIGIN,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS']
|
||||
});
|
||||
await app.register(cookie);
|
||||
|
||||
// Overrides Fastify's default JSON body parser, which throws "Body cannot be empty
|
||||
// when content-type is set to 'application/json'" for any bodyless request (DELETE,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { runEventRecaps } from './eventsRecap.js';
|
||||
import { runRetentionSweep } from './retention.js';
|
||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import { pruneExpiredSessions } from '../storage/db/auth.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
|
||||
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
||||
@@ -56,7 +55,6 @@ export function startScheduler() {
|
||||
setInterval(() => {
|
||||
try {
|
||||
runRetentionSweep(settingsDb.getSettings());
|
||||
pruneExpiredSessions();
|
||||
logger.info('retention', 'Retention sweep completed');
|
||||
} catch (err) {
|
||||
logger.error('retention', `Retention tick failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './index.js';
|
||||
import { hashPassword } from '../../api/password.js';
|
||||
import { logger } from './logs.js';
|
||||
|
||||
const SESSION_TTL_HOURS = 24;
|
||||
|
||||
export function ensureAdminUserSeeded(defaultUsername: string, defaultPassword: string) {
|
||||
const existing = db.prepare('SELECT id FROM admin_users LIMIT 1').get();
|
||||
if (existing) return;
|
||||
db.prepare('INSERT INTO admin_users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)').run(
|
||||
randomUUID(),
|
||||
defaultUsername,
|
||||
hashPassword(defaultPassword),
|
||||
new Date().toISOString()
|
||||
);
|
||||
logger.warn('auth', `Seeded initial admin user "${defaultUsername}". Change this password after first login.`);
|
||||
}
|
||||
|
||||
export function getAdminUserByUsername(username: string) {
|
||||
return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username) as
|
||||
| { id: string; username: string; password_hash: string }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function createSession(ip: string | null): { id: string; expiresAt: string } {
|
||||
const id = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_TTL_HOURS * 3600_000).toISOString();
|
||||
db.prepare('INSERT INTO sessions (id, created_at, expires_at, ip) VALUES (?, ?, ?, ?)').run(
|
||||
id,
|
||||
now.toISOString(),
|
||||
expiresAt,
|
||||
ip
|
||||
);
|
||||
return { id, expiresAt };
|
||||
}
|
||||
|
||||
export function isSessionValid(id: string): boolean {
|
||||
const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) as
|
||||
| { expires_at: string }
|
||||
| undefined;
|
||||
if (!row) return false;
|
||||
return new Date(row.expires_at).getTime() > Date.now();
|
||||
}
|
||||
|
||||
export function deleteSession(id: string) {
|
||||
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function pruneExpiredSessions() {
|
||||
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(new Date().toISOString());
|
||||
}
|
||||
@@ -18,6 +18,13 @@ db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
export function migrate() {
|
||||
// Admin auth moved from username/password + sessions to a per-launch API key (see
|
||||
// api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live
|
||||
// session in them, have no further purpose and are dropped rather than left as
|
||||
// orphaned schema/data.
|
||||
db.exec('DROP TABLE IF EXISTS admin_users;');
|
||||
db.exec('DROP TABLE IF EXISTS sessions;');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -133,20 +140,6 @@ export function migrate() {
|
||||
is_default INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user