Add expiring panel auth sessions
This commit is contained in:
@@ -5,6 +5,7 @@ import request from 'supertest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { UpdateSystemInput } from '../src/shared/contracts';
|
||||
import { createApp } from './app';
|
||||
import { AuthService } from './lib/auth';
|
||||
import type { RuntimeSnapshot } from './lib/config';
|
||||
import type { RuntimeController } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
@@ -47,9 +48,31 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('panel api', () => {
|
||||
it('rejects protected api access without a bearer token', async () => {
|
||||
const app = await createTestApp();
|
||||
const response = await request(app).get('/api/state');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toMatch(/authorization required/i);
|
||||
});
|
||||
|
||||
it('logs in and returns an expiring panel token', async () => {
|
||||
const app = await createTestApp();
|
||||
const response = await request(app).post('/api/auth/login').send({
|
||||
login: 'admin',
|
||||
password: 'proxy-ui-demo',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.token).toMatch(/\./);
|
||||
expect(response.body.ttlMs).toBe(24 * 60 * 60 * 1000);
|
||||
expect(typeof response.body.expiresAt).toBe('string');
|
||||
});
|
||||
|
||||
it('rejects user creation against a non-assignable service', async () => {
|
||||
const app = await createTestApp();
|
||||
const response = await request(app).post('/api/users').send({
|
||||
const token = await authorize(app);
|
||||
const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({
|
||||
username: 'bad-admin-user',
|
||||
password: 'secret123',
|
||||
serviceId: 'admin',
|
||||
@@ -62,15 +85,16 @@ describe('panel api', () => {
|
||||
|
||||
it('pauses and deletes a user through the api', async () => {
|
||||
const app = await createTestApp();
|
||||
const initial = await request(app).get('/api/state');
|
||||
const token = await authorize(app);
|
||||
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
|
||||
const userId = initial.body.userRecords[0].id;
|
||||
const username = initial.body.userRecords[0].username;
|
||||
|
||||
const paused = await request(app).post(`/api/users/${userId}/pause`);
|
||||
const paused = await request(app).post(`/api/users/${userId}/pause`).set('Authorization', `Bearer ${token}`);
|
||||
expect(paused.status).toBe(200);
|
||||
expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true);
|
||||
|
||||
const removed = await request(app).delete(`/api/users/${userId}`);
|
||||
const removed = await request(app).delete(`/api/users/${userId}`).set('Authorization', `Bearer ${token}`);
|
||||
expect(removed.status).toBe(200);
|
||||
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
|
||||
false,
|
||||
@@ -79,12 +103,13 @@ describe('panel api', () => {
|
||||
|
||||
it('rejects system updates when two services reuse the same port', async () => {
|
||||
const app = await createTestApp();
|
||||
const initial = await request(app).get('/api/state');
|
||||
const token = await authorize(app);
|
||||
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
|
||||
const system = createSystemPayload(initial.body);
|
||||
|
||||
system.services[1].port = system.services[0].port;
|
||||
|
||||
const response = await request(app).put('/api/system').send(system);
|
||||
const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toMatch(/cannot share port/i);
|
||||
@@ -92,14 +117,15 @@ describe('panel api', () => {
|
||||
|
||||
it('rejects system updates that strand existing users on a disabled service', async () => {
|
||||
const app = await createTestApp();
|
||||
const initial = await request(app).get('/api/state');
|
||||
const token = await authorize(app);
|
||||
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
|
||||
const system = createSystemPayload(initial.body);
|
||||
|
||||
system.services = system.services.map((service) =>
|
||||
service.id === 'socks-main' ? { ...service, enabled: false } : service,
|
||||
);
|
||||
|
||||
const response = await request(app).put('/api/system').send(system);
|
||||
const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toMatch(/enabled assignable service/i);
|
||||
@@ -108,7 +134,8 @@ describe('panel api', () => {
|
||||
|
||||
it('updates system settings and regenerates the rendered config', async () => {
|
||||
const app = await createTestApp();
|
||||
const initial = await request(app).get('/api/state');
|
||||
const token = await authorize(app);
|
||||
const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`);
|
||||
const system = createSystemPayload(initial.body);
|
||||
|
||||
system.publicHost = 'ops-gateway.example.net';
|
||||
@@ -116,7 +143,7 @@ describe('panel api', () => {
|
||||
service.id === 'socks-main' ? { ...service, port: 1180 } : service,
|
||||
);
|
||||
|
||||
const response = await request(app).put('/api/system').send(system);
|
||||
const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.system.publicHost).toBe('ops-gateway.example.net');
|
||||
@@ -134,11 +161,17 @@ async function createTestApp() {
|
||||
|
||||
const runtime = new FakeRuntime();
|
||||
const store = new StateStore(path.join(dir, 'state', 'panel-state.json'));
|
||||
const auth = new AuthService({
|
||||
login: 'admin',
|
||||
password: 'proxy-ui-demo',
|
||||
ttlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
return createApp({
|
||||
store,
|
||||
runtime,
|
||||
runtimeRootDir: dir,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,3 +179,12 @@ function createSystemPayload(body: { system: Record<string, unknown> }): UpdateS
|
||||
const { previewConfig: _previewConfig, ...system } = body.system;
|
||||
return structuredClone(system) as unknown as UpdateSystemInput;
|
||||
}
|
||||
|
||||
async function authorize(app: Awaited<ReturnType<typeof createTestApp>>) {
|
||||
const response = await request(app).post('/api/auth/login').send({
|
||||
login: 'admin',
|
||||
password: 'proxy-ui-demo',
|
||||
});
|
||||
|
||||
return response.body.token as string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ControlPlaneState, CreateUserInput, UpdateSystemInput } from '../src/shared/contracts';
|
||||
import type {
|
||||
ControlPlaneState,
|
||||
CreateUserInput,
|
||||
PanelLoginInput,
|
||||
UpdateSystemInput,
|
||||
} from '../src/shared/contracts';
|
||||
import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation';
|
||||
import {
|
||||
buildRuntimePaths,
|
||||
@@ -10,6 +15,7 @@ import {
|
||||
render3proxyConfig,
|
||||
type RuntimePaths,
|
||||
} from './lib/config';
|
||||
import { AuthService } from './lib/auth';
|
||||
import type { RuntimeController } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
|
||||
@@ -17,9 +23,10 @@ export interface AppServices {
|
||||
store: StateStore;
|
||||
runtime: RuntimeController;
|
||||
runtimeRootDir: string;
|
||||
auth: AuthService;
|
||||
}
|
||||
|
||||
export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
||||
export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) {
|
||||
const app = express();
|
||||
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||
const distDir = path.resolve('dist');
|
||||
@@ -27,6 +34,40 @@ export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
||||
app.use(express.json());
|
||||
app.use(express.static(distDir));
|
||||
|
||||
app.post('/api/auth/login', (request, response) => {
|
||||
const input = request.body as Partial<PanelLoginInput>;
|
||||
|
||||
try {
|
||||
const payload = auth.login(input.login?.trim() ?? '', input.password ?? '');
|
||||
response.json(payload);
|
||||
} catch (error) {
|
||||
response.status(401).json({
|
||||
error: error instanceof Error ? error.message : 'Wrong panel credentials.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', (request, response, next) => {
|
||||
if (request.path === '/auth/login' || request.path === '/health') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = auth.extractBearerToken(request);
|
||||
|
||||
if (!token) {
|
||||
response.status(401).json(auth.unauthorizedError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.verify(token)) {
|
||||
response.status(401).json(auth.invalidTokenError());
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/api/health', async (_request, response) => {
|
||||
const state = await store.read();
|
||||
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createApp } from './app';
|
||||
import { AuthService } from './lib/auth';
|
||||
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
||||
import { ThreeProxyManager } from './lib/runtime';
|
||||
import { StateStore } from './lib/store';
|
||||
@@ -10,17 +11,30 @@ const runtimeRootDir = path.resolve(process.env.RUNTIME_DIR ?? 'runtime');
|
||||
const statePath = path.join(runtimeRootDir, 'state', 'panel-state.json');
|
||||
const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy');
|
||||
const autoStart = process.env.AUTO_START_3PROXY === 'true';
|
||||
const authLogin = process.env.PANEL_AUTH_LOGIN ?? 'admin';
|
||||
const authPassword = process.env.PANEL_AUTH_PASSWORD ?? 'proxy-ui-demo';
|
||||
const sessionTtlHours = Number(process.env.PANEL_SESSION_TTL_HOURS ?? '24');
|
||||
const sessionTtlMs = Number.isFinite(sessionTtlHours) && sessionTtlHours > 0
|
||||
? sessionTtlHours * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
const authSecret = process.env.PANEL_TOKEN_SECRET;
|
||||
|
||||
const store = new StateStore(statePath);
|
||||
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||
const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart);
|
||||
const auth = new AuthService({
|
||||
login: authLogin,
|
||||
password: authPassword,
|
||||
ttlMs: sessionTtlMs,
|
||||
secret: authSecret,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const initialState = await store.read();
|
||||
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
||||
await runtime.initialize();
|
||||
const app = createApp({ store, runtime, runtimeRootDir });
|
||||
const app = createApp({ store, runtime, runtimeRootDir, auth });
|
||||
app.listen(port, () => {
|
||||
console.log(`Panel server listening on http://0.0.0.0:${port}`);
|
||||
});
|
||||
|
||||
112
server/lib/auth.ts
Normal file
112
server/lib/auth.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export interface AuthConfig {
|
||||
login: string;
|
||||
password: string;
|
||||
ttlMs: number;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(private readonly config: AuthConfig) {
|
||||
this.secret = config.secret ?? `${config.login}:${config.password}`;
|
||||
}
|
||||
|
||||
login(login: string, password: string) {
|
||||
if (!safeEqual(login, this.config.login) || !safeEqual(password, this.config.password)) {
|
||||
throw new Error('Wrong panel credentials.');
|
||||
}
|
||||
|
||||
const payload: TokenPayload = {
|
||||
sub: this.config.login,
|
||||
exp: Date.now() + this.config.ttlMs,
|
||||
};
|
||||
|
||||
const encoded = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = this.sign(encoded);
|
||||
|
||||
return {
|
||||
token: `${encoded}.${signature}`,
|
||||
expiresAt: new Date(payload.exp).toISOString(),
|
||||
ttlMs: this.config.ttlMs,
|
||||
};
|
||||
}
|
||||
|
||||
verify(token: string): TokenPayload | null {
|
||||
const [encoded, signature] = token.split('.');
|
||||
|
||||
if (!encoded || !signature || !safeEqual(this.sign(encoded), signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(encoded)) as TokenPayload;
|
||||
|
||||
if (payload.sub !== this.config.login || !Number.isFinite(payload.exp) || payload.exp <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extractBearerToken(request: Request): string | null {
|
||||
const header = request.header('authorization');
|
||||
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
unauthorizedError() {
|
||||
return { error: 'Panel authorization required.' };
|
||||
}
|
||||
|
||||
invalidTokenError() {
|
||||
return { error: 'Panel session is missing or expired.' };
|
||||
}
|
||||
|
||||
private sign(value: string): string {
|
||||
return hmac(value, this.secret);
|
||||
}
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
function hmac(value: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(value).digest('base64url');
|
||||
}
|
||||
Reference in New Issue
Block a user