Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ limits:
- "::1"
- "10.10.10.1"
- "::ffff:10.10.10.1"
admin:
rateLimits:
- description: 30 admin requests/min
period: 60000
rate: 30
loginRateLimits:
- description: 10 login attempts per 15 minutes
period: 900000
rate: 10
ipWhitelist:
- "::1"
- "10.10.10.1"
- "::ffff:10.10.10.1"
connection:
rateLimits:
- period: 1000
Expand Down Expand Up @@ -229,3 +242,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
}
13 changes: 13 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,17 @@ export interface AdmissionCheckLimits {
ipWhitelist?: string[]
}

export interface AdminLimits {
rateLimits?: RateLimit[]
loginRateLimits?: RateLimit[]
ipWhitelist?: string[]
}

export interface Limits {
rateLimiter?: RateLimiterSettings
invoice?: InvoiceLimits
admissionCheck?: AdmissionCheckLimits
admin?: AdminLimits
connection?: ConnectionLimits
client?: ClientLimits
event?: EventLimits
Expand Down Expand Up @@ -266,6 +273,11 @@ export interface Nip05Settings {
domainBlacklist?: string[]
}

export interface AdminSettings {
enabled: boolean
passwordHash?: string
sessionTtlSeconds?: number
}
export interface WoTSettings {
enabled: boolean
/**
Expand All @@ -287,6 +299,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 {
buildAdminSessionCookieHeader,
createAdminSessionToken,
getAdminSessionTokenFromRequest,
isValidAdminSessionToken,
parseAdminSessionToken,
resolveAdminSessionTtlSeconds,
} from '../utils/admin-session'
import { validateSchema } from '../utils/validation'

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({ error: 'Invalid request' })
return
}
Comment thread
Ferryx349 marked this conversation as resolved.

if (!this.verifyPassword(validation.value.password)) {
response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' })
return
}
Comment thread
Ferryx349 marked this conversation as resolved.

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

response
.status(200)
.setHeader('content-type', 'application/json')
.setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds))
.send({ authenticated: true, expiresAt })
}
Comment thread
Ferryx349 marked this conversation as resolved.

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(health)
}
}
15 changes: 15 additions & 0 deletions src/controllers/admin/get-session-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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> {
response.status(200).setHeader('content-type', 'application/json').send({
authenticated: true,
expiresAt: this.authProvider.getSessionExpiresAt(request),
})
}
Comment thread
Ferryx349 marked this conversation as resolved.
}
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())
}
27 changes: 27 additions & 0 deletions src/handlers/request-handlers/admin-rate-limit-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextFunction, Request, Response } from 'express'

import { createSettings } from '../../factories/settings-factory'
import { rateLimiterFactory } from '../../factories/rate-limiter-factory'
import { isAdminRateLimited } from '../../utils/admin-rate-limit'

const sendTooManyRequests = (response: Response) => {
response.status(429).setHeader('content-type', 'application/json').send({ error: 'Too many requests' })
}
Comment thread
Ferryx349 marked this conversation as resolved.

export const adminLoginRateLimitMiddleware = async (request: Request, response: Response, next: NextFunction) => {
if (await isAdminRateLimited(request, createSettings(), rateLimiterFactory, 'login')) {
sendTooManyRequests(response)
return
}

next()
}

export const adminRateLimitMiddleware = async (request: Request, response: Response, next: NextFunction) => {
if (await isAdminRateLimited(request, createSettings(), rateLimiterFactory, 'admin')) {
sendTooManyRequests(response)
return
}

next()
}
43 changes: 43 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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 {
adminLoginRateLimitMiddleware,
adminRateLimitMiddleware,
} from '../../handlers/request-handlers/admin-rate-limit-middleware'
import { rateLimiterMiddleware } from '../../handlers/request-handlers/rate-limiter-middleware'
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({ error: 'Unauthorized' })
return
}

next()
}
Comment thread
Ferryx349 marked this conversation as resolved.
Outdated

router.use(requireAdminEnabled)
router.use(rateLimiterMiddleware)

router.post('/login', adminLoginRateLimitMiddleware, json(), withController(createPostAdminLoginController))
router.get('/session', adminRateLimitMiddleware, 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.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
router.get('/health', adminRateLimitMiddleware, 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.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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 }
}
}
Loading
Loading