Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ USE_AWS_SECRETS_MANAGER=false
AWS_SECRETS_MANAGER_SECRET_NAME=paystream/secrets
AWS_REGION=us-east-1

# Vault
USE_VAULT=false
VAULT_ADDR=
VAULT_TOKEN=
VAULT_SECRET_PATH=

# Application environment
APP_ENV=development

# Auth rate limiting
AUTH_MAX_ATTEMPTS=5
AUTH_LOCKOUT_MS=900000
AUTH_BASE_DELAY_MS=500

# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
Expand Down
84 changes: 84 additions & 0 deletions api/config/environment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const { loadSecrets } = require('../services/secretsService');
const { readVaultSecret } = require('../services/vaultService');

function resolveCandidateFiles(environmentName) {
const names = [
`.env.${environmentName}.local`,
`.env.${environmentName}`,
'.env.local',
'.env',
];

const cwd = process.cwd();
const candidates = [];
for (const name of names) {
candidates.push(path.resolve(cwd, name));
candidates.push(path.resolve(cwd, 'api', name));
}

return [...new Set(candidates)];
}

function applyParsedEnv(parsedEnv, source) {
Object.entries(parsedEnv).forEach(([key, value]) => {
if (typeof value === 'string' && !Object.prototype.hasOwnProperty.call(process.env, key)) {
process.env[key] = value;
}
});

return source;
}

function loadEnvironmentFiles(environmentName) {
const candidates = resolveCandidateFiles(environmentName);
const loaded = [];

for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
const parsed = dotenv.config({ path: candidate, override: false });
if (!parsed.error) {
loaded.push(path.relative(process.cwd(), candidate));
}
}
}

return loaded;
}

async function loadConfiguration() {
const environmentName = process.env.APP_ENV || process.env.NODE_ENV || 'development';
const loadedFiles = loadEnvironmentFiles(environmentName);

if (process.env.USE_AWS_SECRETS_MANAGER === 'true') {
await loadSecrets();
}

if (process.env.USE_VAULT === 'true') {
const secretPath = process.env.VAULT_SECRET_PATH;
if (!secretPath) {
throw new Error('VAULT_SECRET_PATH is required when Vault integration is enabled');
}
const vaultSecrets = await readVaultSecret(secretPath);
Object.entries(vaultSecrets || {}).forEach(([key, value]) => {
if (typeof value === 'string' && !Object.prototype.hasOwnProperty.call(process.env, key)) {
process.env[key] = value;
}
});
}

return {
environmentName,
loadedFiles,
hasAwsSecrets: process.env.USE_AWS_SECRETS_MANAGER === 'true',
hasVault: process.env.USE_VAULT === 'true',
};
}

module.exports = {
loadConfiguration,
applyParsedEnv,
resolveCandidateFiles,
};
114 changes: 114 additions & 0 deletions api/middleware/authRateLimiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const crypto = require('crypto');

const DEFAULTS = {
maxAttempts: 5,
lockoutMs: 15 * 60 * 1000,
baseDelayMs: 500,
cooldownMultiplier: 2,
};

const state = {
failures: new Map(),
lockouts: new Map(),
};

function getIdentifier(req) {
const address = req.body && req.body.address ? String(req.body.address) : '';
return `${req.ip || 'unknown'}:${address}`;
}

function getFailureEntry(identifier) {
return state.failures.get(identifier) || { count: 0, lastFailureAt: 0 };
}

function resetAuthRateLimitState() {
state.failures.clear();
state.lockouts.clear();
}

function recordFailure(identifier) {
const entry = getFailureEntry(identifier);
entry.count += 1;
entry.lastFailureAt = Date.now();
state.failures.set(identifier, entry);
console.warn(`[auth-rate-limit] failure recorded for ${identifier} (attempt ${entry.count})`);
return entry;
}

function createAuthRateLimiter(options = {}) {
const config = { ...DEFAULTS, ...options };

return function authRateLimiter(req, res, next) {
const identifier = getIdentifier(req);
const now = Date.now();
const lockout = state.lockouts.get(identifier);

if (lockout && lockout.expiresAt > now) {
return res.status(429).json({
error: 'Too many failed authentication attempts. Please try again later.',
code: 'AUTH_RATE_LIMITED',
retryAfterSeconds: Math.ceil((lockout.expiresAt - now) / 1000),
});
}

if (lockout && lockout.expiresAt <= now) {
state.lockouts.delete(identifier);
}

const failureEntry = getFailureEntry(identifier);
if (failureEntry.count >= config.maxAttempts) {
const lockoutUntil = now + config.lockoutMs;
state.lockouts.set(identifier, { expiresAt: lockoutUntil });
return res.status(429).json({
error: 'Too many failed authentication attempts. Please try again later.',
code: 'AUTH_RATE_LIMITED',
retryAfterSeconds: Math.ceil(config.lockoutMs / 1000),
});
}

const delayMs = config.baseDelayMs * Math.pow(config.cooldownMultiplier, failureEntry.count);
if (delayMs > 0) {
const start = Date.now();
while (Date.now() - start < delayMs) {}
}

req.authRateLimit = { identifier, failureEntry };
return next();
};
}

function applyAuthFailure(req) {
const identifier = req.authRateLimit && req.authRateLimit.identifier ? req.authRateLimit.identifier : getIdentifier(req);
const entry = recordFailure(identifier);
const lockoutMs = DEFAULTS.lockoutMs;
if (entry.count >= DEFAULTS.maxAttempts) {
state.lockouts.set(identifier, { expiresAt: Date.now() + lockoutMs });
console.warn(`[auth-rate-limit] account locked for ${identifier}`);
}
}

function resetAuthFailure(req) {
const identifier = req.authRateLimit && req.authRateLimit.identifier ? req.authRateLimit.identifier : getIdentifier(req);
resetAuthRateLimit(identifier);
}

function resetAuthRateLimit(identifier) {
if (identifier) {
state.failures.delete(identifier);
state.lockouts.delete(identifier);
console.info(`[auth-rate-limit] cleared state for ${identifier}`);
return;
}

state.failures.clear();
state.lockouts.clear();
console.info('[auth-rate-limit] cleared all state');
}

module.exports = {
createAuthRateLimiter,
applyAuthFailure,
resetAuthFailure,
resetAuthRateLimitState,
resetAuthRateLimit,
};
61 changes: 61 additions & 0 deletions api/middleware/authRateLimiter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const { createAuthRateLimiter, resetAuthRateLimitState, applyAuthFailure } = require('./authRateLimiter');

function createMockResponse() {
return {
statusCode: 200,
status(status) {
this.statusCode = status;
return this;
},
json(payload) {
return payload;
},
send(payload) {
return payload;
},
};
}

describe('auth rate limiter', () => {
beforeEach(() => {
jest.useFakeTimers();
resetAuthRateLimitState();
});

afterEach(() => {
jest.useRealTimers();
resetAuthRateLimitState();
});

it('locks an address after repeated failures', () => {
const limiter = createAuthRateLimiter({ maxAttempts: 2, lockoutMs: 1000, baseDelayMs: 0 });
const req = { ip: '203.0.113.10', body: { address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' } };
const res = createMockResponse();
const next = jest.fn();

limiter(req, res, next);
applyAuthFailure(req);
applyAuthFailure(req);

const secondResponse = createMockResponse();
limiter(req, secondResponse, next);

expect(next).toHaveBeenCalled();
expect(secondResponse.statusCode).toBe(429);
});

it('blocks requests while the account is locked', () => {
const limiter = createAuthRateLimiter({ maxAttempts: 1, lockoutMs: 1000, baseDelayMs: 0 });
const req = { ip: '203.0.113.20', body: { address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' } };
const res = createMockResponse();
const next = jest.fn();

limiter(req, res, next);
applyAuthFailure(req);

const lockedResponse = createMockResponse();
limiter(req, lockedResponse, next);

expect(lockedResponse.statusCode).toBe(429);
});
});
16 changes: 16 additions & 0 deletions api/routes/auth-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const express = require('express');
const { resetAuthRateLimit } = require('../middleware/authRateLimiter');

const router = express.Router();

router.post('/unlock', (req, res) => {
const identifier = req.body && req.body.identifier ? String(req.body.identifier) : '';
if (!identifier) {
return res.status(400).json({ error: 'identifier is required', code: 'IDENTIFIER_REQUIRED' });
}

resetAuthRateLimit(identifier);
return res.json({ success: true, message: `Unlocked ${identifier}` });
});

module.exports = router;
12 changes: 12 additions & 0 deletions api/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ const jwt = require('jsonwebtoken');
const { Keypair } = require('stellar-sdk');
const { body, validationResult } = require('express-validator');
const { JWT_SECRET } = require('../middleware/auth');
const { createAuthRateLimiter, applyAuthFailure, resetAuthFailure } = require('../middleware/authRateLimiter');

const router = express.Router();
const authLimiter = createAuthRateLimiter({
maxAttempts: parseInt(process.env.AUTH_MAX_ATTEMPTS || '5', 10),
lockoutMs: parseInt(process.env.AUTH_LOCKOUT_MS || String(15 * 60 * 1000), 10),
baseDelayMs: parseInt(process.env.AUTH_BASE_DELAY_MS || '500', 10),
});

// In-memory nonce store: address -> { nonce, expiresAt }
// In production replace with Redis or a DB-backed store.
Expand Down Expand Up @@ -65,6 +71,7 @@ setInterval(() => {
*/
router.post(
'/challenge',
authLimiter,
[body('address').matches(/^G[A-Z0-9]{55}$/).withMessage('Invalid Stellar address')],
(req, res) => {
const errors = validationResult(req);
Expand Down Expand Up @@ -125,6 +132,7 @@ router.post(
*/
router.post(
'/verify',
authLimiter,
[
body('address').matches(/^G[A-Z0-9]{55}$/).withMessage('Invalid Stellar address'),
body('signature').isHexadecimal().withMessage('Signature must be hex-encoded'),
Expand All @@ -140,6 +148,7 @@ router.post(

if (!entry || Date.now() > entry.expiresAt) {
nonceStore.delete(address);
applyAuthFailure(req);
return res.status(401).json({ error: 'Nonce expired or not found. Request a new challenge.', code: 'NONCE_EXPIRED' });
}

Expand All @@ -150,14 +159,17 @@ router.post(
const sigBytes = Buffer.from(signature, 'hex');
const valid = keypair.verify(nonceBytes, sigBytes);
if (!valid) {
applyAuthFailure(req);
return res.status(401).json({ error: 'Signature verification failed', code: 'INVALID_SIGNATURE' });
}
} catch {
applyAuthFailure(req);
return res.status(401).json({ error: 'Signature verification failed', code: 'INVALID_SIGNATURE' });
}

// Consume nonce (one-time use)
nonceStore.delete(address);
resetAuthFailure(req);

const token = jwt.sign({ sub: address }, JWT_SECRET, { expiresIn: JWT_EXPIRY });
res.json({ token, expiresIn: JWT_EXPIRY });
Expand Down
6 changes: 4 additions & 2 deletions api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ createBullBoard({
serverAdapter,
});

const { loadSecrets } = require('./services/secretsService');
const { loadConfiguration } = require('./config/environment');
const { closePool } = require('./services/dbService');
const authMiddleware = require('./middleware/auth');
const errorHandler = require('./middleware/errorHandler');
const { versionHeader, deprecationWarning } = require('./middleware/versioning');
const authRoutes = require('./routes/auth');
const authAdminRoutes = require('./routes/auth-admin');
const streamRoutes = require('./routes/streams');
const tokenRoutes = require('./routes/tokens');
const adminRoutes = require('./routes/admin');
Expand Down Expand Up @@ -363,6 +364,7 @@ app.post('/streams/:id/withdraw', (req, res) => {

// Auth routes (public — no authMiddleware)
app.use('/auth', authRoutes);
app.use('/auth/admin', authAdminRoutes);

// v1 API routes (current)
app.use('/v1/api/streams', authMiddleware, streamRoutes);
Expand Down Expand Up @@ -436,7 +438,7 @@ process.on('SIGINT', () => shutdown('SIGINT'));

async function start() {
try {
await loadSecrets();
await loadConfiguration();

server = http.createServer(app);
server.listen(PORT, () => {
Expand Down
Loading