Query API: - GET /api/payloads/store/:id/query - Filter products with flexible params (brand, category, price_min/max, thc_min/max, search, sort, pagination) - GET /api/payloads/store/:id/aggregate - Group by brand/category with metrics (count, avg_price, min_price, max_price, avg_thc, in_stock_count) - Documentation at docs/QUERY_API.md Trusted Origins Admin: - GET/POST/PUT/DELETE /api/admin/trusted-origins - Manage auth bypass list - Trusted IPs, domains, and regex patterns stored in DB - 5-minute cache with invalidation on admin updates - Fallback to hardcoded defaults if DB unavailable - Migration 085 creates table with seed data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
378 lines
9.6 KiB
TypeScript
Executable File
378 lines
9.6 KiB
TypeScript
Executable File
/**
|
|
* CannaiQ Authentication Middleware
|
|
*
|
|
* AUTH METHODS (in order of priority):
|
|
* 1. IP-based: Localhost/trusted IPs get 'internal' role (full access, no token needed)
|
|
* 2. Token-based: Bearer token (JWT or API token)
|
|
*
|
|
* NO username/password auth in API. Use tokens only.
|
|
*
|
|
* Trusted origins are managed via /admin and stored in the trusted_origins table.
|
|
* Localhost bypass: curl from 127.0.0.1 gets automatic admin access.
|
|
*/
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import bcrypt from 'bcrypt';
|
|
import { pool } from '../db/pool';
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'change_this_in_production';
|
|
|
|
// Fallback trusted origins (used if DB unavailable)
|
|
const FALLBACK_TRUSTED_ORIGINS = [
|
|
'https://cannaiq.co',
|
|
'https://www.cannaiq.co',
|
|
'https://findadispo.com',
|
|
'https://www.findadispo.com',
|
|
'https://findagram.co',
|
|
'https://www.findagram.co',
|
|
'http://localhost:3010',
|
|
'http://localhost:8080',
|
|
'http://localhost:5173',
|
|
];
|
|
|
|
const FALLBACK_TRUSTED_PATTERNS = [
|
|
/^https:\/\/.*\.cannabrands\.app$/,
|
|
/^https:\/\/.*\.cannaiq\.co$/,
|
|
];
|
|
|
|
const FALLBACK_TRUSTED_IPS = [
|
|
'127.0.0.1',
|
|
'::1',
|
|
'::ffff:127.0.0.1',
|
|
];
|
|
|
|
// Cache for DB-backed trusted origins
|
|
let trustedOriginsCache: {
|
|
ips: Set<string>;
|
|
domains: Set<string>;
|
|
patterns: RegExp[];
|
|
loadedAt: Date;
|
|
} | null = null;
|
|
|
|
/**
|
|
* Load trusted origins from DB with caching (5 min TTL)
|
|
*/
|
|
async function loadTrustedOrigins(): Promise<{
|
|
ips: Set<string>;
|
|
domains: Set<string>;
|
|
patterns: RegExp[];
|
|
}> {
|
|
// Return cached if fresh
|
|
if (trustedOriginsCache) {
|
|
const age = Date.now() - trustedOriginsCache.loadedAt.getTime();
|
|
if (age < 5 * 60 * 1000) {
|
|
return trustedOriginsCache;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT origin_type, origin_value
|
|
FROM trusted_origins
|
|
WHERE active = true
|
|
`);
|
|
|
|
const ips = new Set<string>();
|
|
const domains = new Set<string>();
|
|
const patterns: RegExp[] = [];
|
|
|
|
for (const row of result.rows) {
|
|
switch (row.origin_type) {
|
|
case 'ip':
|
|
ips.add(row.origin_value);
|
|
break;
|
|
case 'domain':
|
|
// Store as full origin for comparison
|
|
if (!row.origin_value.startsWith('http')) {
|
|
domains.add(`https://${row.origin_value}`);
|
|
domains.add(`http://${row.origin_value}`);
|
|
} else {
|
|
domains.add(row.origin_value);
|
|
}
|
|
break;
|
|
case 'pattern':
|
|
try {
|
|
patterns.push(new RegExp(row.origin_value));
|
|
} catch {
|
|
console.warn(`[Auth] Invalid trusted origin pattern: ${row.origin_value}`);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
trustedOriginsCache = { ips, domains, patterns, loadedAt: new Date() };
|
|
return trustedOriginsCache;
|
|
} catch (error) {
|
|
// DB not available or table doesn't exist - use fallbacks
|
|
return {
|
|
ips: new Set(FALLBACK_TRUSTED_IPS),
|
|
domains: new Set(FALLBACK_TRUSTED_ORIGINS),
|
|
patterns: FALLBACK_TRUSTED_PATTERNS,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear trusted origins cache (called when admin updates origins)
|
|
*/
|
|
export function clearTrustedOriginsCache() {
|
|
trustedOriginsCache = null;
|
|
}
|
|
|
|
/**
|
|
* Check if request is from a trusted origin/IP
|
|
*/
|
|
async function isTrustedRequest(req: Request): Promise<boolean> {
|
|
const { ips, domains, patterns } = await loadTrustedOrigins();
|
|
|
|
// Check origin header
|
|
const origin = req.headers.origin;
|
|
if (origin) {
|
|
if (domains.has(origin)) {
|
|
return true;
|
|
}
|
|
for (const pattern of patterns) {
|
|
if (pattern.test(origin)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check referer header (for same-origin requests without CORS)
|
|
const referer = req.headers.referer;
|
|
if (referer) {
|
|
for (const trusted of domains) {
|
|
if (referer.startsWith(trusted)) {
|
|
return true;
|
|
}
|
|
}
|
|
try {
|
|
const refererUrl = new URL(referer);
|
|
const refererOrigin = refererUrl.origin;
|
|
for (const pattern of patterns) {
|
|
if (pattern.test(refererOrigin)) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid referer URL, skip
|
|
}
|
|
}
|
|
|
|
// Check IP for internal requests (pod-to-pod, localhost)
|
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
if (ips.has(clientIp)) {
|
|
return true;
|
|
}
|
|
|
|
// Check for Kubernetes internal header (set by ingress/service mesh)
|
|
const internalHeader = req.headers['x-internal-request'];
|
|
if (internalHeader === process.env.INTERNAL_REQUEST_SECRET) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export interface AuthUser {
|
|
id: number;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
export interface AuthRequest extends Request {
|
|
user?: AuthUser;
|
|
apiToken?: {
|
|
id: number;
|
|
name: string;
|
|
rate_limit: number;
|
|
};
|
|
}
|
|
|
|
export function generateToken(user: AuthUser): string {
|
|
return jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
}
|
|
|
|
export function verifyToken(token: string): AuthUser | null {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET) as AuthUser;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function authenticateUser(email: string, password: string): Promise<AuthUser | null> {
|
|
const result = await pool.query(
|
|
'SELECT id, email, password_hash, role FROM users WHERE email = $1',
|
|
[email]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const user = result.rows[0];
|
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
|
|
|
if (!isValid) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role
|
|
};
|
|
}
|
|
|
|
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
|
const authHeader = req.headers.authorization;
|
|
|
|
// If a Bearer token is provided, always try to use it first (logged-in user)
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const token = authHeader.substring(7);
|
|
|
|
// Try JWT first
|
|
const jwtUser = verifyToken(token);
|
|
|
|
if (jwtUser) {
|
|
req.user = jwtUser;
|
|
return next();
|
|
}
|
|
|
|
// If JWT fails, try API token
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT id, name, rate_limit, active, expires_at, allowed_endpoints
|
|
FROM api_tokens
|
|
WHERE token = $1
|
|
`, [token]);
|
|
|
|
if (result.rows.length > 0) {
|
|
const apiToken = result.rows[0];
|
|
if (!apiToken.active) {
|
|
return res.status(401).json({ error: 'API token is inactive' });
|
|
}
|
|
if (apiToken.expires_at && new Date(apiToken.expires_at) < new Date()) {
|
|
return res.status(401).json({ error: 'API token has expired' });
|
|
}
|
|
req.user = {
|
|
id: 0,
|
|
email: `api:${apiToken.name}`,
|
|
role: 'api_token'
|
|
};
|
|
req.apiToken = apiToken;
|
|
return next();
|
|
}
|
|
} catch (err) {
|
|
console.error('API token lookup error:', err);
|
|
}
|
|
|
|
// Token provided but invalid
|
|
return res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
|
|
// No token provided - check trusted origins for API access (WordPress, etc.)
|
|
if (await isTrustedRequest(req)) {
|
|
req.user = {
|
|
id: 0,
|
|
email: 'internal@system',
|
|
role: 'internal'
|
|
};
|
|
return next();
|
|
}
|
|
|
|
return res.status(401).json({ error: 'No token provided' });
|
|
}
|
|
|
|
|
|
/**
|
|
* Require specific role(s) to access endpoint.
|
|
*
|
|
* NOTE: 'internal' role (localhost/trusted IPs) bypasses all role checks.
|
|
* This allows local development and internal services full access.
|
|
*/
|
|
export function requireRole(...roles: string[]) {
|
|
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
// Internal role (localhost) bypasses role checks
|
|
if (req.user.role === 'internal') {
|
|
return next();
|
|
}
|
|
|
|
if (!roles.includes(req.user.role)) {
|
|
return res.status(403).json({ error: 'Insufficient permissions' });
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optional auth middleware - attempts to authenticate but allows unauthenticated requests
|
|
*
|
|
* If a valid token is provided, sets req.user with the authenticated user.
|
|
* If no token or invalid token, continues without setting req.user.
|
|
*
|
|
* Use this for endpoints that work for both authenticated and anonymous users
|
|
* (e.g., product click tracking where we want user_id when available).
|
|
*/
|
|
export async function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
|
const authHeader = req.headers.authorization;
|
|
|
|
// No token provided - continue without auth
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return next();
|
|
}
|
|
|
|
const token = authHeader.substring(7);
|
|
|
|
// Try JWT first
|
|
const jwtUser = verifyToken(token);
|
|
|
|
if (jwtUser) {
|
|
req.user = jwtUser;
|
|
return next();
|
|
}
|
|
|
|
// If JWT fails, try API token
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT id, name, rate_limit, active, expires_at
|
|
FROM api_tokens
|
|
WHERE token = $1
|
|
`, [token]);
|
|
|
|
if (result.rows.length > 0) {
|
|
const apiToken = result.rows[0];
|
|
|
|
// Check if token is active and not expired
|
|
if (apiToken.active && (!apiToken.expires_at || new Date(apiToken.expires_at) >= new Date())) {
|
|
req.apiToken = {
|
|
id: apiToken.id,
|
|
name: apiToken.name,
|
|
rate_limit: apiToken.rate_limit
|
|
};
|
|
|
|
req.user = {
|
|
id: apiToken.id,
|
|
email: `api-token-${apiToken.id}@system`,
|
|
role: 'api'
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Silently ignore errors - optional auth should not fail the request
|
|
console.warn('[OptionalAuth] Error checking API token:', error);
|
|
}
|
|
|
|
next();
|
|
}
|