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"] } }
