This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 3a533a5b1abe94f04c30184741a84e11fd327e70
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 10:05:54 2026 +0800

    bff: local auth with argon2 and cookie sessions
---
 apps/bff/package.json         |  1 +
 apps/bff/src/auth/local.ts    | 46 ++++++++++++++++++++++++
 apps/bff/src/auth/routes.ts   | 75 +++++++++++++++++++++++++++++++++++++++
 apps/bff/src/auth/sessions.ts | 82 +++++++++++++++++++++++++++++++++++++++++++
 apps/bff/src/cli/hash.ts      | 44 +++++++++++++++++++++++
 apps/bff/src/logger.ts        |  8 +++--
 apps/bff/src/server.ts        | 15 ++++++--
 package.json                  |  3 ++
 8 files changed, 269 insertions(+), 5 deletions(-)

diff --git a/apps/bff/package.json b/apps/bff/package.json
index df4e835..de0a505 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -7,6 +7,7 @@
   "scripts": {
     "dev": "tsx watch src/server.ts",
     "build": "esbuild src/server.ts --bundle --platform=node --format=esm 
--target=node20 --outfile=dist/server.js --packages=external",
+    "cli:hash": "tsx src/cli/hash.ts",
     "type-check": "tsc --noEmit",
     "lint": "eslint . --ext .ts,.cjs,.mjs --fix --ignore-path 
../../.gitignore",
     "test:unit": "vitest --root src/"
diff --git a/apps/bff/src/auth/local.ts b/apps/bff/src/auth/local.ts
new file mode 100644
index 0000000..03f934b
--- /dev/null
+++ b/apps/bff/src/auth/local.ts
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import argon2 from 'argon2';
+import type { HorizonConfig } from '../config/schema.js';
+
+// Pre-computed dummy hash used to keep the timing path identical when a
+// username does not exist. This blunts username-enumeration attacks.
+const DUMMY_HASH =
+  
'$argon2id$v=19$m=65536,t=3,p=4$dummysaltdummysalt$dummyhashdummyhashdummyhashdummyhash';
+
+export interface VerifiedUser {
+  username: string;
+  roles: string[];
+}
+
+export async function verifyLocalCredentials(
+  cfg: HorizonConfig,
+  username: string,
+  password: string,
+): Promise<VerifiedUser | null> {
+  const user = cfg.auth.local.users.find((u) => u.username === username);
+  const hash = user?.passwordHash ?? DUMMY_HASH;
+  let ok = false;
+  try {
+    ok = await argon2.verify(hash, password);
+  } catch {
+    ok = false;
+  }
+  if (!user || !ok) return null;
+  return { username: user.username, roles: user.roles };
+}
diff --git a/apps/bff/src/auth/routes.ts b/apps/bff/src/auth/routes.ts
new file mode 100644
index 0000000..363f449
--- /dev/null
+++ b/apps/bff/src/auth/routes.ts
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import { badRequest, unauthorized } from '../errors.js';
+import type { ConfigSource } from '../config/loader.js';
+import { verifyLocalCredentials } from './local.js';
+import type { SessionStore } from './sessions.js';
+
+const loginBodySchema = z.object({
+  username: z.string().min(1),
+  password: z.string().min(1),
+});
+
+export function registerAuthRoutes(
+  app: FastifyInstance,
+  source: ConfigSource,
+  sessions: SessionStore,
+): void {
+  const cookieName = () => source.current.session.cookieName;
+  const cookieSecure = () => source.current.session.cookieSecure;
+  const ttlMs = () => source.current.session.ttlMinutes * 60_000;
+
+  app.post('/api/auth/login', async (req, reply) => {
+    const parsed = loginBodySchema.safeParse(req.body);
+    if (!parsed.success) throw badRequest('invalid login body', 
parsed.error.flatten());
+
+    const verified = await verifyLocalCredentials(
+      source.current,
+      parsed.data.username,
+      parsed.data.password,
+    );
+    if (!verified) throw unauthorized('invalid credentials');
+
+    const session = sessions.create(verified.username, verified.roles);
+    reply.setCookie(cookieName(), session.sid, {
+      httpOnly: true,
+      sameSite: 'strict',
+      secure: cookieSecure(),
+      path: '/',
+      maxAge: Math.floor(ttlMs() / 1000),
+    });
+    return { username: session.username, roles: session.roles };
+  });
+
+  app.post('/api/auth/logout', async (req, reply) => {
+    const sid = req.cookies[cookieName()];
+    if (sid) sessions.destroy(sid);
+    reply.clearCookie(cookieName(), { path: '/' });
+    return { status: 'ok' };
+  });
+
+  app.get('/api/auth/me', async (req) => {
+    const sid = req.cookies[cookieName()];
+    if (!sid) throw unauthorized();
+    const session = sessions.touch(sid);
+    if (!session) throw unauthorized();
+    return { username: session.username, roles: session.roles };
+  });
+}
diff --git a/apps/bff/src/auth/sessions.ts b/apps/bff/src/auth/sessions.ts
new file mode 100644
index 0000000..6c19bae
--- /dev/null
+++ b/apps/bff/src/auth/sessions.ts
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { randomBytes } from 'node:crypto';
+
+export interface Session {
+  sid: string;
+  username: string;
+  roles: string[];
+  createdAt: number;
+  lastSeenAt: number;
+}
+
+export interface SessionStoreOptions {
+  ttlMinutes: number;
+  reapIntervalMs?: number;
+}
+
+export class SessionStore {
+  private readonly sessions = new Map<string, Session>();
+  private readonly ttlMs: number;
+  private readonly reaper: NodeJS.Timeout;
+
+  constructor(opts: SessionStoreOptions) {
+    this.ttlMs = opts.ttlMinutes * 60_000;
+    this.reaper = setInterval(() => this.reap(), opts.reapIntervalMs ?? 
60_000);
+    this.reaper.unref?.();
+  }
+
+  create(username: string, roles: string[]): Session {
+    const sid = randomBytes(32).toString('base64url');
+    const now = Date.now();
+    const session: Session = { sid, username, roles, createdAt: now, 
lastSeenAt: now };
+    this.sessions.set(sid, session);
+    return session;
+  }
+
+  touch(sid: string): Session | undefined {
+    const session = this.sessions.get(sid);
+    if (!session) return undefined;
+    if (Date.now() - session.lastSeenAt > this.ttlMs) {
+      this.sessions.delete(sid);
+      return undefined;
+    }
+    session.lastSeenAt = Date.now();
+    return session;
+  }
+
+  destroy(sid: string): void {
+    this.sessions.delete(sid);
+  }
+
+  size(): number {
+    return this.sessions.size;
+  }
+
+  private reap(): void {
+    const now = Date.now();
+    for (const [sid, s] of this.sessions) {
+      if (now - s.lastSeenAt > this.ttlMs) this.sessions.delete(sid);
+    }
+  }
+
+  async close(): Promise<void> {
+    clearInterval(this.reaper);
+    this.sessions.clear();
+  }
+}
diff --git a/apps/bff/src/cli/hash.ts b/apps/bff/src/cli/hash.ts
new file mode 100644
index 0000000..5e1f10e
--- /dev/null
+++ b/apps/bff/src/cli/hash.ts
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { stdin } from 'node:process';
+import argon2 from 'argon2';
+
+async function readPassword(): Promise<string> {
+  return new Promise((resolve) => {
+    let buf = '';
+    stdin.setEncoding('utf8');
+    stdin.on('data', (chunk) => (buf += chunk));
+    stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
+  });
+}
+
+async function main(): Promise<void> {
+  const arg = process.argv[2];
+  const password = arg ?? (await readPassword());
+  if (!password) {
+    process.stderr.write('usage: hash <password> | echo <password> | hash\n');
+    process.exit(1);
+  }
+  const hash = await argon2.hash(password, { type: argon2.argon2id });
+  process.stdout.write(hash + '\n');
+}
+
+main().catch((err) => {
+  process.stderr.write(`hash failed: ${err instanceof Error ? err.message : 
String(err)}\n`);
+  process.exit(1);
+});
diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts
index 702478e..335beed 100644
--- a/apps/bff/src/logger.ts
+++ b/apps/bff/src/logger.ts
@@ -15,11 +15,11 @@
  * limitations under the License.
  */
 
-import pino from 'pino';
+import pino, { type LoggerOptions } from 'pino';
 
 const isDev = process.env.NODE_ENV !== 'production';
 
-export const logger = pino({
+export const loggerOptions: LoggerOptions = {
   level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
   ...(isDev
     ? {
@@ -33,4 +33,6 @@ export const logger = pino({
         },
       }
     : {}),
-});
+};
+
+export const logger = pino(loggerOptions);
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 7706acb..75468b1 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -16,9 +16,12 @@
  */
 
 import Fastify from 'fastify';
+import cookie from '@fastify/cookie';
+import { registerAuthRoutes } from './auth/routes.js';
+import { SessionStore } from './auth/sessions.js';
 import { loadConfig, type ConfigSource } from './config/loader.js';
 import { HttpError } from './errors.js';
-import { logger } from './logger.js';
+import { logger, loggerOptions } from './logger.js';
 
 const configPath = process.env.HORIZON_CONFIG ?? './horizon.yaml';
 
@@ -26,7 +29,7 @@ const source: ConfigSource = loadConfig(configPath);
 logger.info({ configPath: source.path }, 'config loaded');
 source.onChange((cfg) => logger.info({ users: cfg.auth.local.users.length }, 
'config reloaded'));
 
-const app = Fastify({ loggerInstance: logger });
+const app = Fastify({ logger: loggerOptions });
 
 app.setErrorHandler((err, _req, reply) => {
   if (err instanceof HttpError) {
@@ -37,9 +40,16 @@ app.setErrorHandler((err, _req, reply) => {
   return reply.status(500).send({ code: 'internal_error', message });
 });
 
+const sessions = new SessionStore({ ttlMinutes: 
source.current.session.ttlMinutes });
+
+await app.register(cookie);
+
+registerAuthRoutes(app, source, sessions);
+
 app.get('/api/health', async () => ({
   status: 'ok',
   version: process.env.HORIZON_VERSION ?? '0.1.0',
+  sessions: sessions.size(),
 }));
 
 const { host, port } = source.current.server;
@@ -54,6 +64,7 @@ app.listen({ host, port }).then(
 async function shutdown(signal: string) {
   logger.info({ signal }, 'shutting down');
   await app.close();
+  await sessions.close();
   await source.close();
   process.exit(0);
 }
diff --git a/package.json b/package.json
index a507d63..5c1d29f 100644
--- a/package.json
+++ b/package.json
@@ -22,5 +22,8 @@
   },
   "devDependencies": {
     "typescript": "~5.6.3"
+  },
+  "pnpm": {
+    "onlyBuiltDependencies": ["argon2", "esbuild", "@parcel/watcher", 
"vue-demi"]
   }
 }

Reply via email to