Skip to content
Open
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
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 Down Expand Up @@ -295,6 +307,7 @@ export interface Nip43Settings {

export interface Settings {
info: Info
admin?: AdminSettings
payments?: Payments
paymentsProcessors?: PaymentsProcessors
network: Network
Expand Down
76 changes: 76 additions & 0 deletions src/admin/password-admin-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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
}

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

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

try {
const token = createAdminSessionToken(expiresAt)

response
.status(200)
.setHeader('content-type', 'application/json')
.setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds))
.send({ authenticated: true, expiresAt })
} catch {
response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' })
}
}

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),
})
}
}
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())
}
19 changes: 19 additions & 0 deletions src/handlers/request-handlers/admin-auth-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextFunction, Request, Response } from 'express'

import { createAdminAuthProvider } from '../../factories/admin-auth-provider-factory'

const adminAuthProvider = createAdminAuthProvider()

export const adminAuthMiddleware = (request: Request, response: Response, next: NextFunction) => {
try {
if (!adminAuthProvider.isRequestAuthenticated(request)) {
response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' })
return
}
} catch {
response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' })
return
}

next()
}
12 changes: 12 additions & 0 deletions src/handlers/request-handlers/admin-enabled-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NextFunction, Request, Response } from 'express'

import { createSettings } from '../../factories/settings-factory'

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

next()
}
25 changes: 25 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,25 @@
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'

type AdminRateLimitScope = 'login' | 'admin'

const sendTooManyRequests = (response: Response) => {
response.status(429).setHeader('content-type', 'application/json').send({ error: 'Too many requests' })
}

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

next()
}
}

export const adminLoginRateLimitMiddleware = createAdminRateLimitMiddleware('login')
export const adminRateLimitMiddleware = createAdminRateLimitMiddleware('admin')
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Request, Response } from 'express'

import { Factory } from '../../@types/base'
import { IController } from '../../@types/controllers'

export const withAdminController =
(controllerFactory: Factory<IController>) => async (request: Request, response: Response) => {
try {
return await controllerFactory().handleRequest(request, response)
} catch {
response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' })
}
}
25 changes: 25 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { json, Router } from 'express'

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 { adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware'
import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware'
import {
adminLoginRateLimitMiddleware,
adminRateLimitMiddleware,
} from '../../handlers/request-handlers/admin-rate-limit-middleware'
import { rateLimiterMiddleware } from '../../handlers/request-handlers/rate-limiter-middleware'
import { withAdminController } from '../../handlers/request-handlers/with-admin-controller-request-handler'

const router: Router = Router()

// codeql[js/missing-rate-limiting] - custom Redis-backed sliding window rate limiter
router.use(rateLimiterMiddleware)
// codeql[js/missing-rate-limiting] - feature gate only, not authentication
router.use(adminEnabledMiddleware)
router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController))
router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController))

Check failure

Code scanning / CodeQL

Missing rate limiting High

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

Check failure

Code scanning / CodeQL

Missing rate limiting High

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()
Loading
Loading