Merge pull request 'Sign in through an authenticating reverse proxy' (#3) from trusted-header-login into main
Reviewed-on: branderson/flashpoint-web#3 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -109,6 +109,12 @@ ENABLE_CACHE_PREWARM=true
|
|||||||
# Off unless both are set. See docs/09-deployment/environment-variables.md.
|
# Off unless both are set. See docs/09-deployment/environment-variables.md.
|
||||||
# STREAMING_API_URL=http://flashpoint-session-manager:8080
|
# STREAMING_API_URL=http://flashpoint-session-manager:8080
|
||||||
# STREAMING_API_TOKEN=
|
# STREAMING_API_TOKEN=
|
||||||
|
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
|
||||||
|
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
|
||||||
|
# both set. See docs/09-deployment/environment-variables.md.
|
||||||
|
# TRUSTED_HEADER_USERNAME=X-authentik-username
|
||||||
|
# TRUSTED_HEADER_EMAIL=X-authentik-email
|
||||||
|
# TRUSTED_PROXIES=192.0.2.10
|
||||||
# Serve the built frontend from the backend (on by default in production)
|
# Serve the built frontend from the backend (on by default in production)
|
||||||
# SERVE_FRONTEND=true
|
# SERVE_FRONTEND=true
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,17 @@ export const config = {
|
|||||||
streamingApiUrl: process.env.STREAMING_API_URL || null,
|
streamingApiUrl: process.env.STREAMING_API_URL || null,
|
||||||
streamingApiToken: process.env.STREAMING_API_TOKEN || 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
|
// When enabled, copies flashpoint.sqlite to local storage for faster network access
|
||||||
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
|
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
|
||||||
localDbPath: '/app/data/flashpoint.sqlite',
|
localDbPath: '/app/data/flashpoint.sqlite',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { asyncHandler } from '../middleware/asyncHandler';
|
|||||||
import { softAuth } from '../middleware/auth';
|
import { softAuth } from '../middleware/auth';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { getTrustedIdentity, isTrustedHeaderEnabled } from '../utils/trustedHeader';
|
||||||
import {
|
import {
|
||||||
setRefreshTokenCookie,
|
setRefreshTokenCookie,
|
||||||
clearRefreshTokenCookie,
|
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(
|
router.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
softAuth,
|
softAuth,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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<typeof import('../config')>();
|
||||||
|
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 in once when a misconfigured proxy repeats the header', async () => {
|
||||||
|
// superagent sends an array as repeated header lines; its types only allow that for Cookie
|
||||||
|
const twice = (value: string) => [value, value] as unknown as string;
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/trusted-login')
|
||||||
|
.set('X-authentik-username', twice('brad'))
|
||||||
|
.set('X-authentik-email', twice('brad@example.com'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user).toMatchObject({ username: 'brad', email: 'brad@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { authenticate } from '../middleware/auth';
|
|||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { getTrustedIdentity } from '../utils/trustedHeader';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -257,9 +258,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdminReset = hasAdminPermission && !isOwnPassword;
|
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)
|
// 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');
|
throw new AppError(400, 'Current password is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +273,7 @@ router.post(
|
|||||||
id,
|
id,
|
||||||
data.currentPassword || '',
|
data.currentPassword || '',
|
||||||
data.newPassword,
|
data.newPassword,
|
||||||
isAdminReset
|
isAdminReset || isTrustedSelf
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true, message: 'Password changed successfully' });
|
res.json({ success: true, message: 'Password changed successfully' });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { UserDatabaseService } from './UserDatabaseService';
|
import { UserDatabaseService } from './UserDatabaseService';
|
||||||
import { SystemSettingsService } from './SystemSettingsService';
|
import { SystemSettingsService } from './SystemSettingsService';
|
||||||
import { CachedSystemSettingsService } from './CachedSystemSettingsService';
|
import { CachedSystemSettingsService } from './CachedSystemSettingsService';
|
||||||
@@ -153,21 +154,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userId = registerTransaction();
|
const userId = registerTransaction();
|
||||||
|
this.createDefaultSettings(userId, data.username);
|
||||||
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}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = UserDatabaseService.get<UserTokenData & { email: string }>(
|
const user = UserDatabaseService.get<UserTokenData & { email: string }>(
|
||||||
`SELECT u.id, u.username, u.email, r.name as role_name
|
`SELECT u.id, u.username, u.email, r.name as role_name
|
||||||
@@ -195,6 +182,110 @@ export class AuthService {
|
|||||||
return { user: authUser, tokens };
|
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<UserTokenData & { email: string; is_active: number }>(
|
||||||
|
`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<void> {
|
async logout(refreshToken: string): Promise<void> {
|
||||||
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
|
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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<string, string | string[]> = {}): Request {
|
||||||
|
const headersDistinct = Object.fromEntries(
|
||||||
|
Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? value : [value]])
|
||||||
|
);
|
||||||
|
return { socket: { remoteAddress }, headersDistinct } 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('accepts a header repeated with the same value, and refuses conflicting repeats', async () => {
|
||||||
|
const { getTrustedIdentity } = await load();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getTrustedIdentity(
|
||||||
|
request('192.0.2.10', {
|
||||||
|
'x-authentik-username': ['brad', 'brad'],
|
||||||
|
'x-authentik-email': ['brad@example.com', 'brad@example.com'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toEqual({ username: 'brad', email: 'brad@example.com' });
|
||||||
|
expect(
|
||||||
|
getTrustedIdentity(request('192.0.2.10', { 'x-authentik-username': ['brad', 'mallory'] }))
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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;
|
||||||
|
// Read each occurrence: Node joins repeats into one value with ", ", which a
|
||||||
|
// proxy setting the header twice would turn into a different username
|
||||||
|
const values = (req.headersDistinct[name.toLowerCase()] ?? [])
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (values.some((value) => value !== values[0])) {
|
||||||
|
logger.warn(`[TrustedHeader] Ignoring ${name}: the proxy sent conflicting values`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values[0] ?? 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;
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@ services:
|
|||||||
# Streamed play for games that can't run in a browser (off unless both are set)
|
# Streamed play for games that can't run in a browser (off unless both are set)
|
||||||
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
||||||
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
||||||
|
# Sign in users named by an authenticating reverse proxy (off unless both are set)
|
||||||
|
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
|
||||||
|
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
|
||||||
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||||
# OpenTelemetry
|
# OpenTelemetry
|
||||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ services:
|
|||||||
# Streamed play for games that can't run in a browser (off unless both are set)
|
# Streamed play for games that can't run in a browser (off unless both are set)
|
||||||
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
||||||
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
|
||||||
|
# Sign in users named by an authenticating reverse proxy (off unless both are set)
|
||||||
|
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
|
||||||
|
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
|
||||||
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||||
# OpenTelemetry
|
# OpenTelemetry
|
||||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
|
|||||||
@@ -131,6 +131,40 @@ browser's IndexedDB.
|
|||||||
| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` |
|
| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` |
|
||||||
| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set |
|
| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set |
|
||||||
|
|
||||||
|
**Trusted Header Login:**
|
||||||
|
|
||||||
|
When a reverse proxy such as Authentik (forward auth) authenticates users in
|
||||||
|
front of flashpoint-web, it can pass the signed-in username in a header so
|
||||||
|
users don't log in twice. The backend believes the header only on connections
|
||||||
|
from `TRUSTED_PROXIES`, checked against the connection's own address, not
|
||||||
|
`X-Forwarded-For`. The proxy must always set the header, overwriting anything a
|
||||||
|
client sends, and flashpoint-web must not be reachable from untrusted networks
|
||||||
|
except through it.
|
||||||
|
|
||||||
|
Users are matched by username, so an existing account of that name is signed
|
||||||
|
in; otherwise an account is created (the first one as admin, as with
|
||||||
|
registration). New accounts get a random password. While signed in through the
|
||||||
|
proxy, users can set a password from Settings without knowing the current one,
|
||||||
|
then log in with it when reaching flashpoint-web without the proxy, such as on a
|
||||||
|
LAN.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| ------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||||
|
| `TRUSTED_HEADER_USERNAME` | - | Header holding the username, e.g. `X-authentik-username` |
|
||||||
|
| `TRUSTED_HEADER_EMAIL` | - | Optional header holding the email used for new accounts |
|
||||||
|
| `TRUSTED_PROXIES` | - | Comma-separated proxy addresses or CIDR ranges allowed to set the header |
|
||||||
|
|
||||||
|
With nginx and Authentik's forward auth, each protected `location` needs these
|
||||||
|
lines once. Authentik's generated nginx snippet already includes them; adding
|
||||||
|
them again sends each header twice:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
auth_request_set $authentik_username $upstream_http_x_authentik_username;
|
||||||
|
auth_request_set $authentik_email $upstream_http_x_authentik_email;
|
||||||
|
proxy_set_header X-authentik-username $authentik_username;
|
||||||
|
proxy_set_header X-authentik-email $authentik_email;
|
||||||
|
```
|
||||||
|
|
||||||
**OpenTelemetry:**
|
**OpenTelemetry:**
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
+10
-1
@@ -21,6 +21,7 @@ import { useAuthStore } from './store/auth';
|
|||||||
import { useServerStatusStore } from './store/serverStatus';
|
import { useServerStatusStore } from './store/serverStatus';
|
||||||
import { authSettingsApi, authApi } from './lib/api';
|
import { authSettingsApi, authApi } from './lib/api';
|
||||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||||
|
import { useTrustedLogin } from './hooks/useTrustedLogin';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ function App() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: publicSettings } = usePublicSettings();
|
const { data: publicSettings } = usePublicSettings();
|
||||||
|
|
||||||
|
const trustedLoginChecked = useTrustedLogin();
|
||||||
|
|
||||||
// Check if system needs initial setup (no users exist)
|
// Check if system needs initial setup (no users exist)
|
||||||
const { data: setupStatus } = useQuery({
|
const { data: setupStatus } = useQuery({
|
||||||
queryKey: ['setupStatus'],
|
queryKey: ['setupStatus'],
|
||||||
@@ -137,6 +140,8 @@ function App() {
|
|||||||
location.pathname === '/register' ||
|
location.pathname === '/register' ||
|
||||||
location.pathname === '/setup';
|
location.pathname === '/setup';
|
||||||
|
|
||||||
|
if (!trustedLoginChecked) return;
|
||||||
|
|
||||||
if (isAuthPage && isGuest && !isAuthenticated) {
|
if (isAuthPage && isGuest && !isAuthenticated) {
|
||||||
// Clear guest mode when navigating to auth pages
|
// Clear guest mode when navigating to auth pages
|
||||||
clearAuth();
|
clearAuth();
|
||||||
@@ -144,7 +149,7 @@ function App() {
|
|||||||
// Initialize guest mode for non-auth pages
|
// Initialize guest mode for non-auth pages
|
||||||
setGuestMode();
|
setGuestMode();
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth]);
|
}, [isAuthenticated, isGuest, location.pathname, setGuestMode, clearAuth, trustedLoginChecked]);
|
||||||
|
|
||||||
// Update browser title from site name in public settings
|
// Update browser title from site name in public settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,6 +195,10 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!trustedLoginChecked) {
|
||||||
|
return <RouteLoadingFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ChangePasswordDialog } from '@/components/users/ChangePasswordDialog';
|
||||||
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
|
||||||
|
/** Lets a signed-in user change their password, or set one to sign in without single sign-on. */
|
||||||
|
export function AccountPasswordCard() {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const isTrustedSession = useAuthStore((state) => state.isTrustedSession);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!user || !isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Password</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{isTrustedSession
|
||||||
|
? "You're signed in through single sign-on. Set a password to also sign in without it, such as on your local network."
|
||||||
|
: 'Change the password you sign in with.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsOpen(true)}>
|
||||||
|
<KeyRound size={16} aria-hidden="true" />
|
||||||
|
{isTrustedSession ? 'Set Password' : 'Change Password'}
|
||||||
|
</Button>
|
||||||
|
<ChangePasswordDialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
user={user}
|
||||||
|
currentPasswordOptional={isTrustedSession}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
onSuccess={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ChangePasswordDialog } from './ChangePasswordDialog';
|
||||||
|
|
||||||
|
const mutateAsync = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('@/hooks/useUsers', () => ({
|
||||||
|
useChangePassword: () => ({ mutateAsync, isError: false, isPending: false, error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const user = { id: 7, username: 'brad' };
|
||||||
|
|
||||||
|
async function submit(newPassword: string) {
|
||||||
|
await userEvent.type(screen.getByLabelText('New Password'), newPassword);
|
||||||
|
await userEvent.type(screen.getByLabelText('Confirm New Password'), newPassword);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Change Password' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChangePasswordDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mutateAsync.mockReset().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the current password by default', async () => {
|
||||||
|
render(<ChangePasswordDialog isOpen user={user} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
||||||
|
|
||||||
|
await submit('new-password');
|
||||||
|
|
||||||
|
expect(await screen.findByText('Current password is required')).toBeInTheDocument();
|
||||||
|
expect(mutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a single sign-on session set a password without the current one', async () => {
|
||||||
|
render(
|
||||||
|
<ChangePasswordDialog
|
||||||
|
isOpen
|
||||||
|
user={user}
|
||||||
|
currentPasswordOptional
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSuccess={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Current Password (optional)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await submit('new-password');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith({
|
||||||
|
id: 7,
|
||||||
|
passwordData: { currentPassword: undefined, newPassword: 'new-password' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
|
FormDescription,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
@@ -45,8 +46,10 @@ type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
|||||||
|
|
||||||
interface ChangePasswordDialogProps {
|
interface ChangePasswordDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
user: UserDetails;
|
user: Pick<UserDetails, 'id' | 'username'>;
|
||||||
isAdminReset?: boolean;
|
isAdminReset?: boolean;
|
||||||
|
/** Signed in through a trusted proxy, which vouches for the user in place of the current password */
|
||||||
|
currentPasswordOptional?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,7 @@ export function ChangePasswordDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
user,
|
user,
|
||||||
isAdminReset,
|
isAdminReset,
|
||||||
|
currentPasswordOptional,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: ChangePasswordDialogProps) {
|
}: ChangePasswordDialogProps) {
|
||||||
@@ -70,7 +74,7 @@ export function ChangePasswordDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: ChangePasswordFormValues) => {
|
const onSubmit = async (values: ChangePasswordFormValues) => {
|
||||||
if (!isAdminReset && !values.currentPassword) {
|
if (!isAdminReset && !currentPasswordOptional && !values.currentPassword) {
|
||||||
form.setError('currentPassword', { message: 'Current password is required' });
|
form.setError('currentPassword', { message: 'Current password is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,7 +82,8 @@ export function ChangePasswordDialog({
|
|||||||
await changePasswordMutation.mutateAsync({
|
await changePasswordMutation.mutateAsync({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
passwordData: {
|
passwordData: {
|
||||||
currentPassword: isAdminReset ? undefined : values.currentPassword,
|
currentPassword:
|
||||||
|
isAdminReset || !values.currentPassword ? undefined : values.currentPassword,
|
||||||
newPassword: values.newPassword,
|
newPassword: values.newPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,10 +118,17 @@ export function ChangePasswordDialog({
|
|||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Current Password</FormLabel>
|
<FormLabel>
|
||||||
|
Current Password{currentPasswordOptional ? ' (optional)' : ''}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="******************" {...field} />
|
<Input type="password" placeholder="******************" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{currentPasswordOptional ? (
|
||||||
|
<FormDescription>
|
||||||
|
Not needed while you're signed in through single sign-on.
|
||||||
|
</FormDescription>
|
||||||
|
) : null}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiClient } from '../lib/api/client';
|
|||||||
import { LoginCredentials, RegisterData } from '../types/auth';
|
import { LoginCredentials, RegisterData } from '../types/auth';
|
||||||
import { PublicSettings } from '../types/settings';
|
import { PublicSettings } from '../types/settings';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { SKIP_TRUSTED_LOGIN_KEY } from '../lib/trustedLogin';
|
||||||
|
|
||||||
/** Validate redirect path to prevent open redirects */
|
/** Validate redirect path to prevent open redirects */
|
||||||
function sanitizeRedirectPath(path: string | undefined): string {
|
function sanitizeRedirectPath(path: string | undefined): string {
|
||||||
@@ -64,6 +65,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
async (credentials: LoginCredentials, redirectPath?: string) => {
|
async (credentials: LoginCredentials, redirectPath?: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await authApi.login(credentials);
|
const result = await authApi.login(credentials);
|
||||||
|
sessionStorage.removeItem(SKIP_TRUSTED_LOGIN_KEY);
|
||||||
|
|
||||||
// Clear user-specific cached queries, but keep public settings (not user-specific)
|
// Clear user-specific cached queries, but keep public settings (not user-specific)
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
@@ -114,6 +116,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
|
||||||
try {
|
try {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTrustedLogin } from './useTrustedLogin';
|
||||||
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
|
||||||
|
|
||||||
|
const trustedLogin = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('@/lib/api', () => ({ authApi: { trustedLogin } }));
|
||||||
|
vi.mock('@/store/theme', () => ({
|
||||||
|
useThemeStore: { getState: () => ({ loadThemeFromServer: () => Promise.resolve() }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: 7,
|
||||||
|
username: 'brad',
|
||||||
|
email: 'brad@example.com',
|
||||||
|
role: 'user',
|
||||||
|
permissions: ['games.read'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(path: string) {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
return renderHook(() => ({ checked: useTrustedLogin(), location: useLocation() }), { wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTrustedLogin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs in the user the proxy vouches for and leaves the login page', async () => {
|
||||||
|
trustedLogin.mockResolvedValue({ user, tokens: { expiresIn: 3600 } });
|
||||||
|
|
||||||
|
const { result } = render('/login');
|
||||||
|
expect(result.current.checked).toBe(false);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.checked).toBe(true));
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isTrustedSession: true,
|
||||||
|
});
|
||||||
|
expect(result.current.location.pathname).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues signed out when there is no trusted identity', async () => {
|
||||||
|
trustedLogin.mockRejectedValue(new Error('403'));
|
||||||
|
|
||||||
|
const { result } = render('/games/abc');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.checked).toBe(true));
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(result.current.location.pathname).toBe('/games/abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not ask after an explicit logout or when already signed in', () => {
|
||||||
|
sessionStorage.setItem(SKIP_TRUSTED_LOGIN_KEY, '1');
|
||||||
|
expect(render('/').result.current.checked).toBe(true);
|
||||||
|
|
||||||
|
sessionStorage.clear();
|
||||||
|
useAuthStore.getState().setAuth(user);
|
||||||
|
expect(render('/').result.current.checked).toBe(true);
|
||||||
|
|
||||||
|
expect(trustedLogin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { SKIP_TRUSTED_LOGIN_KEY } from '@/lib/trustedLogin';
|
||||||
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
|
||||||
|
const AUTH_PAGES = ['/login', '/register', '/setup'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-out visitor may be vouched for by an authenticating reverse proxy.
|
||||||
|
* Asks once on load, before guest mode or a login redirect takes over, and
|
||||||
|
* returns whether that check is done.
|
||||||
|
*/
|
||||||
|
export function useTrustedLogin(): boolean {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
() =>
|
||||||
|
useAuthStore.getState().isAuthenticated ||
|
||||||
|
sessionStorage.getItem(SKIP_TRUSTED_LOGIN_KEY) !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checked) return;
|
||||||
|
let cancelled = false;
|
||||||
|
authApi
|
||||||
|
.trustedLogin()
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
useAuthStore.getState().setAuth(result.user, true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
|
||||||
|
if (AUTH_PAGES.includes(location.pathname)) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
logger.debug('[useTrustedLogin] No trusted proxy identity; continuing signed out');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setChecked(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Runs once on load; later navigation must not repeat it
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return checked;
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ export const authApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Sign in as the user an authenticating reverse proxy vouches for, if any */
|
||||||
|
trustedLogin: async (): Promise<LoginResponse> => {
|
||||||
|
const { data } = await apiClient.post<LoginResponse>('/auth/trusted-login');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
logout: async (): Promise<{ success: boolean; message: string }> => {
|
logout: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
const { data } = await apiClient.post('/auth/logout');
|
const { data } = await apiClient.post('/auth/logout');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Set for the rest of the tab's session after an explicit logout, so the app
|
||||||
|
* doesn't sign straight back in through the trusted proxy header.
|
||||||
|
*/
|
||||||
|
export const SKIP_TRUSTED_LOGIN_KEY = 'flashpoint-skip-trusted-login';
|
||||||
@@ -9,8 +9,10 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
isMaintenanceMode: boolean;
|
isMaintenanceMode: boolean;
|
||||||
|
/** Signed in through a trusted proxy header rather than a password */
|
||||||
|
isTrustedSession: boolean;
|
||||||
|
|
||||||
setAuth: (user: User) => void;
|
setAuth: (user: User, isTrustedSession?: boolean) => void;
|
||||||
setGuestMode: () => void;
|
setGuestMode: () => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
updateUser: (user: User) => void;
|
updateUser: (user: User) => void;
|
||||||
@@ -29,12 +31,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
|
isTrustedSession: false,
|
||||||
|
|
||||||
setAuth: (user: User) => {
|
setAuth: (user: User, isTrustedSession = false) => {
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
|
isTrustedSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user.role !== 'guest') {
|
if (user.role !== 'guest') {
|
||||||
@@ -66,6 +70,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
|
isTrustedSession: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -141,6 +146,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: state.user,
|
user: state.user,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
isGuest: state.isGuest,
|
isGuest: state.isGuest,
|
||||||
|
isTrustedSession: state.isTrustedSession,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Settings } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AccountPasswordCard } from '@/components/settings/AccountPasswordCard';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ruffleApi } from '@/lib/api';
|
import { ruffleApi } from '@/lib/api';
|
||||||
@@ -62,6 +63,8 @@ export function SettingsView() {
|
|||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountPasswordCard />
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">
|
<TabsList className="grid w-full grid-cols-4 border-2 border-primary/60 h-full">
|
||||||
|
|||||||
Reference in New Issue
Block a user