refractor code,
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
/**
|
||||
* Sends a uniform auth-error JSON response.
|
||||
* All auth middleware must go through this function so the shape is identical
|
||||
* to what the errorMiddleware produces for AppError instances.
|
||||
*/
|
||||
export function sendUnauthorized(res: Response, error: string, message: string) {
|
||||
return res.status(401).json({ error, message, statusCode: 401 })
|
||||
}
|
||||
|
||||
export function getCookie(req: Request, name: string): string | null {
|
||||
const cookieHeader = req.headers.cookie
|
||||
if (!cookieHeader) return null
|
||||
|
||||
for (const chunk of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = chunk.trim().split('=')
|
||||
if (rawName === name) {
|
||||
return decodeURIComponent(rawValue.join('='))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(403).json({ error, message, statusCode: 403, ...extra })
|
||||
}
|
||||
|
||||
export function sendPaymentRequired(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
|
||||
return res.status(402).json({ error, message, statusCode: 402, ...extra })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { AdminRole } from '@rentaldrivego/database'
|
||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
|
||||
const ROLE_RANK: Record<AdminRole, number> = {
|
||||
SUPER_ADMIN: 5,
|
||||
@@ -11,48 +12,52 @@ const ROLE_RANK: Record<AdminRole, number> = {
|
||||
VIEWER: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a valid admin Bearer token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.admin — the full AdminUser record
|
||||
*/
|
||||
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'admin') {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
next()
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'admin') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
|
||||
if (!admin || !admin.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the authenticated admin to have at least `minimumRole`.
|
||||
* Must be applied after `requireAdminAuth`.
|
||||
*/
|
||||
export function requireAdminRole(minimumRole: AdminRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const admin = req.admin
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
|
||||
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
const rank = ROLE_RANK[admin.role] ?? 0
|
||||
const required = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (rank < required) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
})
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { getCookie, sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.employee — full employee record (with company relation)
|
||||
* req.company — the employee's company
|
||||
* req.companyId — string shorthand for req.company.id
|
||||
*/
|
||||
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
|
||||
const authHeader = req.headers.authorization
|
||||
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
|
||||
const token = bearerToken ?? cookieToken
|
||||
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'employee') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Employee account not found or inactive')
|
||||
}
|
||||
|
||||
req.employee = employee
|
||||
req.company = employee.company
|
||||
req.companyId = employee.companyId
|
||||
next()
|
||||
}
|
||||
|
||||
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const sessionToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!sessionToken) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Authentication required',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(sessionToken, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'employee') {
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Employee account not found or inactive',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
req.employee = employee
|
||||
req.company = employee.company
|
||||
req.companyId = employee.companyId
|
||||
return next()
|
||||
}
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired session token',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
return authenticateCompanyRequest(req, res, next)
|
||||
}
|
||||
|
||||
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
|
||||
return authenticateCompanyRequest(req, res, next, true)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Requires a valid renter Bearer token.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.renterId — the authenticated renter's id
|
||||
*/
|
||||
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
|
||||
|
||||
let payload: { sub: string; type: string }
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'renter') {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
}
|
||||
|
||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||
if (!renter || !renter.isActive) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
|
||||
}
|
||||
|
||||
req.renterId = renter.id
|
||||
next()
|
||||
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
|
||||
}
|
||||
|
||||
if (payload.type !== 'renter') {
|
||||
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
|
||||
}
|
||||
|
||||
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
|
||||
if (!renter || !renter.isActive) {
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
|
||||
}
|
||||
|
||||
req.renterId = renter.id
|
||||
next()
|
||||
}
|
||||
|
||||
export async function optionalRenterAuth(req: Request, res: Response, next: NextFunction) {
|
||||
/**
|
||||
* Optionally extracts renter identity from a Bearer token.
|
||||
* Never blocks the request — invalid or absent tokens are silently ignored.
|
||||
*
|
||||
* Sets on success:
|
||||
* req.renterId — the authenticated renter's id (if token is valid)
|
||||
*/
|
||||
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) return next()
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type === 'renter') {
|
||||
req.renterId = payload.sub
|
||||
}
|
||||
if (payload.type === 'renter') req.renterId = payload.sub
|
||||
} catch {
|
||||
// Optional — ignore invalid tokens
|
||||
// Optional — silently ignore invalid tokens
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { EmployeeRole } from '@rentaldrivego/database'
|
||||
import { sendUnauthorized, sendForbidden } from './authHelpers'
|
||||
|
||||
const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||
OWNER: 3,
|
||||
@@ -7,21 +8,20 @@ const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||
AGENT: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the authenticated employee to have at least `minimumRole`.
|
||||
* Must be applied after `requireCompanyAuth`.
|
||||
*/
|
||||
export function requireRole(minimumRole: EmployeeRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const employee = req.employee
|
||||
if (!employee) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
}
|
||||
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
|
||||
|
||||
const employeeRank = ROLE_RANK[employee.role] ?? 0
|
||||
const requiredRank = ROLE_RANK[minimumRole] ?? 99
|
||||
const requiredRank = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (employeeRank < requiredRank) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`, {
|
||||
requiredRole: minimumRole,
|
||||
yourRole: employee.role,
|
||||
})
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
|
||||
|
||||
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
|
||||
|
||||
/**
|
||||
* Blocks requests for companies with lapsed or unactivated subscriptions.
|
||||
* Must be applied after `requireTenant`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company.status is not SUSPENDED or PENDING
|
||||
*/
|
||||
export function requireSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
const company = req.company
|
||||
if (!company) {
|
||||
return res.status(401).json({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
|
||||
}
|
||||
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
|
||||
|
||||
if (BLOCKED_STATUSES.includes(company.status)) {
|
||||
return res.status(402).json({
|
||||
error: 'subscription_' + company.status.toLowerCase(),
|
||||
message:
|
||||
company.status === 'SUSPENDED'
|
||||
? 'Your account has been suspended. Please contact support or renew your subscription.'
|
||||
: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
statusCode: 402,
|
||||
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing`,
|
||||
})
|
||||
return sendPaymentRequired(
|
||||
res,
|
||||
`subscription_${company.status.toLowerCase()}`,
|
||||
company.status === 'SUSPENDED'
|
||||
? 'Your account has been suspended. Please contact support or renew your subscription.'
|
||||
: 'Your account is pending activation. Please complete your subscription setup.',
|
||||
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
|
||||
)
|
||||
}
|
||||
|
||||
next()
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendUnauthorized } from './authHelpers'
|
||||
|
||||
/**
|
||||
* Loads the company record and validates it exists.
|
||||
* Must be applied after `requireCompanyAuth`.
|
||||
*
|
||||
* Guarantees on success:
|
||||
* req.company — full Company record
|
||||
* req.companyId — string id (already set by requireCompanyAuth)
|
||||
*/
|
||||
export async function requireTenant(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.companyId) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Tenant context missing — requireCompanyAuth must run first',
|
||||
statusCode: 401,
|
||||
})
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Tenant context missing — requireCompanyAuth must run first')
|
||||
}
|
||||
|
||||
const company = await prisma.company.findUnique({ where: { id: req.companyId } })
|
||||
if (!company) {
|
||||
return res.status(401).json({
|
||||
error: 'company_not_found',
|
||||
message: 'Company not found',
|
||||
statusCode: 401,
|
||||
})
|
||||
return sendUnauthorized(res, 'company_not_found', 'Company not found')
|
||||
}
|
||||
|
||||
req.company = company
|
||||
|
||||
Reference in New Issue
Block a user