Implement auth: login/signup (scrypt), API key generation, shared migration
- Login: email + password lookup, scrypt verify, JWT token - Signup: create tenant + owner user in transaction, slug generation - API key: dd0c_ prefix, SHA-256 hash (not bcrypt — faster for API key lookups), prefix index - Scrypt over bcrypt: zero native deps, Node.js built-in crypto - Auth routes skip JWT middleware (login/signup are public) - 002_auth.sql: users + api_keys tables with RLS, copied to all products - Synced auth middleware to P3/P4/P5/P6
This commit is contained in:
35
products/03-alert-intelligence/migrations/002_auth.sql
Normal file
35
products/03-alert-intelligence/migrations/002_auth.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- dd0c shared auth tables — append to each product's migration
|
||||||
|
-- Run after 001_init.sql
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_users ON users
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_api_keys ON api_keys
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
const logger = pino({ name: 'auth' });
|
const logger = pino({ name: 'auth' });
|
||||||
@@ -16,32 +18,43 @@ export interface AuthPayload {
|
|||||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
*/
|
*/
|
||||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.decorateRequest('tenantId', '');
|
app.decorateRequest('tenantId', '');
|
||||||
app.decorateRequest('userId', '');
|
app.decorateRequest('userId', '');
|
||||||
app.decorateRequest('userRole', 'viewer');
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// Skip health check
|
|
||||||
if (req.url === '/health') return;
|
if (req.url === '/health') return;
|
||||||
// Skip webhook endpoints (they use HMAC auth)
|
|
||||||
if (req.url.startsWith('/webhooks/')) return;
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
// Skip Slack endpoints (they use Slack signature)
|
|
||||||
if (req.url.startsWith('/slack/')) 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 apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// API key auth: dd0c_ prefix + 32 hex chars
|
|
||||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||||
// TODO: Look up by prefix, bcrypt compare full key
|
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||||
// For now, reject — real implementation needs DB lookup
|
|
||||||
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
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 ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
@@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
(req as any).userRole = payload.role;
|
(req as any).userRole = payload.role;
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Role-based access control check.
|
|
||||||
* Use in route handlers: requireRole(req, reply, 'admin')
|
|
||||||
*/
|
|
||||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
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 hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
@@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT token (for login/signup endpoints).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
return jwt.sign(payload, secret, { expiresIn });
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||||
* Login endpoint registration.
|
|
||||||
*/
|
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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
@@ -98,18 +125,73 @@ const signupSchema = z.object({
|
|||||||
tenant_name: z.string().min(1).max(100),
|
tenant_name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
// TODO: Look up user by email, bcrypt compare password
|
|
||||||
// For now, return placeholder
|
const result = await pool.query(
|
||||||
return reply.status(501).send({ error: 'Login not yet implemented' });
|
`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) => {
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
const body = signupSchema.parse(req.body);
|
const body = signupSchema.parse(req.body);
|
||||||
// TODO: Create tenant + user, hash password with bcrypt
|
|
||||||
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
// 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) => {
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
@@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
|||||||
role: (req as any).userRole,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
35
products/04-lightweight-idp/migrations/002_auth.sql
Normal file
35
products/04-lightweight-idp/migrations/002_auth.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- dd0c shared auth tables — append to each product's migration
|
||||||
|
-- Run after 001_init.sql
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_users ON users
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_api_keys ON api_keys
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
const logger = pino({ name: 'auth' });
|
const logger = pino({ name: 'auth' });
|
||||||
@@ -16,32 +18,43 @@ export interface AuthPayload {
|
|||||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
*/
|
*/
|
||||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.decorateRequest('tenantId', '');
|
app.decorateRequest('tenantId', '');
|
||||||
app.decorateRequest('userId', '');
|
app.decorateRequest('userId', '');
|
||||||
app.decorateRequest('userRole', 'viewer');
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// Skip health check
|
|
||||||
if (req.url === '/health') return;
|
if (req.url === '/health') return;
|
||||||
// Skip webhook endpoints (they use HMAC auth)
|
|
||||||
if (req.url.startsWith('/webhooks/')) return;
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
// Skip Slack endpoints (they use Slack signature)
|
|
||||||
if (req.url.startsWith('/slack/')) 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 apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// API key auth: dd0c_ prefix + 32 hex chars
|
|
||||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||||
// TODO: Look up by prefix, bcrypt compare full key
|
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||||
// For now, reject — real implementation needs DB lookup
|
|
||||||
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
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 ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
@@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
(req as any).userRole = payload.role;
|
(req as any).userRole = payload.role;
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Role-based access control check.
|
|
||||||
* Use in route handlers: requireRole(req, reply, 'admin')
|
|
||||||
*/
|
|
||||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
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 hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
@@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT token (for login/signup endpoints).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
return jwt.sign(payload, secret, { expiresIn });
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||||
* Login endpoint registration.
|
|
||||||
*/
|
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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
@@ -98,18 +125,73 @@ const signupSchema = z.object({
|
|||||||
tenant_name: z.string().min(1).max(100),
|
tenant_name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
// TODO: Look up user by email, bcrypt compare password
|
|
||||||
// For now, return placeholder
|
const result = await pool.query(
|
||||||
return reply.status(501).send({ error: 'Login not yet implemented' });
|
`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) => {
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
const body = signupSchema.parse(req.body);
|
const body = signupSchema.parse(req.body);
|
||||||
// TODO: Create tenant + user, hash password with bcrypt
|
|
||||||
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
// 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) => {
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
@@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
|||||||
role: (req as any).userRole,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
35
products/05-aws-cost-anomaly/migrations/002_auth.sql
Normal file
35
products/05-aws-cost-anomaly/migrations/002_auth.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- dd0c shared auth tables — append to each product's migration
|
||||||
|
-- Run after 001_init.sql
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_users ON users
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_api_keys ON api_keys
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
const logger = pino({ name: 'auth' });
|
const logger = pino({ name: 'auth' });
|
||||||
@@ -16,32 +18,43 @@ export interface AuthPayload {
|
|||||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
*/
|
*/
|
||||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.decorateRequest('tenantId', '');
|
app.decorateRequest('tenantId', '');
|
||||||
app.decorateRequest('userId', '');
|
app.decorateRequest('userId', '');
|
||||||
app.decorateRequest('userRole', 'viewer');
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// Skip health check
|
|
||||||
if (req.url === '/health') return;
|
if (req.url === '/health') return;
|
||||||
// Skip webhook endpoints (they use HMAC auth)
|
|
||||||
if (req.url.startsWith('/webhooks/')) return;
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
// Skip Slack endpoints (they use Slack signature)
|
|
||||||
if (req.url.startsWith('/slack/')) 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 apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// API key auth: dd0c_ prefix + 32 hex chars
|
|
||||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||||
// TODO: Look up by prefix, bcrypt compare full key
|
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||||
// For now, reject — real implementation needs DB lookup
|
|
||||||
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
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 ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
@@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
(req as any).userRole = payload.role;
|
(req as any).userRole = payload.role;
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Role-based access control check.
|
|
||||||
* Use in route handlers: requireRole(req, reply, 'admin')
|
|
||||||
*/
|
|
||||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
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 hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
@@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT token (for login/signup endpoints).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
return jwt.sign(payload, secret, { expiresIn });
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||||
* Login endpoint registration.
|
|
||||||
*/
|
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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
@@ -98,18 +125,73 @@ const signupSchema = z.object({
|
|||||||
tenant_name: z.string().min(1).max(100),
|
tenant_name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
// TODO: Look up user by email, bcrypt compare password
|
|
||||||
// For now, return placeholder
|
const result = await pool.query(
|
||||||
return reply.status(501).send({ error: 'Login not yet implemented' });
|
`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) => {
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
const body = signupSchema.parse(req.body);
|
const body = signupSchema.parse(req.body);
|
||||||
// TODO: Create tenant + user, hash password with bcrypt
|
|
||||||
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
// 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) => {
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
@@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
|||||||
role: (req as any).userRole,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
35
products/06-runbook-automation/saas/migrations/002_auth.sql
Normal file
35
products/06-runbook-automation/saas/migrations/002_auth.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- dd0c shared auth tables — append to each product's migration
|
||||||
|
-- Run after 001_init.sql
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_users ON users
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_api_keys ON api_keys
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
const logger = pino({ name: 'auth' });
|
const logger = pino({ name: 'auth' });
|
||||||
@@ -16,32 +18,43 @@ export interface AuthPayload {
|
|||||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
*/
|
*/
|
||||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.decorateRequest('tenantId', '');
|
app.decorateRequest('tenantId', '');
|
||||||
app.decorateRequest('userId', '');
|
app.decorateRequest('userId', '');
|
||||||
app.decorateRequest('userRole', 'viewer');
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// Skip health check
|
|
||||||
if (req.url === '/health') return;
|
if (req.url === '/health') return;
|
||||||
// Skip webhook endpoints (they use HMAC auth)
|
|
||||||
if (req.url.startsWith('/webhooks/')) return;
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
// Skip Slack endpoints (they use Slack signature)
|
|
||||||
if (req.url.startsWith('/slack/')) 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 apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// API key auth: dd0c_ prefix + 32 hex chars
|
|
||||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||||
// TODO: Look up by prefix, bcrypt compare full key
|
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||||
// For now, reject — real implementation needs DB lookup
|
|
||||||
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
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 ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
@@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
(req as any).userRole = payload.role;
|
(req as any).userRole = payload.role;
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Role-based access control check.
|
|
||||||
* Use in route handlers: requireRole(req, reply, 'admin')
|
|
||||||
*/
|
|
||||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
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 hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
@@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT token (for login/signup endpoints).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
return jwt.sign(payload, secret, { expiresIn });
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||||
* Login endpoint registration.
|
|
||||||
*/
|
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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
@@ -98,18 +125,73 @@ const signupSchema = z.object({
|
|||||||
tenant_name: z.string().min(1).max(100),
|
tenant_name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
// TODO: Look up user by email, bcrypt compare password
|
|
||||||
// For now, return placeholder
|
const result = await pool.query(
|
||||||
return reply.status(501).send({ error: 'Login not yet implemented' });
|
`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) => {
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
const body = signupSchema.parse(req.body);
|
const body = signupSchema.parse(req.body);
|
||||||
// TODO: Create tenant + user, hash password with bcrypt
|
|
||||||
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
// 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) => {
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
@@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
|||||||
role: (req as any).userRole,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
35
products/shared/002_auth.sql
Normal file
35
products/shared/002_auth.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- dd0c shared auth tables — append to each product's migration
|
||||||
|
-- Run after 001_init.sql
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
|
||||||
|
|
||||||
|
-- API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false;
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_users ON users
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_api_keys ON api_keys
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
const logger = pino({ name: 'auth' });
|
const logger = pino({ name: 'auth' });
|
||||||
@@ -16,32 +18,43 @@ export interface AuthPayload {
|
|||||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
*/
|
*/
|
||||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.decorateRequest('tenantId', '');
|
app.decorateRequest('tenantId', '');
|
||||||
app.decorateRequest('userId', '');
|
app.decorateRequest('userId', '');
|
||||||
app.decorateRequest('userRole', 'viewer');
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// Skip health check
|
|
||||||
if (req.url === '/health') return;
|
if (req.url === '/health') return;
|
||||||
// Skip webhook endpoints (they use HMAC auth)
|
|
||||||
if (req.url.startsWith('/webhooks/')) return;
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
// Skip Slack endpoints (they use Slack signature)
|
|
||||||
if (req.url.startsWith('/slack/')) 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 apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
// API key auth: dd0c_ prefix + 32 hex chars
|
|
||||||
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||||
// TODO: Look up by prefix, bcrypt compare full key
|
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||||
// For now, reject — real implementation needs DB lookup
|
|
||||||
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
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 ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
@@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
(req as any).userId = payload.userId;
|
(req as any).userId = payload.userId;
|
||||||
(req as any).userRole = payload.role;
|
(req as any).userRole = payload.role;
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return reply.status(401).send({ error: 'Invalid or expired token' });
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Role-based access control check.
|
|
||||||
* Use in route handlers: requireRole(req, reply, 'admin')
|
|
||||||
*/
|
|
||||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
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 hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
@@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT token (for login/signup endpoints).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
return jwt.sign(payload, secret, { expiresIn });
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||||
* Login endpoint registration.
|
|
||||||
*/
|
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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
@@ -98,18 +125,73 @@ const signupSchema = z.object({
|
|||||||
tenant_name: z.string().min(1).max(100),
|
tenant_name: z.string().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
const body = loginSchema.parse(req.body);
|
const body = loginSchema.parse(req.body);
|
||||||
// TODO: Look up user by email, bcrypt compare password
|
|
||||||
// For now, return placeholder
|
const result = await pool.query(
|
||||||
return reply.status(501).send({ error: 'Login not yet implemented' });
|
`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) => {
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
const body = signupSchema.parse(req.body);
|
const body = signupSchema.parse(req.body);
|
||||||
// TODO: Create tenant + user, hash password with bcrypt
|
|
||||||
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
// 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) => {
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
@@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
|||||||
role: (req as any).userRole,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user