Add auth middleware to P2 Drift (signup/login/API keys), remove pino-pretty dev transport
This commit is contained in:
224
products/02-iac-drift-detection/saas/src/auth/middleware.ts
Normal file
224
products/02-iac-drift-detection/saas/src/auth/middleware.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { z } from 'zod';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { Pool } from 'pg';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'auth' });
|
||||
|
||||
export interface AuthPayload {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
if (req.url.startsWith('/api/v1/auth/login') || req.url.startsWith('/api/v1/auth/signup')) return;
|
||||
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
if (apiKey) {
|
||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT ak.tenant_id, ak.user_id, u.role
|
||||
FROM api_keys ak JOIN users u ON ak.user_id = u.id
|
||||
WHERE ak.key_prefix = $1 AND ak.key_hash = $2 AND ak.revoked = false`,
|
||||
[prefix, keyHash],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return reply.status(401).send({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
(req as any).tenantId = result.rows[0].tenant_id;
|
||||
(req as any).userId = result.rows[0].user_id;
|
||||
(req as any).userRole = result.rows[0].role;
|
||||
return;
|
||||
}
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
return;
|
||||
} catch {
|
||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
const hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||
const requiredLevel = hierarchy[minRole] ?? 0;
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
reply.status(403).send({ error: `Requires ${minRole} role or higher` });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||
return jwt.sign(payload, secret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
resolve(`${salt}:${derived.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
const [salt, key] = hash.split(':');
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Auth Routes ---
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.tenant_id, u.email, u.password_hash, u.role
|
||||
FROM users u WHERE u.email = $1`,
|
||||
[body.email],
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
if (!user) return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
|
||||
const valid = await verifyPassword(body.password, user.password_hash);
|
||||
if (!valid) return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
|
||||
const token = signToken({
|
||||
tenantId: user.tenant_id,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
}, jwtSecret);
|
||||
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
const token = signToken({
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
user_id: (req as any).userId,
|
||||
role: (req as any).userRole,
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
|
||||
const rawKey = `dd0c_${crypto.randomBytes(16).toString('hex')}`;
|
||||
const prefix = rawKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO api_keys (tenant_id, user_id, key_prefix, key_hash) VALUES ($1, $2, $3, $4)`,
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
}
|
||||
@@ -4,15 +4,13 @@ import helmet from '@fastify/helmet';
|
||||
import { config } from './config/index.js';
|
||||
import { registerProcessorRoutes } from './processor/routes.js';
|
||||
import { registerApiRoutes } from './api/routes.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { createPool } from './data/db.js';
|
||||
import { createRedis } from './data/redis.js';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.logLevel,
|
||||
transport: config.nodeEnv === 'development'
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,13 +27,19 @@ async function start() {
|
||||
app.decorate('redis', redis);
|
||||
app.decorate('config', config);
|
||||
|
||||
// Auth
|
||||
registerAuth(app, config.jwtSecret, pool);
|
||||
|
||||
// Health (before auth)
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
// Auth routes (signup/login)
|
||||
registerAuthRoutes(app, config.jwtSecret, pool);
|
||||
|
||||
// Routes
|
||||
await registerProcessorRoutes(app);
|
||||
await registerApiRoutes(app);
|
||||
|
||||
// Health
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
await app.listen({ port: config.port, host: '0.0.0.0' });
|
||||
app.log.info(`dd0c/drift SaaS listening on :${config.port}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user