From faf44333a84f9ff4990b507f8ec742d6ade1e4bc Mon Sep 17 00:00:00 2001 From: Stanton Borthwick Date: Mon, 28 Sep 2026 21:49:29 +0100 Subject: [PATCH] feat(server): optional HTTP Basic Auth via AUTH_USERNAME/AUTH_PASSWORD When both env vars are set, the web UI and API require Basic Auth. The KOReader plugin works unchanged by embedding credentials in the server URL (LuaSocket sends them as a Basic Authorization header). KoSync endpoints that already check x-auth-user/x-auth-key stay open so KOReader progress sync keeps working. POST /users/create and GET /syncs/progress, which had no auth, are now protected. Refs #81, #12 Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++ apps/server/.env.example | 8 ++ apps/server/src/app.ts | 6 ++ .../src/auth/basic-auth-middleware.test.ts | 83 +++++++++++++++++++ apps/server/src/auth/basic-auth-middleware.ts | 66 +++++++++++++++ apps/server/src/config.ts | 6 ++ 6 files changed, 180 insertions(+) create mode 100644 apps/server/src/auth/basic-auth-middleware.test.ts create mode 100644 apps/server/src/auth/basic-auth-middleware.ts diff --git a/README.md b/README.md index 08736475..b1f04140 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,17 @@ KoInsight can be configured using the following environment variables: *Default:* `100` - `DATA_PATH`: Path to the directory where KoInsight data (such as stats or uploads) will be stored.
*Default:* `../../../data` or `/app/data` in Docker. +- `AUTH_USERNAME` / `AUTH_PASSWORD`: Enable HTTP Basic Auth for the web UI and API. Both must be set.
+ *Default:* unset (no authentication) + +## Authentication +KoInsight has no authentication by default. If you expose it to the internet, set `AUTH_USERNAME` and `AUTH_PASSWORD`. + +- **Web UI:** your browser will ask for the username and password. +- **KOReader plugin:** put the credentials in the server URL, e.g. `https://username:password@koinsight.example.com`. URL-encode any special characters in the password. +- **KOReader progress sync:** keeps working with its own KoSync username and password. Registering a new KoSync user requires the Basic Auth credentials, so include them in the progress sync server URL when registering. + +Always use HTTPS when authentication is enabled, since Basic Auth sends credentials with every request. # Usage diff --git a/apps/server/.env.example b/apps/server/.env.example index 2ecbe65e..e71684da 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -32,3 +32,11 @@ MAX_FILE_SIZE_MB=100 # OPENAI_API_KEY= # OPENAI_PROJECT_ID= # OPENAI_ORG_ID= + +# --------------------------------------------------------------------------- +# Optional - HTTP Basic Auth for the web UI and API. Enabled only when both +# are set. The KOReader plugin authenticates via the server URL: +# https://username:password@koinsight.example.com +# --------------------------------------------------------------------------- +# AUTH_USERNAME= +# AUTH_PASSWORD= diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 283079e1..20d455ae 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -4,6 +4,7 @@ import { Server } from 'http'; import morgan from 'morgan'; import path from 'path'; import { openAiRouter } from './ai/open-ai-router'; +import { basicAuth } from './auth/basic-auth-middleware'; import { booksRouter } from './books/books-router'; import { appConfig } from './config'; import { devicesRouter } from './devices/devices-router'; @@ -26,6 +27,11 @@ async function setupServer() { app.use(cors({ origin: '*' })); // } + if (appConfig.auth) { + app.use(basicAuth(appConfig.auth)); + console.info('Basic Auth enabled'); + } + app.use('/', kosyncRouter); // Needs to be mounted at root to follow KoSync API app.use('/api/plugin', kopluginRouter); app.use('/api/devices', devicesRouter); diff --git a/apps/server/src/auth/basic-auth-middleware.test.ts b/apps/server/src/auth/basic-auth-middleware.test.ts new file mode 100644 index 00000000..1bbade50 --- /dev/null +++ b/apps/server/src/auth/basic-auth-middleware.test.ts @@ -0,0 +1,83 @@ +import express from 'express'; +import request from 'supertest'; +import { basicAuth } from './basic-auth-middleware'; + +describe('basicAuth', () => { + const app = express(); + app.use(basicAuth({ username: 'reader', password: 's3cret' })); + app.all(/.*/, (_req, res) => { + res.status(200).json({ ok: true }); + }); + + it('allows requests with valid credentials', async () => { + const response = await request(app).get('/api/books').auth('reader', 's3cret'); + + expect(response.status).toBe(200); + }); + + it('rejects requests without credentials and prompts the browser', async () => { + const response = await request(app).get('/api/books'); + + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toBe('Basic realm="KoInsight", charset="UTF-8"'); + expect(response.body).toEqual({ error: 'Unauthorized' }); + }); + + it('rejects a wrong password', async () => { + const response = await request(app).get('/api/books').auth('reader', 'wrong'); + + expect(response.status).toBe(401); + }); + + it('rejects a wrong username', async () => { + const response = await request(app).get('/api/books').auth('someone', 's3cret'); + + expect(response.status).toBe(401); + }); + + it('rejects a malformed authorization header', async () => { + const response = await request(app) + .get('/api/books') + .set('Authorization', 'Basic bm9jb2xvbg=='); + + expect(response.status).toBe(401); + }); + + it('allows passwords containing colons', async () => { + const colonApp = express(); + colonApp.use(basicAuth({ username: 'reader', password: 'a:b:c' })); + colonApp.get('/', (_req, res) => { + res.status(200).json({ ok: true }); + }); + + const response = await request(colonApp).get('/').auth('reader', 'a:b:c'); + + expect(response.status).toBe(200); + }); + + it('protects the web UI and plugin upload endpoints', async () => { + for (const path of ['/', '/api/plugin/import', '/api/plugin/download']) { + const response = await request(app).get(path); + expect(response.status).toBe(401); + } + }); + + it.each([ + ['GET', '/users/auth'], + ['PUT', '/syncs/progress'], + ['GET', '/syncs/progress/abc123'], + ])('leaves self-authenticated KoSync endpoint %s %s open', async (method, path) => { + const response = await request(app)[method.toLowerCase() as 'get' | 'put'](path); + + expect(response.status).toBe(200); + }); + + it.each([ + ['POST', '/users/create'], + ['GET', '/syncs/progress'], + ])('protects KoSync endpoint %s %s that has no auth of its own', async (method, path) => { + const response = await request(app)[method.toLowerCase() as 'get' | 'post'](path); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/server/src/auth/basic-auth-middleware.ts b/apps/server/src/auth/basic-auth-middleware.ts new file mode 100644 index 00000000..e6c6fa08 --- /dev/null +++ b/apps/server/src/auth/basic-auth-middleware.ts @@ -0,0 +1,66 @@ +import { createHash, timingSafeEqual } from 'crypto'; +import { NextFunction, Request, Response } from 'express'; + +type Credentials = { username: string; password: string }; + +/** + * KoSync endpoints that authenticate with their own x-auth-user / x-auth-key + * headers. They are left open so the built-in KOReader progress sync keeps working. + */ +const KOSYNC_SELF_AUTHENTICATED = [ + { method: 'GET', pattern: /^\/users\/auth\/?$/ }, + { method: 'PUT', pattern: /^\/syncs\/progress\/?$/ }, + { method: 'GET', pattern: /^\/syncs\/progress\/[^/]+\/?$/ }, +]; + +// Hashing first gives equal-length buffers, so the comparison is constant-time +function safeEqual(a: string, b: string): boolean { + const hash = (value: string) => createHash('sha256').update(value).digest(); + return timingSafeEqual(hash(a), hash(b)); +} + +function parseBasicAuth(header: string | undefined): Credentials | null { + if (!header?.startsWith('Basic ')) { + return null; + } + + const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + if (separator === -1) { + return null; + } + + return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; +} + +function isKosyncSelfAuthenticated(req: Request): boolean { + return KOSYNC_SELF_AUTHENTICATED.some( + ({ method, pattern }) => req.method === method && pattern.test(req.path) + ); +} + +/** + * Protects the web UI and API with HTTP Basic Auth. + * The KOReader plugin authenticates by embedding the credentials in the server URL, + * e.g. https://user:password@koinsight.example.com + */ +export function basicAuth(expected: Credentials) { + return (req: Request, res: Response, next: NextFunction) => { + if (isKosyncSelfAuthenticated(req)) { + next(); + return; + } + + const credentials = parseBasicAuth(req.header('authorization')); + const usernameMatches = safeEqual(credentials?.username ?? '', expected.username); + const passwordMatches = safeEqual(credentials?.password ?? '', expected.password); + + if (credentials && usernameMatches && passwordMatches) { + next(); + return; + } + + res.set('WWW-Authenticate', 'Basic realm="KoInsight", charset="UTF-8"'); + res.status(401).json({ error: 'Unauthorized' }); + }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index f81d7f51..cdbe1737 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -25,6 +25,12 @@ export const appConfig = { maxFileSizeMegaBytes: MAX_FILE_SIZE_MB, }, + // Basic Auth is enabled only when both are set + auth: + process.env.AUTH_USERNAME && process.env.AUTH_PASSWORD + ? { username: process.env.AUTH_USERNAME, password: process.env.AUTH_PASSWORD } + : null, + db: { dev: path.resolve(DATA_PATH, 'dev.sqlite3'), prod: path.resolve(DATA_PATH, 'prod.sqlite3'),