Skip to content

Commit 837540b

Browse files
authored
feat: add admin backend foundation (login, session, health) (#641)
1 parent 8ab4825 commit 837540b

28 files changed

Lines changed: 988 additions & 1 deletion
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat: add disabled-by-default admin API with password auth, session, and health endpoints

‎resources/default-settings.yaml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ limits:
112112
- "::1"
113113
- "10.10.10.1"
114114
- "::ffff:10.10.10.1"
115+
admin:
116+
rateLimits:
117+
- description: 30 admin requests/min
118+
period: 60000
119+
rate: 30
120+
loginRateLimits:
121+
- description: 10 login attempts per 15 minutes
122+
period: 900000
123+
rate: 10
124+
ipWhitelist:
125+
- "::1"
126+
- "10.10.10.1"
127+
- "::ffff:10.10.10.1"
115128
connection:
116129
rateLimits:
117130
- period: 1000
@@ -229,3 +242,6 @@ limits:
229242
- "::1"
230243
- "10.10.10.1"
231244
- "::ffff:10.10.10.1"
245+
admin:
246+
enabled: false
247+
sessionTtlSeconds: 86400

‎src/@types/admin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Request, Response } from 'express'
2+
3+
export interface IAdminAuthProvider {
4+
handleLogin(request: Request, response: Response): Promise<void>
5+
isRequestAuthenticated(request: Request): boolean
6+
getSessionExpiresAt(request: Request): number | undefined
7+
}

‎src/@types/settings.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,17 @@ export interface AdmissionCheckLimits {
141141
ipWhitelist?: string[]
142142
}
143143

144+
export interface AdminLimits {
145+
rateLimits?: RateLimit[]
146+
loginRateLimits?: RateLimit[]
147+
ipWhitelist?: string[]
148+
}
149+
144150
export interface Limits {
145151
rateLimiter?: RateLimiterSettings
146152
invoice?: InvoiceLimits
147153
admissionCheck?: AdmissionCheckLimits
154+
admin?: AdminLimits
148155
connection?: ConnectionLimits
149156
client?: ClientLimits
150157
event?: EventLimits
@@ -266,6 +273,11 @@ export interface Nip05Settings {
266273
domainBlacklist?: string[]
267274
}
268275

276+
export interface AdminSettings {
277+
enabled: boolean
278+
passwordHash?: string
279+
sessionTtlSeconds?: number
280+
}
269281
export interface WoTSettings {
270282
enabled: boolean
271283
/**
@@ -295,6 +307,7 @@ export interface Nip43Settings {
295307

296308
export interface Settings {
297309
info: Info
310+
admin?: AdminSettings
298311
payments?: Payments
299312
paymentsProcessors?: PaymentsProcessors
300313
network: Network
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Request, Response } from 'express'
2+
3+
import { IAdminAuthProvider } from '../@types/admin'
4+
import { Settings } from '../@types/settings'
5+
import { adminLoginBodySchema } from '../schemas/admin-login-schema'
6+
import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password'
7+
import {
8+
buildAdminSessionCookieHeader,
9+
createAdminSessionToken,
10+
getAdminSessionTokenFromRequest,
11+
isValidAdminSessionToken,
12+
parseAdminSessionToken,
13+
resolveAdminSessionTtlSeconds,
14+
} from '../utils/admin-session'
15+
import { validateSchema } from '../utils/validation'
16+
17+
export class PasswordAdminAuthProvider implements IAdminAuthProvider {
18+
public constructor(private readonly settings: () => Settings) {}
19+
20+
public async handleLogin(request: Request, response: Response): Promise<void> {
21+
const validation = validateSchema(adminLoginBodySchema)(request.body)
22+
if (validation.error) {
23+
response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' })
24+
return
25+
}
26+
27+
if (!this.verifyPassword(validation.value.password)) {
28+
response.status(401).setHeader('content-type', 'application/json').send({ error: 'Unauthorized' })
29+
return
30+
}
31+
32+
const currentSettings = this.settings()
33+
const sessionTtlSeconds = resolveAdminSessionTtlSeconds(currentSettings.admin?.sessionTtlSeconds)
34+
const expiresAt = Math.floor(Date.now() / 1000) + sessionTtlSeconds
35+
36+
try {
37+
const token = createAdminSessionToken(expiresAt)
38+
39+
response
40+
.status(200)
41+
.setHeader('content-type', 'application/json')
42+
.setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds))
43+
.send({ authenticated: true, expiresAt })
44+
} catch {
45+
response.status(500).setHeader('content-type', 'application/json').send({ error: 'Internal Server Error' })
46+
}
47+
}
48+
49+
public isRequestAuthenticated(request: Request): boolean {
50+
const token = this.getToken(request)
51+
return token ? isValidAdminSessionToken(token) : false
52+
}
53+
54+
public getSessionExpiresAt(request: Request): number | undefined {
55+
const token = this.getToken(request)
56+
return token ? parseAdminSessionToken(token)?.expiresAt : undefined
57+
}
58+
59+
private getToken(request: Request): string | undefined {
60+
return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie)
61+
}
62+
63+
private verifyPassword(password: string): boolean {
64+
const envPassword = process.env.ADMIN_PASSWORD
65+
if (typeof envPassword === 'string' && envPassword.length > 0) {
66+
return verifyPlaintextPassword(password, envPassword)
67+
}
68+
69+
const passwordHash = this.settings().admin?.passwordHash
70+
if (!passwordHash) {
71+
return false
72+
}
73+
74+
return verifyAdminPasswordHash(password, passwordHash)
75+
}
76+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Request, Response } from 'express'
2+
3+
import { IController } from '../../@types/controllers'
4+
import { collectAdminHealthSnapshot } from '../../utils/admin-health'
5+
6+
export class GetAdminHealthController implements IController {
7+
public async handleRequest(_request: Request, response: Response): Promise<void> {
8+
const health = await collectAdminHealthSnapshot()
9+
response.status(200).setHeader('content-type', 'application/json').send(health)
10+
}
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Request, Response } from 'express'
2+
3+
import { IAdminAuthProvider } from '../../@types/admin'
4+
import { IController } from '../../@types/controllers'
5+
6+
export class GetAdminSessionController implements IController {
7+
public constructor(private readonly authProvider: IAdminAuthProvider) {}
8+
9+
public async handleRequest(request: Request, response: Response): Promise<void> {
10+
response.status(200).setHeader('content-type', 'application/json').send({
11+
authenticated: true,
12+
expiresAt: this.authProvider.getSessionExpiresAt(request),
13+
})
14+
}
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Request, Response } from 'express'
2+
3+
import { IAdminAuthProvider } from '../../@types/admin'
4+
import { IController } from '../../@types/controllers'
5+
6+
export class PostAdminLoginController implements IController {
7+
public constructor(private readonly authProvider: IAdminAuthProvider) {}
8+
9+
public async handleRequest(request: Request, response: Response): Promise<void> {
10+
await this.authProvider.handleLogin(request, response)
11+
}
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { PasswordAdminAuthProvider } from '../admin/password-admin-auth-provider'
2+
import { IAdminAuthProvider } from '../@types/admin'
3+
import { createSettings } from './settings-factory'
4+
5+
export const createAdminAuthProvider = (): IAdminAuthProvider => {
6+
return new PasswordAdminAuthProvider(createSettings)
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { GetAdminHealthController } from '../../controllers/admin/get-health-controller'
2+
import { IController } from '../../@types/controllers'
3+
4+
export const createGetAdminHealthController = (): IController => {
5+
return new GetAdminHealthController()
6+
}

0 commit comments

Comments
 (0)