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
4 changes: 4 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import feeRoutes from "./routes/fees";
import reconciliationRoutes from "./routes/reconciliation";
import auditRoutes from "./routes/audit";
import stressTestRoutes from "./routes/stressTest";
import walletRoutes from "./routes/wallets";
import reportRoutes from "./routes/reports";
import { config } from "./config";
import { logger } from "./logger";
import { createContainer } from "./container";
Expand Down Expand Up @@ -86,6 +88,8 @@ export async function buildApp() {
await app.register(contractRoutes, { prefix });
await app.register(refundsRoutes, { prefix });
await app.register(notificationRoutes, { prefix });
await app.register(walletRoutes, { prefix });
await app.register(reportRoutes, { prefix });
await app.register(complianceRoutes, { prefix });
await app.register(errorRoutes, { prefix });
await app.register(feeRoutes, { prefix });
Expand Down
6 changes: 6 additions & 0 deletions backend/src/auth/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,9 @@ export const resendRateLimiter = new RateLimiter({
windowMs: 60 * 60 * 1000, // 1 hour
lockoutDurationMs: 60 * 60 * 1000, // 1 hour
});

export const recoveryRateLimiter = new RateLimiter({
maxAttempts: 5,
windowMs: 30 * 60 * 1000, // 30 minutes
lockoutDurationMs: 60 * 60 * 1000, // 1 hour
});
8 changes: 8 additions & 0 deletions backend/src/auth/sessionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export interface Session {
verified: boolean;
hasWallet: boolean;
onboardingCompleted: boolean;
/**
* When true, the session must complete a step-up verification challenge
* before accessing protected routes (forced re-authentication).
*/
reauthRequired?: boolean;
reauthReason?: 'new_ip' | 'new_device' | 'risk';
reauthFactors?: string[];
reauthAssessedAt?: string;
role?: UserRole;
user?: PublicUser;
metadata: SessionMetadata;
Expand Down
10 changes: 10 additions & 0 deletions backend/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface AppContainer {
stellarFee: StellarFeeService;
adminAlerts: AdminAlertService;
operationalMetrics: OperationalMetricsService;
auditStorage: AuditStorageService;
authRiskEngine: AuthRiskEngine;
deadLetterQueue: DeadLetterQueue;
settlementAnalytics: SettlementAnalyticsService;
stellarMonitor: StellarMonitorService;

};
}
Expand Down Expand Up @@ -159,6 +164,11 @@ export function createContainer(): AppContainer {
stellarFee,
adminAlerts,
operationalMetrics,
auditStorage,
authRiskEngine,
deadLetterQueue,
settlementAnalytics,
stellarMonitor,

},
};
Expand Down
82 changes: 81 additions & 1 deletion backend/src/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { JwtSessionPayload } from '../auth/sessionTypes';
import { getSession, touchSession } from '../auth/sessionStore';
import { getSession, saveSession, touchSession } from '../auth/sessionStore';

export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
Expand Down Expand Up @@ -33,4 +33,84 @@ export async function requireVerifiedSession(request: FastifyRequest, reply: Fas
await reply.code(403).send({ error: 'Verification required' });
return;
}

// Session anomaly / forced re-authentication gate (step-up)
// Uses the existing risk engine (new IP/device/rapid attempts/unusual time).
const userAgent = request.headers['user-agent'];
const riskAssessment = request.server.container.services.authRiskEngine.assessRisk(
session.id,
request.ip,
typeof userAgent === 'string' ? userAgent : undefined,
);

if (riskAssessment.requiresBlock) {
try {
await request.server.container.services.notification.notifySecurityEvent({
userId: session.id,
kind: 'access_blocked',
title: 'Suspicious sign-in blocked',
message: `We blocked access due to suspicious activity. ${riskAssessment.factors.join('; ')}`,
metadata: {
riskLevel: riskAssessment.level,
score: riskAssessment.score,
factors: riskAssessment.factors,
},
});
} catch {
// notifications are best-effort
}
await reply.code(403).send({
error: 'Access blocked due to suspicious activity',
code: 'access_blocked',
riskAssessment: {
level: riskAssessment.level,
score: riskAssessment.score,
factors: riskAssessment.factors,
assessedAt: riskAssessment.assessedAt,
},
});
return;
}

if (session.reauthRequired || riskAssessment.requiresStepUp) {
// If the risk engine says we need step-up, persist the reason on the session.
if (!session.reauthRequired && riskAssessment.requiresStepUp) {
session.reauthRequired = true;
session.reauthReason = riskAssessment.factors.some((f) => f.toLowerCase().includes('new ip'))
? 'new_ip'
: riskAssessment.factors.some((f) => f.toLowerCase().includes('unrecognized device'))
? 'new_device'
: 'risk';
session.reauthFactors = riskAssessment.factors;
session.reauthAssessedAt = riskAssessment.assessedAt;
saveSession(session);

try {
await request.server.container.services.notification.notifySecurityEvent({
userId: session.id,
kind: 'reauth_required',
title: 'Re-authentication required',
message: `We detected a session change and need you to verify again. ${riskAssessment.factors.join('; ')}`,
metadata: {
riskLevel: riskAssessment.level,
score: riskAssessment.score,
factors: riskAssessment.factors,
},
});
} catch {
// notifications are best-effort
}
}

await reply.code(403).send({
error: 'Re-authentication required',
code: 'reauth_required',
details: {
reason: session.reauthReason ?? 'risk',
factors: session.reauthFactors ?? riskAssessment.factors,
assessedAt: session.reauthAssessedAt ?? riskAssessment.assessedAt,
},
});
return;
}
}
15 changes: 15 additions & 0 deletions backend/src/modules/notifications/notificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ export class NotificationService {
});
}

async notifySecurityEvent(payload: {
userId: string;
kind: 'reauth_required' | 'access_blocked' | 'step_up_completed';
title: string;
message: string;
metadata?: Record<string, unknown>;
}) {
return this.createForUser(payload.userId, {
type: payload.kind === 'step_up_completed' ? 'success' : payload.kind === 'access_blocked' ? 'error' : 'warning',
title: payload.title,
message: payload.message,
metadata: { kind: payload.kind, ...(payload.metadata ?? {}) },
});
}

private async createForUser(
userId: string,
input: {
Expand Down
50 changes: 50 additions & 0 deletions backend/src/modules/wallets/linkedWalletStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { PublicUser } from '../../auth/sessionTypes';

export interface LinkedWalletRecord {
id: string;
publicKey: string;
provider: string;
label: string;
isPrimary: boolean;
linkedAt: string;
}

function keyForUser(user: { id: string; email?: string; phone?: string }) {
const email = user.email?.toLowerCase().trim();
const phone = user.phone?.trim();
return email ? `email:${email}` : phone ? `phone:${phone}` : `id:${user.id}`;
}

// In-memory user wallet linkage store (MVP).
const store = new Map<string, LinkedWalletRecord[]>();

export function listLinkedWallets(user: PublicUser): LinkedWalletRecord[] {
return store.get(keyForUser(user)) || user.wallets || [];
}

export function upsertLinkedWallet(user: PublicUser, wallet: LinkedWalletRecord): LinkedWalletRecord[] {
const key = keyForUser(user);
const existing = store.get(key) || user.wallets || [];
const next = existing.some((w) => w.publicKey === wallet.publicKey)
? existing.map((w) => (w.publicKey === wallet.publicKey ? { ...w, ...wallet } : w))
: [wallet, ...existing];
store.set(key, next);
return next;
}

export function removeLinkedWallet(user: PublicUser, walletId: string): LinkedWalletRecord[] {
const key = keyForUser(user);
const existing = store.get(key) || user.wallets || [];
const next = existing.filter((w) => w.id !== walletId);
store.set(key, next);
return next;
}

export function setPrimaryLinkedWallet(user: PublicUser, walletId: string): LinkedWalletRecord[] {
const key = keyForUser(user);
const existing = store.get(key) || user.wallets || [];
const next = existing.map((w) => ({ ...w, isPrimary: w.id === walletId }));
store.set(key, next);
return next;
}

14 changes: 12 additions & 2 deletions backend/src/routes/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { requireVerifiedSession } from '../middleware/authenticate';

interface ActivityQuery {
limit?: string;
compact?: string;
}

interface SearchQuery {
Expand All @@ -27,7 +28,10 @@ export default async function activityRoutes(fastify: FastifyInstance) {
async (req, reply) => {
const session = requireSessionUser(req.user as JwtSessionPayload, reply);
if (!session) return;
const limit = sanitizeLimit(req.query?.limit, 50, 100);
const headerCompact = req.headers['x-low-bandwidth'] === '1';
const queryCompact = req.query?.compact === '1' || req.query?.compact === 'true';
const compact = headerCompact || queryCompact;
const limit = sanitizeLimit(req.query?.limit, compact ? 20 : 50, compact ? 50 : 100);
reply.header('Cache-Control', 'private, max-age=10');
return {
items: await fastify.container.services.activity.listTransactions(session.user!.id, limit),
Expand Down Expand Up @@ -102,7 +106,13 @@ export default async function activityRoutes(fastify: FastifyInstance) {
const session = requireSessionUser(req.user as JwtSessionPayload, reply);
if (!session) return;
reply.header('Cache-Control', 'private, max-age=10');
return fastify.container.services.activity.getSpendingInsights(session.user!.id);
const compact = req.headers['x-low-bandwidth'] === '1';
const insights = await fastify.container.services.activity.getSpendingInsights(session.user!.id);
if (!compact) {
return insights;
}
// Compact view: keep only the summary for low-bandwidth clients.
return { summary: insights.summary };
},
);

Expand Down
81 changes: 81 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
getSessionInfo,
isMariaIdentifier,
saveSession,
trustIp,
updateLastKnownIp,
} from '../auth/sessionStore';
import { authenticate } from '../middleware/authenticate';
import { deleteCachedKey, getCachedJson, setCachedJson } from '../utils/redisCache';
Expand All @@ -22,6 +24,10 @@ interface VerifyBody {
code: string;
}

interface StepUpVerifyBody {
code: string;
}

interface OnboardingBody {
name?: string;
email?: string;
Expand Down Expand Up @@ -226,6 +232,81 @@ export default async function authRoutes(fastify: FastifyInstance) {
});
});

/**
* Step-up verification used for forced re-authentication when session anomaly is detected.
* For now, this reuses the same OTP-style code logic as /auth/verify.
*/
fastify.post<{ Body: StepUpVerifyBody }>('/auth/step-up/verify', { preHandler: [authenticate] }, async (request, reply) => {
const code = request.body?.code?.trim();
if (!code) {
return reply.status(400).send({ error: 'code is required' });
}

const token = request.user as JwtSessionPayload;
const session = getSession(token.sub);
if (!session) {
clearAuthCookie(reply);
return reply.status(401).send({ error: 'Session expired' });
}

if (!session.verified) {
return reply.status(403).send({ error: 'Verification required' });
}

if (!isValidVerificationCode(code)) {
fastify.container.services.authRiskEngine.recordAuthEvent({
userId: session.id,
type: 'step_up',
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
success: false,
timestamp: new Date().toISOString(),
});
return reply.status(400).send({ error: 'Invalid verification code' });
}

session.reauthRequired = false;
session.reauthReason = undefined;
session.reauthFactors = undefined;
session.reauthAssessedAt = undefined;

// Trust the current client context.
updateLastKnownIp(session.id, request.ip);
trustIp(session.id, request.ip);

saveSession(session);
await deleteCachedKey(`auth:me:${session.id}`);
await setAuthCookie(reply, session);

fastify.container.services.authRiskEngine.recordAuthEvent({
userId: session.id,
type: 'step_up',
ipAddress: request.ip,
userAgent: request.headers['user-agent'],
success: true,
timestamp: new Date().toISOString(),
});

try {
await fastify.container.services.notification.notifySecurityEvent({
userId: session.id,
kind: 'step_up_completed',
title: 'Verification complete',
message: 'You’re verified and can continue using your account.',
metadata: { ip: request.ip },
});
} catch {
// best-effort
}

return reply.send({
ok: true,
authUser: sessionToAuthUser(session),
session: getSessionInfo(session),
user: session.user ?? null,
});
});

fastify.post('/auth/verify/unlock', { preHandler: [authenticate] }, async (request, reply) => {
const token = request.user as JwtSessionPayload;
const session = getSession(token.sub);
Expand Down
13 changes: 8 additions & 5 deletions backend/src/routes/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { JwtSessionPayload } from "../auth/sessionTypes";

interface NotificationsQuery {
limit?: string;
compact?: string;
}

export default async function notificationRoutes(fastify: FastifyInstance) {
Expand All @@ -15,11 +16,13 @@ export default async function notificationRoutes(fastify: FastifyInstance) {
authGuards,
async (req) => {
const payload = req.user as JwtSessionPayload;
const limit = Number(req.query.limit || 20);
return fastify.container.services.notification.listByUserId(
payload.sub,
limit,
);
const headerCompact = req.headers['x-low-bandwidth'] === '1';
const queryCompact = req.query.compact === '1' || req.query.compact === 'true';
const compact = headerCompact || queryCompact;
const fallbackLimit = compact ? 10 : 20;
const hardMax = compact ? 25 : 100;
const limit = Math.min(hardMax, Math.max(1, Number(req.query.limit || fallbackLimit)));
return fastify.container.services.notification.listByUserId(payload.sub, limit);
},
);

Expand Down
Loading
Loading