Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/admin-console-phase-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat: add disabled-by-default admin API with password auth, session, and health endpoints
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,6 @@ limits:
- "::1"
- "10.10.10.1"
- "::ffff:10.10.10.1"
admin:
enabled: false
sessionTtlSeconds: 86400
7 changes: 7 additions & 0 deletions src/@types/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Request, Response } from 'express'

export interface IAdminAuthProvider {
handleLogin(request: Request, response: Response): Promise<void>
isRequestAuthenticated(request: Request): boolean
getSessionExpiresAt(request: Request): number | undefined
}
6 changes: 6 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ export interface Nip05Settings {
domainBlacklist?: string[]
}

export interface AdminSettings {
enabled: boolean
passwordHash?: string
sessionTtlSeconds?: number
}
export interface WoTSettings {
enabled: boolean
/**
Expand All @@ -287,6 +292,7 @@ export interface WoTSettings {

export interface Settings {
info: Info
admin?: AdminSettings
payments?: Payments
paymentsProcessors?: PaymentsProcessors
network: Network
Expand Down
71 changes: 71 additions & 0 deletions src/admin/password-admin-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Request, Response } from 'express'

import { IAdminAuthProvider } from '../@types/admin'
import { Settings } from '../@types/settings'
import { adminLoginBodySchema } from '../schemas/admin-login-schema'
import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password'
import {
createAdminSessionToken,
getAdminSessionTokenFromRequest,
isValidAdminSessionToken,
parseAdminSessionToken,
} from '../utils/admin-session'
import { validateSchema } from '../utils/validation'

const DEFAULT_SESSION_TTL_SECONDS = 86400

export class PasswordAdminAuthProvider implements IAdminAuthProvider {
public constructor(private readonly settings: () => Settings) {}

public async handleLogin(request: Request, response: Response): Promise<void> {
const validation = validateSchema(adminLoginBodySchema)(request.body)
if (validation.error) {
response.status(400).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Invalid request' }))
return
}

if (!this.verifyPassword(validation.value.password)) {
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
return
}

const currentSettings = this.settings()
const ttl = currentSettings.admin?.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS
const expiresAt = Math.floor(Date.now() / 1000) + ttl
const token = createAdminSessionToken(expiresAt)

response
.status(200)
.setHeader('content-type', 'application/json')
.setHeader('Set-Cookie', `admin_session=${token}; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=${ttl}`)
.send(JSON.stringify({ authenticated: true, expiresAt }))
}

public isRequestAuthenticated(request: Request): boolean {
const token = this.getToken(request)
return token ? isValidAdminSessionToken(token) : false
}

public getSessionExpiresAt(request: Request): number | undefined {
const token = this.getToken(request)
return token ? parseAdminSessionToken(token)?.expiresAt : undefined
}

private getToken(request: Request): string | undefined {
return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie)
}

private verifyPassword(password: string): boolean {
const envPassword = process.env.ADMIN_PASSWORD
if (typeof envPassword === 'string' && envPassword.length > 0) {
return verifyPlaintextPassword(password, envPassword)
}

const passwordHash = this.settings().admin?.passwordHash
if (!passwordHash) {
return false
}

return verifyAdminPasswordHash(password, passwordHash)
}
}
11 changes: 11 additions & 0 deletions src/controllers/admin/get-health-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { collectAdminHealthSnapshot } from '../../utils/admin-health'

export class GetAdminHealthController implements IController {
public async handleRequest(_request: Request, response: Response): Promise<void> {
const health = await collectAdminHealthSnapshot()
response.status(200).setHeader('content-type', 'application/json').send(JSON.stringify(health))
}
}
22 changes: 22 additions & 0 deletions src/controllers/admin/get-session-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Request, Response } from 'express'

import { IAdminAuthProvider } from '../../@types/admin'
import { IController } from '../../@types/controllers'

export class GetAdminSessionController implements IController {
public constructor(private readonly authProvider: IAdminAuthProvider) {}

public async handleRequest(request: Request, response: Response): Promise<void> {
if (!this.authProvider.isRequestAuthenticated(request)) {
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
return
}

response.status(200).setHeader('content-type', 'application/json').send(
JSON.stringify({
authenticated: true,
expiresAt: this.authProvider.getSessionExpiresAt(request),
}),
)
}
}
12 changes: 12 additions & 0 deletions src/controllers/admin/post-login-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Request, Response } from 'express'

import { IAdminAuthProvider } from '../../@types/admin'
import { IController } from '../../@types/controllers'

export class PostAdminLoginController implements IController {
public constructor(private readonly authProvider: IAdminAuthProvider) {}

public async handleRequest(request: Request, response: Response): Promise<void> {
await this.authProvider.handleLogin(request, response)
}
}
7 changes: 7 additions & 0 deletions src/factories/admin-auth-provider-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PasswordAdminAuthProvider } from '../admin/password-admin-auth-provider'
import { IAdminAuthProvider } from '../@types/admin'
import { createSettings } from './settings-factory'

export const createAdminAuthProvider = (): IAdminAuthProvider => {
return new PasswordAdminAuthProvider(createSettings)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { GetAdminHealthController } from '../../controllers/admin/get-health-controller'
import { IController } from '../../@types/controllers'

export const createGetAdminHealthController = (): IController => {
return new GetAdminHealthController()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { GetAdminSessionController } from '../../controllers/admin/get-session-controller'
import { IController } from '../../@types/controllers'
import { createAdminAuthProvider } from '../admin-auth-provider-factory'

export const createGetAdminSessionController = (): IController => {
return new GetAdminSessionController(createAdminAuthProvider())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PostAdminLoginController } from '../../controllers/admin/post-login-controller'
import { IController } from '../../@types/controllers'
import { createAdminAuthProvider } from '../admin-auth-provider-factory'

export const createPostAdminLoginController = (): IController => {
return new PostAdminLoginController(createAdminAuthProvider())
}
37 changes: 37 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { json, NextFunction, Request, Response, Router } from 'express'

import { createAdminAuthProvider } from '../../factories/admin-auth-provider-factory'
import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory'
import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory'
import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory'
import { createSettings } from '../../factories/settings-factory'
import { withController } from '../../handlers/request-handlers/with-controller-request-handler'

const router: Router = Router()

const requireAdminEnabled = (_request: Request, response: Response, next: NextFunction) => {
const settings = createSettings()
if (!settings.admin?.enabled) {
response.status(404).setHeader('content-type', 'text/plain').send('Not Found')
return
}

next()
}

const requireAdminAuth = (request: Request, response: Response, next: NextFunction) => {
if (!createAdminAuthProvider().isRequestAuthenticated(request)) {
response.status(401).setHeader('content-type', 'application/json').send(JSON.stringify({ error: 'Unauthorized' }))
return
}

next()
}

router.use(requireAdminEnabled)

router.post('/login', json(), withController(createPostAdminLoginController))
router.get('/session', requireAdminAuth, withController(createGetAdminSessionController))

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
router.get('/health', requireAdminAuth, withController(createGetAdminHealthController))

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

export default router
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express, { Router } from 'express'

import { nodeinfo21Handler, nodeinfoHandler } from '../handlers/request-handlers/nodeinfo-handler'
import adminRouter from './admin'
import admissionRouter from './admissions'
import callbacksRouter from './callbacks'
import { getHealthRequestHandler } from '../handlers/request-handlers/get-health-request-handler'
Expand Down Expand Up @@ -28,6 +29,7 @@ router.get('/.well-known/nodeinfo', nodeinfoHandler)
router.get('/nodeinfo/2.1', nodeinfo21Handler)
router.get('/nodeinfo/2.0', nodeinfo21Handler)

router.use('/admin', adminRouter)
router.use('/invoices', rateLimiterMiddleware, invoiceRouter)
router.use('/admissions', rateLimiterMiddleware, admissionRouter)
router.use('/callbacks', rateLimiterMiddleware, callbacksRouter)
Expand Down
7 changes: 7 additions & 0 deletions src/schemas/admin-login-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod'

export const adminLoginBodySchema = z
.object({
password: z.string().min(1),
})
.strict()
55 changes: 55 additions & 0 deletions src/utils/admin-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getCacheClient } from '../cache/client'
import { getMasterDbClient } from '../database/client'

export interface AdminDependencyHealth {
ok: boolean
}

export interface AdminHealthSnapshot {
status: 'ok' | 'degraded'
uptimeSeconds: number
worker: {
type: string
index?: string
}
database: AdminDependencyHealth
redis: AdminDependencyHealth
}

export const collectAdminHealthSnapshot = async (): Promise<AdminHealthSnapshot> => {
const database = await pingDatabase()
const redis = await pingRedis()

return {
status: database.ok && redis.ok ? 'ok' : 'degraded',
uptimeSeconds: Math.floor(process.uptime()),
worker: {
type: process.env.WORKER_TYPE ?? 'primary',
...(process.env.WORKER_INDEX ? { index: process.env.WORKER_INDEX } : {}),
},
database,
redis,
}
}

const pingDatabase = async (): Promise<AdminDependencyHealth> => {
try {
await getMasterDbClient().raw('SELECT 1')
return { ok: true }
} catch {
return { ok: false }
}
}

const pingRedis = async (): Promise<AdminDependencyHealth> => {
try {
const client = getCacheClient()
if (!client.isOpen) {
await client.connect()
}
const pong = await client.ping()
return { ok: pong === 'PONG' }
} catch {
return { ok: false }
}
}
38 changes: 38 additions & 0 deletions src/utils/admin-password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'

const SCRYPT_PREFIX = 'scrypt'

export const hashAdminPassword = (password: string): string => {
const salt = randomBytes(16)
const hash = scryptSync(password, salt, 64)
return `${SCRYPT_PREFIX}:${salt.toString('base64')}:${hash.toString('base64')}`
}

export const verifyAdminPasswordHash = (password: string, storedHash: string): boolean => {
const parts = storedHash.split(':')
if (parts.length !== 3 || parts[0] !== SCRYPT_PREFIX) {
return false
}

const [, saltB64, hashB64] = parts
const salt = Buffer.from(saltB64, 'base64')
const expected = Buffer.from(hashB64, 'base64')
const actual = scryptSync(password, salt, expected.length)

if (expected.length !== actual.length) {
return false
}

return timingSafeEqual(expected, actual)
}

export const verifyPlaintextPassword = (password: string, expectedPassword: string): boolean => {
const expected = Buffer.from(expectedPassword, 'utf8')
const actual = Buffer.from(password, 'utf8')

if (expected.length !== actual.length) {
return false
}

return timingSafeEqual(expected, actual)
}
Loading
Loading