Fix auth security issues, add rate limiting, write Phase 2 audit

Security fixes:
- Register route validates company exists before creating user
- Rate limiting on auth routes (10 per 15min per IP)
- Dev auth plugin guards against production use
- Main.ts throws if JWT_SECRET missing in production

Added Phase 2 audit doc (22) covering:
- Built vs planning doc comparison
- Security review with fixes applied
- Duplicate code patterns identified
- Standard POS feature gap analysis
- Music-specific feature gaps

33 tests passing.
This commit is contained in:
Ryan Moon
2026-03-27 19:21:33 -05:00
parent dcc3dd1eed
commit c34ad27b86
6 changed files with 204 additions and 204 deletions

View File

@@ -1,4 +1,5 @@
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import { databasePlugin } from './plugins/database.js'
import { redisPlugin } from './plugins/redis.js'
import { corsPlugin } from './plugins/cors.js'
@@ -25,12 +26,16 @@ export async function buildApp() {
await app.register(errorHandlerPlugin)
await app.register(databasePlugin)
await app.register(redisPlugin)
await app.register(rateLimit, { global: false })
// Auth — use JWT if secret is set, otherwise dev bypass
// Auth — JWT in production/test, dev bypass only in development without JWT_SECRET
if (process.env.JWT_SECRET) {
await app.register(authPlugin)
} else {
} else if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
app.log.warn('JWT_SECRET not set — using dev auth bypass. DO NOT USE IN PRODUCTION.')
await app.register(devAuthPlugin)
} else {
throw new Error('JWT_SECRET is required in production')
}
// Routes

View File

@@ -9,14 +9,21 @@ declare module 'fastify' {
}
/**
* Dev-only auth bypass. Used when JWT_SECRET is not set.
* Reads x-company-id and x-location-id headers to set request context.
* Dev-only auth bypass. Used when JWT_SECRET is not set in development.
* NEVER runs in production — main.ts guards against this.
*/
export const devAuthPlugin = fp(async (app) => {
if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') {
throw new Error('Dev auth plugin cannot be used outside development/test environments')
}
app.addHook('onRequest', async (request) => {
const companyId = (request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
const locationId = (request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010'
const userId = (request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
const companyId =
(request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
const locationId =
(request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010'
const userId =
(request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
request.companyId = companyId
request.locationId = locationId

View File

@@ -3,11 +3,22 @@ import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js'
import { companies } from '../../db/schema/stores.js'
const SALT_ROUNDS = 10
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/auth/register', async (request, reply) => {
// Rate limit auth routes — 10 attempts per 15 minutes per IP
const rateLimitConfig = {
config: {
rateLimit: {
max: 10,
timeWindow: '15 minutes',
},
},
}
app.post('/auth/register', rateLimitConfig, async (request, reply) => {
const parsed = RegisterSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
@@ -18,6 +29,25 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const { email, password, firstName, lastName, role } = parsed.data
const companyId = request.companyId
// Validate that the company exists
if (!companyId) {
return reply.status(400).send({
error: { message: 'Company ID is required (x-company-id header)', statusCode: 400 },
})
}
const [company] = await app.db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, companyId))
.limit(1)
if (!company) {
return reply.status(400).send({
error: { message: 'Invalid company', statusCode: 400 },
})
}
// Email is globally unique across all companies
const existing = await app.db
.select({ id: users.id })
@@ -61,7 +91,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send({ user, token })
})
app.post('/auth/login', async (request, reply) => {
app.post('/auth/login', rateLimitConfig, async (request, reply) => {
const parsed = LoginSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({