Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 All @@ -287,6 +299,7 @@ export interface WoTSettings {

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

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' })
}
}
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())
}
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' })
}
Comment thread
Ferryx349 marked this conversation as resolved.

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