diff --git a/CHANGELOG.md b/CHANGELOG.md index 5347da2..d75ada9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## 2.20.4 - 2026-10-25 + +### Added + +- Added optional `AUTH_TRUSTED_PROXIES` configuration for reverse-proxy IP addresses and CIDR ranges so Better Auth can resolve forwarded client IPs for sessions and per-client rate limits. + +### Fixed + +- Restored successful-login audit events for password, two-factor, and passkey authentication, without recording pending two-factor challenges as completed logins. + ## 2.20.3 - 2026-10-25 ### Added diff --git a/build/package.player.json b/build/package.player.json index 957fd72..972a1ab 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.20.3", + "version": "2.20.4", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index b5bb4b2..00fe2e7 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.20.3", + "version": "2.20.4", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 30bf7bd..ec306af 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -6,6 +6,7 @@ PULSE_SIGNAGE_PLAYER_IMAGE="git.lzstealth.com/lzstealth/pulse-signage-player:lat PULSE_SIGNAGE_SHARED_SECRET="" AUTH_SECRET="REPLACE_WITH_A_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS" AUTH_TRUSTED_ORIGINS="http://localhost:8080,http://127.0.0.1:8080" +AUTH_TRUSTED_PROXIES="" # Database DB_HOST="mysql" diff --git a/docker-compose/README.md b/docker-compose/README.md index 5aef8da..a23cba9 100644 --- a/docker-compose/README.md +++ b/docker-compose/README.md @@ -62,12 +62,15 @@ Key configuration: - `PULSE_SIGNAGE_SHARED_SECRET` - `AUTH_SECRET` (generate with `openssl rand -base64 32` and keep stable across restarts) - `AUTH_TRUSTED_ORIGINS` (comma-separated extra browser origins, such as local `localhost` URLs) +- `AUTH_TRUSTED_PROXIES` (optional comma-separated proxy IP addresses or CIDR ranges; leave empty for direct access) - `WEB_PUBLIC_URL` - `BRIDGE_INTERNAL_URL` - `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_NAME` - `DEFAULT_ADMIN_PASSWORD` +For reverse-proxy deployments, set `AUTH_TRUSTED_PROXIES` to the actual proxy addresses or narrowly scoped proxy networks present in the forwarded IP chain. Better Auth uses these to resolve multi-address `X-Forwarded-For` headers for session IPs and per-client rate limits. For example, `AUTH_TRUSTED_PROXIES="192.0.2.10,198.51.100.0/24"` illustrates the format; replace these documentation addresses with your own. Do not use browser origins, trust all networks, or add client networks just to suppress the warning. Ensure the proxy sanitizes forwarded headers and clients cannot bypass it. Recreate the web container after changing the environment. Local development reads this setting from `docker-compose/.local.env`. + ### `player` The screen runtime that renders playlists and receives commands. diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 419c2cc..69d1e24 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -17,6 +17,7 @@ services: PULSE_SIGNAGE_SHARED_SECRET: ${PULSE_SIGNAGE_SHARED_SECRET:-} AUTH_SECRET: ${AUTH_SECRET:?Set a random AUTH_SECRET in .env} AUTH_TRUSTED_ORIGINS: ${AUTH_TRUSTED_ORIGINS:-http://localhost:8080,http://127.0.0.1:8080} + AUTH_TRUSTED_PROXIES: ${AUTH_TRUSTED_PROXIES:-} WEB_PUBLIC_URL: ${WEB_PUBLIC_URL:-http://localhost:8080} BRIDGE_INTERNAL_URL: ${BRIDGE_INTERNAL_URL:-http://player-bridge:8090} DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin} diff --git a/package-lock.json b/package-lock.json index 7101478..9718ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.20.3", + "version": "2.20.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.20.3", + "version": "2.20.4", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 01ba563..dce9782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.20.3", + "version": "2.20.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web/lib/auth/better-auth.js b/src/web/lib/auth/better-auth.js index 2aa8fe7..3d127e0 100644 --- a/src/web/lib/auth/better-auth.js +++ b/src/web/lib/auth/better-auth.js @@ -2,9 +2,34 @@ const crypto = require('crypto'); const { isIP } = require('node:net'); const { verifyEncodedPassword } = require('#src/auth'); const { fetchAppSettings } = require('#src/data/app-settings'); +const { recordRequestAuditEvent } = require('#src/data/audit-log'); const { formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate } = require('#src/data/account-email-templates'); const LOGIN_RATE_LIMIT_PATHS = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code']; +const LOGIN_AUDIT_PATHS = LOGIN_RATE_LIMIT_PATHS.concat('/passkey/verify-authentication'); + +function createLoginAuditHook(pool, recordAudit = recordRequestAuditEvent) { + return async function (ctx) { + const newSession = ctx.context.newSession; + if (!LOGIN_AUDIT_PATHS.includes(ctx.path) || !newSession || !newSession.session || !newSession.user) return; + const headers = ctx.headers || ctx.request && ctx.request.headers; + await recordAudit(pool, { + headers: headers ? Object.fromEntries(headers) : {}, + ip: newSession.session.ipAddress + }, { + category: 'authentication', + eventType: 'login.success', + actorUserId: newSession.user.id, + targetType: 'user', + targetId: newSession.user.id, + targetLabel: newSession.user.username || newSession.user.email + }); + }; +} + +function getTrustedProxies(value = process.env.AUTH_TRUSTED_PROXIES) { + return String(value || '').split(',').map(function (proxy) { return proxy.trim(); }).filter(Boolean); +} function ensureBetterAuthClientIp(req) { if (!req.headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) { @@ -272,6 +297,9 @@ async function createBetterAuth(pool, options) { after: [{ matcher: function (ctx) { return LOGIN_RATE_LIMIT_PATHS.includes(ctx.path); }, handler: apiModule.createAuthMiddleware(createLoginRateLimitResetHook(pool)) + }, { + matcher: function (ctx) { return LOGIN_AUDIT_PATHS.includes(ctx.path); }, + handler: apiModule.createAuthMiddleware(createLoginAuditHook(pool)) }] } } @@ -279,7 +307,8 @@ async function createBetterAuth(pool, options) { disabledPaths: ['/sign-up/email', '/forget-password', '/reset-password', '/change-password', '/send-verification-email', '/verify-email'], advanced: { ipAddress: { - ipAddressHeaders: ['x-forwarded-for'] + ipAddressHeaders: ['x-forwarded-for'], + trustedProxies: getTrustedProxies() }, useSecureCookies: false, defaultCookieAttributes: { @@ -317,10 +346,12 @@ async function createBetterAuth(pool, options) { module.exports = { createBetterAuth, + getTrustedProxies, ensureBetterAuthClientIp, createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, + createLoginAuditHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError diff --git a/test/better-auth-password.test.js b/test/better-auth-password.test.js index 06bcfa8..38cd127 100644 --- a/test/better-auth-password.test.js +++ b/test/better-auth-password.test.js @@ -1,7 +1,49 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { hashPassword, encodePasswordRecord, verifyEncodedPassword, hashScryptPassword, verifyCredentialPassword } = require('../src/auth'); -const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp } = require('../src/web/lib/auth/better-auth'); +const { createPasswordCallbacks, createAccountLockSessionHook, createLoginRateLimitResetHook, createLoginAuditHook, createPasskeyAuthenticationGuard, resolvePasskeyRpID, getRoleTwoFactorAuthError, ensureBetterAuthClientIp, getTrustedProxies } = require('../src/web/lib/auth/better-auth'); + +test('completed password, two-factor, and passkey logins record an audit event with request metadata', async () => { + const events = []; + const pool = {}; + const hook = createLoginAuditHook(pool, async function (actualPool, req, event) { + assert.equal(actualPool, pool); + events.push({ req, event }); + }); + for (const path of ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code', '/passkey/verify-authentication']) { + await hook({ + path, + headers: new Headers({ 'x-forwarded-for': '192.0.2.1', 'user-agent': 'test-browser' }), + context: { newSession: { session: { ipAddress: '192.0.2.1' }, user: { id: 7, username: 'alice' } } } + }); + } + assert.equal(events.length, 5); + for (const { req, event } of events) { + assert.deepEqual(event, { category: 'authentication', eventType: 'login.success', actorUserId: 7, targetType: 'user', targetId: 7, targetLabel: 'alice' }); + assert.equal(req.headers['user-agent'], 'test-browser'); + assert.equal(req.headers['x-forwarded-for'], '192.0.2.1'); + assert.equal(req.ip, '192.0.2.1'); + } +}); + +test('failed login, pending two-factor, and unrelated session creation do not log a successful login', async () => { + const hook = createLoginAuditHook({}, async function () { assert.fail('Login must not be audited as successful'); }); + for (const path of ['/sign-in/username', '/two-factor/verify-totp', '/passkey/verify-authentication']) { + await hook({ path, context: { newSession: null } }); + } + await hook({ path: '/two-factor/enable', context: { newSession: { session: {}, user: { id: 7 } } } }); +}); + +test('Better Auth trusted proxies accept comma-separated IPs and CIDRs without trusting proxies by default', async () => { + const { getIPFromHeader } = await import('@better-auth/core/utils/ip'); + assert.deepEqual(getTrustedProxies(''), []); + assert.deepEqual(getTrustedProxies(' , '), []); + const trustedProxies = getTrustedProxies(' 192.0.2.2, 198.51.100.0/24, , ::1 '); + assert.deepEqual(trustedProxies, ['192.0.2.2', '198.51.100.0/24', '::1']); + const chain = '203.0.113.1, 192.0.2.2, 198.51.100.2'; + assert.equal(getIPFromHeader(chain, { trustedProxies: getTrustedProxies('') }), null); + assert.equal(getIPFromHeader(chain, { trustedProxies }), '203.0.113.1'); +}); test('Better Auth client IP falls back to the socket address for direct requests', () => { for (const address of ['192.0.2.1', '::1', '::ffff:192.0.2.1']) {