diff --git a/backend/src/config.ts b/backend/src/config.ts index bd7213f..65726c2 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -174,6 +174,17 @@ export const config = { streamingApiUrl: process.env.STREAMING_API_URL || null, streamingApiToken: process.env.STREAMING_API_TOKEN || null, + // Sign users in from a header set by an authenticating reverse proxy, such as + // Authentik forward auth. Off unless the header name and the proxy addresses + // are both set; the header is only believed on connections from those + // addresses, since anyone else could send it. + trustedHeaderUsername: process.env.TRUSTED_HEADER_USERNAME || null, + trustedHeaderEmail: process.env.TRUSTED_HEADER_EMAIL || null, + trustedProxies: (process.env.TRUSTED_PROXIES ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + // When enabled, copies flashpoint.sqlite to local storage for faster network access enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true', localDbPath: '/app/data/flashpoint.sqlite', diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2b697e0..0dee013 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -8,6 +8,7 @@ import { asyncHandler } from '../middleware/asyncHandler'; import { softAuth } from '../middleware/auth'; import { logActivity } from '../middleware/activityLogger'; import { logger } from '../utils/logger'; +import { getTrustedIdentity, isTrustedHeaderEnabled } from '../utils/trustedHeader'; import { setRefreshTokenCookie, clearRefreshTokenCookie, @@ -245,6 +246,47 @@ router.post( }) ); +// Signs in the user an authenticating reverse proxy vouches for (TRUSTED_HEADER_USERNAME) +router.post( + '/trusted-login', + refreshLimiter, + asyncHandler(async (req, res) => { + if (!isTrustedHeaderEnabled()) { + throw new AppError(404, 'Trusted header login is not enabled'); + } + const identity = getTrustedIdentity(req); + if (!identity) { + throw new AppError(403, 'No trusted identity on this request'); + } + + const ipAddress = req.ip ?? ''; + const result = await authService.trustedLogin(identity.username, identity.email); + + await activityService.log({ + userId: result.user.id, + username: result.user.username, + action: 'login', + resource: 'auth', + details: { method: 'trusted-header', accountCreated: result.created }, + ipAddress, + userAgent: req.headers['user-agent'], + }); + logger.info( + `[Auth] Trusted header login for user: ${result.user.username}${result.created ? ' (account created)' : ''}` + ); + + setRefreshTokenCookie(req, res, result.tokens.refreshToken); + setAccessTokenCookie(req, res, result.tokens.accessToken); + + res.json({ + user: result.user, + tokens: { + expiresIn: result.tokens.expiresIn, + }, + }); + }) +); + router.post( '/logout', softAuth, diff --git a/backend/src/routes/trustedLogin.test.ts b/backend/src/routes/trustedLogin.test.ts new file mode 100644 index 0000000..2d735d6 --- /dev/null +++ b/backend/src/routes/trustedLogin.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import express, { Express } from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import fs from 'fs'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +// UserDatabaseService creates the directory +const tempDir = vi.hoisted( + () => `${process.env.TMPDIR ?? '/tmp'}/trusted-login-${process.pid}-${Date.now()}` +); + +vi.mock('../config', async (importOriginal) => { + const actual = await importOriginal(); + return { + config: { + ...actual.config, + userDbPath: `${tempDir}/user.db`, + trustedHeaderUsername: 'X-authentik-username', + trustedHeaderEmail: 'X-authentik-email', + // supertest connects over loopback, standing in for the proxy + trustedProxies: ['127.0.0.1', '::1'], + }, + }; +}); + +import authRouter from './auth'; +import usersRouter from './users'; +import { errorHandler } from '../middleware/errorHandler'; +import { UserDatabaseService } from '../services/UserDatabaseService'; +import { AuthService } from '../services/AuthService'; + +function createApp(): Express { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/auth', authRouter); + app.use('/api/users', usersRouter); + app.use(errorHandler); + return app; +} + +function trustedLogin(app: Express, username?: string, email?: string) { + const req = request(app).post('/api/auth/trusted-login'); + if (username) req.set('X-authentik-username', username); + if (email) req.set('X-authentik-email', email); + return req; +} + +function cookiesOf(res: request.Response): string[] { + const header = res.headers['set-cookie'] as unknown as string[] | undefined; + return (header ?? []).map((c) => c.split(';')[0]); +} + +describe('trusted header login', () => { + let app: Express; + + beforeAll(async () => { + await UserDatabaseService.initialize(); + app = createApp(); + }); + + afterAll(() => { + UserDatabaseService.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('creates the first trusted user as the administrator, then regular users', async () => { + const first = await trustedLogin(app, 'brad', 'brad@example.com'); + expect(first.status).toBe(200); + expect(first.body.user).toMatchObject({ + username: 'brad', + email: 'brad@example.com', + role: 'admin', + }); + expect(cookiesOf(first).some((c) => c.startsWith('fp_refresh='))).toBe(true); + + const second = await trustedLogin(app, 'friend'); + expect(second.body.user).toMatchObject({ + username: 'friend', + email: 'friend@trusted-header.invalid', + role: 'user', + }); + }); + + it('signs an existing user back in, matching the username case-insensitively', async () => { + const again = await trustedLogin(app, 'BRAD', 'someone-else@example.com'); + expect(again.status).toBe(200); + expect(again.body.user).toMatchObject({ + username: 'brad', + email: 'brad@example.com', + role: 'admin', + }); + }); + + it('adopts an account created with a password', async () => { + const authService = new AuthService(); + const db = UserDatabaseService.getDatabase(); + db.prepare( + "INSERT INTO users (username, email, password_hash, role_id) VALUES ('local', 'local@example.com', 'x', 2)" + ).run(); + const { user } = await authService.trustedLogin('local', null); + expect(user).toMatchObject({ username: 'local', email: 'local@example.com' }); + }); + + it('refuses disabled accounts, missing identities and bad usernames', async () => { + UserDatabaseService.run("UPDATE users SET is_active = 0 WHERE username = 'friend'", []); + expect((await trustedLogin(app, 'friend')).status).toBe(403); + expect((await trustedLogin(app)).status).toBe(403); + expect((await trustedLogin(app, 'ab')).status).toBe(400); + }); + + it('lets a trusted user set a password without the current one, then log in with it', async () => { + const login = await trustedLogin(app, 'brad'); + const cookies = cookiesOf(login); + + const withoutProxy = await request(app) + .post(`/api/users/${login.body.user.id}/change-password`) + .set('Cookie', cookies) + .send({ newPassword: 'lan-password' }); + expect(withoutProxy.status).toBe(400); + + const viaProxy = await request(app) + .post(`/api/users/${login.body.user.id}/change-password`) + .set('Cookie', cookies) + .set('X-authentik-username', 'brad') + .send({ newPassword: 'lan-password' }); + expect(viaProxy.status).toBe(200); + + const passwordLogin = await request(app) + .post('/api/auth/login') + .send({ username: 'brad', password: 'lan-password' }); + expect(passwordLogin.status).toBe(200); + }); + + it("can't set another user's password by naming yourself in the header", async () => { + const friend = await trustedLogin(app, 'local'); + const res = await request(app) + .post(`/api/users/1/change-password`) + .set('Cookie', cookiesOf(friend)) + .set('X-authentik-username', 'local') + .send({ newPassword: 'stolen-password' }); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 3f15f3c..a74ec31 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth'; import { requirePermission } from '../middleware/rbac'; import { logActivity } from '../middleware/activityLogger'; import { AppError } from '../middleware/errorHandler'; +import { getTrustedIdentity } from '../utils/trustedHeader'; import { asyncHandler } from '../middleware/asyncHandler'; import { z } from 'zod'; @@ -257,9 +258,14 @@ router.post( } const isAdminReset = hasAdminPermission && !isOwnPassword; + // A trusted proxy vouching for this user on this request proves identity as + // well as the current password would, and accounts it created have none + const trustedIdentity = getTrustedIdentity(req); + const isTrustedSelf = + isOwnPassword && trustedIdentity?.username.toLowerCase() === req.user?.username.toLowerCase(); // Require current password for self-change (non-admin reset) - if (!isAdminReset && !data.currentPassword) { + if (!isAdminReset && !isTrustedSelf && !data.currentPassword) { throw new AppError(400, 'Current password is required'); } @@ -267,7 +273,7 @@ router.post( id, data.currentPassword || '', data.newPassword, - isAdminReset + isAdminReset || isTrustedSelf ); res.json({ success: true, message: 'Password changed successfully' }); diff --git a/backend/src/services/AuthService.ts b/backend/src/services/AuthService.ts index c701970..a2e4409 100644 --- a/backend/src/services/AuthService.ts +++ b/backend/src/services/AuthService.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import { UserDatabaseService } from './UserDatabaseService'; import { SystemSettingsService } from './SystemSettingsService'; import { CachedSystemSettingsService } from './CachedSystemSettingsService'; @@ -153,21 +154,7 @@ export class AuthService { }); const userId = registerTransaction(); - - const appSettings = this.cachedSystemSettings.getCategory('app'); - const defaultTheme = appSettings.defaultTheme || 'dark'; - const defaultPrimaryColor = appSettings.defaultPrimaryColor || 'blue'; - - UserDatabaseService.run( - `INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES - (?, 'theme_mode', ?), - (?, 'primary_color', ?)`, - [userId, defaultTheme, userId, defaultPrimaryColor] - ); - - logger.info( - `[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}` - ); + this.createDefaultSettings(userId, data.username); const user = UserDatabaseService.get( `SELECT u.id, u.username, u.email, r.name as role_name @@ -195,6 +182,110 @@ export class AuthService { return { user: authUser, tokens }; } + /** + * Sign in a user a trusted proxy has authenticated, creating the account on + * first sight. Matching by username adopts an existing account of that name. + * A new account gets a random password; its owner can set a real one while + * signed in through the proxy, to log in without it. + */ + async trustedLogin( + username: string, + email: string | null + ): Promise<{ user: AuthUser; tokens: AuthTokens; created: boolean }> { + if (username.length < 3 || username.length > 50) { + throw new AppError(400, 'Trusted username must be 3 to 50 characters'); + } + + let created = false; + let user = this.getTokenUserByUsername(username); + if (!user) { + const passwordHash = await hashPassword(randomBytes(32).toString('base64url')); + const db = UserDatabaseService.getDatabase(); + const userId = db.transaction(() => { + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username) as + | { id: number } + | undefined; + if (existing) { + return existing.id; + } + const emailTaken = email && db.prepare('SELECT id FROM users WHERE email = ?').get(email); + const count = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }; + // Same rule as registration: the first account administers the instance + const roleId = count.count === 0 ? 1 : 2; + const result = db + .prepare( + 'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)' + ) + .run( + username, + email && !emailTaken ? email : `${username}@trusted-header.invalid`, + passwordHash, + roleId + ); + created = true; + return result.lastInsertRowid as number; + })(); + if (created) { + this.createDefaultSettings(userId, username); + } + user = this.getTokenUserByUsername(username); + } + + if (!user) { + throw new AppError(500, 'Failed to retrieve trusted user'); + } + if (!user.is_active) { + throw new AppError(403, 'This account is disabled'); + } + + UserDatabaseService.run('UPDATE users SET last_login_at = ? WHERE id = ?', [ + new Date().toISOString(), + user.id, + ]); + + const tokens = await this.generateTokens(user); + return { + user: { + id: user.id, + username: user.username, + email: user.email, + role: user.role_name, + permissions: this.getUserPermissions(user.id), + }, + tokens, + created, + }; + } + + private getTokenUserByUsername( + username: string + ): (UserTokenData & { email: string; is_active: number }) | undefined { + return UserDatabaseService.get( + `SELECT u.id, u.username, u.email, u.is_active, r.name as role_name + FROM users u + JOIN roles r ON u.role_id = r.id + WHERE u.username = ?`, + [username] + ); + } + + private createDefaultSettings(userId: number, username: string): void { + const appSettings = this.cachedSystemSettings.getCategory('app'); + const defaultTheme = appSettings.defaultTheme || 'dark'; + const defaultPrimaryColor = appSettings.defaultPrimaryColor || 'blue'; + + UserDatabaseService.run( + `INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES + (?, 'theme_mode', ?), + (?, 'primary_color', ?)`, + [userId, defaultTheme, userId, defaultPrimaryColor] + ); + + logger.info( + `[AuthService] Created user ${username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}` + ); + } + async logout(refreshToken: string): Promise { UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [ new Date().toISOString(), diff --git a/backend/src/utils/trustedHeader.test.ts b/backend/src/utils/trustedHeader.test.ts new file mode 100644 index 0000000..0b40f49 --- /dev/null +++ b/backend/src/utils/trustedHeader.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request } from 'express'; + +vi.mock('./logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +const config = vi.hoisted(() => ({ + trustedHeaderUsername: 'X-authentik-username' as string | null, + trustedHeaderEmail: 'X-authentik-email' as string | null, + trustedProxies: ['192.0.2.10', '198.51.100.0/24', '2001:db8::/32'], +})); +vi.mock('../config', () => ({ config })); + +function request(remoteAddress: string, headers: Record = {}): Request { + return { socket: { remoteAddress }, headers } as unknown as Request; +} + +describe('trusted header identity', () => { + beforeEach(() => { + vi.resetModules(); + config.trustedHeaderUsername = 'X-authentik-username'; + }); + + async function load() { + return import('./trustedHeader'); + } + + it('believes the header only on connections from a trusted proxy', async () => { + const { getTrustedIdentity } = await load(); + const headers = { 'x-authentik-username': 'brad', 'x-authentik-email': 'brad@example.com' }; + + expect(getTrustedIdentity(request('192.0.2.10', headers))).toEqual({ + username: 'brad', + email: 'brad@example.com', + }); + expect(getTrustedIdentity(request('::ffff:198.51.100.7', headers))?.username).toBe('brad'); + expect(getTrustedIdentity(request('2001:db8::5', headers))?.username).toBe('brad'); + expect(getTrustedIdentity(request('192.0.2.11', headers))).toBeNull(); + expect(getTrustedIdentity(request('10.0.0.5', headers))).toBeNull(); + }); + + it('needs a username, and makes the email optional', async () => { + const { getTrustedIdentity } = await load(); + + expect(getTrustedIdentity(request('192.0.2.10'))).toBeNull(); + expect(getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': ' ' }))).toBeNull(); + expect(getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': 'brad' }))).toEqual({ + username: 'brad', + email: null, + }); + }); + + it('is off without a header name', async () => { + config.trustedHeaderUsername = null; + const { getTrustedIdentity, isTrustedHeaderEnabled } = await load(); + + expect(isTrustedHeaderEnabled()).toBe(false); + expect( + getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': 'brad' })) + ).toBeNull(); + }); + + it('skips invalid proxy entries', async () => { + const { buildBlockList } = await load(); + const list = buildBlockList(['not-an-ip', '192.0.2.0/30']); + + expect(list.check('192.0.2.1', 'ipv4')).toBe(true); + expect(list.check('192.0.2.9', 'ipv4')).toBe(false); + }); +}); diff --git a/backend/src/utils/trustedHeader.ts b/backend/src/utils/trustedHeader.ts new file mode 100644 index 0000000..9e90860 --- /dev/null +++ b/backend/src/utils/trustedHeader.ts @@ -0,0 +1,62 @@ +import { BlockList, isIP } from 'net'; +import type { Request } from 'express'; +import { config } from '../config'; +import { logger } from './logger'; + +let proxies: BlockList | null = null; + +/** Parses TRUSTED_PROXIES entries: addresses or CIDR ranges, IPv4 or IPv6. */ +export function buildBlockList(entries: readonly string[]): BlockList { + const list = new BlockList(); + for (const entry of entries) { + const [address, prefix] = entry.split('/'); + const family = isIP(address); + if (!family) { + logger.warn(`[TrustedHeader] Ignoring invalid TRUSTED_PROXIES entry: ${entry}`); + continue; + } + const type = family === 4 ? 'ipv4' : 'ipv6'; + if (prefix === undefined) { + list.addAddress(address, type); + } else { + list.addSubnet(address, parseInt(prefix, 10), type); + } + } + return list; +} + +export function isTrustedHeaderEnabled(): boolean { + return Boolean(config.trustedHeaderUsername && config.trustedProxies.length > 0); +} + +/** Whether the connection itself, not a forwarded-for header, comes from a trusted proxy. */ +function fromTrustedProxy(req: Request): boolean { + proxies ??= buildBlockList(config.trustedProxies); + let address = req.socket.remoteAddress ?? ''; + // IPv4 clients on a dual-stack socket appear as ::ffff:a.b.c.d + if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) { + address = address.slice(7); + } + const family = isIP(address); + return family !== 0 && proxies.check(address, family === 4 ? 'ipv4' : 'ipv6'); +} + +function header(req: Request, name: string | null): string | null { + if (!name) return null; + const value = req.headers[name.toLowerCase()]; + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +/** + * The identity a trusted proxy vouches for on this request, or null when the + * feature is off, the connection isn't from a trusted proxy, or no user is set. + */ +export function getTrustedIdentity( + req: Request +): { username: string; email: string | null } | null { + if (!isTrustedHeaderEnabled() || !fromTrustedProxy(req)) { + return null; + } + const username = header(req, config.trustedHeaderUsername); + return username ? { username, email: header(req, config.trustedHeaderEmail) } : null; +}