Skip to content

Commit 84d5f93

Browse files
committed
feat(admin): add backend foundation (login, session, health)
1 parent faf55f1 commit 84d5f93

25 files changed

Lines changed: 901 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
/**
@@ -287,6 +299,7 @@ export interface WoTSettings {
287299

288300
export interface Settings {
289301
info: Info
302+
admin?: AdminSettings
290303
payments?: Payments
291304
paymentsProcessors?: PaymentsProcessors
292305
network: Network
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
const token = createAdminSessionToken(expiresAt)
36+
37+
response
38+
.status(200)
39+
.setHeader('content-type', 'application/json')
40+
.setHeader('Set-Cookie', buildAdminSessionCookieHeader(request, currentSettings, token, sessionTtlSeconds))
41+
.send({ authenticated: true, expiresAt })
42+
}
43+
44+
public isRequestAuthenticated(request: Request): boolean {
45+
const token = this.getToken(request)
46+
return token ? isValidAdminSessionToken(token) : false
47+
}
48+
49+
public getSessionExpiresAt(request: Request): number | undefined {
50+
const token = this.getToken(request)
51+
return token ? parseAdminSessionToken(token)?.expiresAt : undefined
52+
}
53+
54+
private getToken(request: Request): string | undefined {
55+
return getAdminSessionTokenFromRequest(request.headers.authorization, request.headers.cookie)
56+
}
57+
58+
private verifyPassword(password: string): boolean {
59+
const envPassword = process.env.ADMIN_PASSWORD
60+
if (typeof envPassword === 'string' && envPassword.length > 0) {
61+
return verifyPlaintextPassword(password, envPassword)
62+
}
63+
64+
const passwordHash = this.settings().admin?.passwordHash
65+
if (!passwordHash) {
66+
return false
67+
}
68+
69+
return verifyAdminPasswordHash(password, passwordHash)
70+
}
71+
}
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)