fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
+84 -22
View File
@@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
@@ -10,9 +10,50 @@ import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
function generateRecoveryCode() {
const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
}
async function issueAdminRecoveryCodes(adminId: string) {
const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
await repo.replaceAdminRecoveryCodes(adminId, hashes)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
resource: 'AdminUser',
resourceId: adminId,
})
return codes
}
async function consumeAdminRecoveryCode(adminId: string, code: string) {
const normalized = code.trim().toUpperCase()
if (!normalized) return false
const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
for (const candidate of codes) {
if (await bcrypt.compare(normalized, candidate.codeHash)) {
await repo.markAdminRecoveryCodeUsed(candidate.id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODE_USED',
resource: 'AdminUser',
resourceId: adminId,
})
return true
}
}
return false
}
function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function toAuditJson<T>(value: T) {
@@ -31,7 +72,7 @@ function ensureAdminBasePath(baseUrl: string) {
}
}
export async function login(email: string, password: string, totpCode?: string) {
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
@@ -39,8 +80,16 @@ export async function login(email: string, password: string, totpCode?: string)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode) return { totpRequired: true } as const
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
@@ -53,7 +102,7 @@ export async function login(email: string, password: string, totpCode?: string)
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(adminId: string, email: string) {
@@ -69,8 +118,24 @@ export async function verifyTotp(adminId: string, code: string) {
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (valid) await repo.enableAdminTotp(adminId)
return valid
if (!valid) return false
await repo.enableAdminTotp(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_VERIFIED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function regenerateRecoveryCodes(adminId: string) {
return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
}
export async function forgotPassword(email: string) {
@@ -155,18 +220,12 @@ export async function deleteCompany(id: string, adminId: string, ip?: string) {
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
const company = await repo.getCompanyForImpersonation(id)
const token = jwt.sign(
{
sub: company.employees[0]?.id,
companyId: company.id,
isImpersonation: true,
type: 'employee',
},
process.env.JWT_SECRET!,
{ expiresIn: '30m' },
)
const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30)
const employeeId = company.employees[0]?.id
if (!employeeId) throw new Error('Company has no employee account to impersonate')
const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any })
await repo.createAuditLog({
adminUserId: adminId,
@@ -174,10 +233,13 @@ export async function impersonateCompany(id: string, adminId: string, ip?: strin
resource: 'Company',
resourceId: id,
companyId: id,
note: reason,
before: { originalAdminId: adminId },
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
ipAddress: ip,
})
return { token, expiresIn: 1800 }
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
@@ -205,7 +267,7 @@ export async function getAuditLogs(query: { adminId?: string; action?: string; c
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin) => presenter.presentAdminUser(admin))
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {