diff --git a/README.md b/README.md index 0873647..b1f0414 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 2ecbe65..e71684d 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 283079e..20d455a 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 0000000..1bbade5 --- /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 0000000..e6c6fa0 --- /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 f81d7f5..cdbe173 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'),