feat(auth): sign in users vouched for by a trusted proxy header
With TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES set, POST /api/auth/trusted-login signs in the user an authenticating reverse proxy (such as Authentik forward auth) names in that header, believing it only on connections from the proxy addresses. Users are matched by username, so an existing account is adopted; otherwise one is created, the first as admin as with registration. New accounts get a random password, and a user the proxy vouches for can set their own password without the current one, to log in when reached without the proxy.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 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 { 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' });
|
||||
|
||||
@@ -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<UserTokenData & { email: string }>(
|
||||
`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<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> {
|
||||
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
|
||||
new Date().toISOString(),
|
||||
|
||||
@@ -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<string, string> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user