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
12 changes: 12 additions & 0 deletions backend/src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,17 @@ export function createStellarError(message, stellarError = null) {
return new StellarError(message, stellarError);
}

/**
* Send a standard error envelope directly from a route handler.
* Use this only when you cannot use next(err) — e.g. inside a non-Express callback.
* Prefer throwing AppError + asyncHandler for route code.
*/
export function sendError(res, statusCode, code, message, details) {
const body = { success: false, error: { code, message } };
if (details !== undefined && details !== null) body.error.details = details;
return res.status(statusCode).json(body);
}

export default {
AppError,
StellarError,
Expand All @@ -345,6 +356,7 @@ export default {
createError,
createValidationError,
createStellarError,
sendError,
generateRequestId,
attachRequestIdToError,
};
51 changes: 29 additions & 22 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { hashPassword, verifyPassword } from '../auth/password.js';
import { createUser, findUser, getUserById, updateUserPassword } from '../auth/userStore.js';
import { signAccessToken, signRefreshToken, verifyToken } from '../auth/tokens.js';
import { requireAuth } from '../middleware/auth.js';
import { sendError, ErrorCodes } from '../middleware/errorHandler.js';
import { consumePendingCredentials } from '../recovery/recoveryStore.js';
import prisma from '../db/client.js';
import { createRateLimiter } from '../middleware/rateLimiter.js';
Expand Down Expand Up @@ -46,7 +47,8 @@ const authRateLimiter = createRateLimiter({

const validateBody = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
if (!errors.isEmpty())
return sendError(res, 422, ErrorCodes.VALIDATION_INVALID_INPUT, 'Validation failed', errors.array());
next();
};

Expand Down Expand Up @@ -110,7 +112,7 @@ router.post('/register', authRateLimiter, userRules, validateBody, async (req, r
const user = createUser(username, passwordHash);
res.status(201).json({ user });
} catch (error) {
res.status(409).json({ error: error.message });
sendError(res, 409, ErrorCodes.CONFLICT, error.message);
}
});

Expand Down Expand Up @@ -184,15 +186,19 @@ router.post('/login', loginRateLimiter, userRules, validateBody, async (req, res
if (locked) {
const retryAfter = Math.ceil(getLockoutDuration() / 1000);
return res.status(423).set('Retry-After', retryAfter).json({
error: 'Account is temporarily locked due to too many failed login attempts',
retryAfter,
success: false,
error: {
code: ErrorCodes.UNAUTHORIZED,
message: 'Account is temporarily locked due to too many failed login attempts',
details: { retryAfter },
},
});
}

const user = findUser(username);
if (!user) {
await recordFailedLogin(username, ipAddress);
return res.status(401).json({ error: 'Invalid credentials' });
return sendError(res, 401, ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Invalid credentials');
}

// Check for pending recovered credentials first
Expand All @@ -211,12 +217,12 @@ router.post('/login', loginRateLimiter, userRules, validateBody, async (req, res
});
}
await recordFailedLogin(username, ipAddress);
return res.status(401).json({ error: 'Invalid credentials' });
return sendError(res, 401, ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Invalid credentials');
}

if (!(await verifyPassword(password, user.passwordHash))) {
await recordFailedLogin(username, ipAddress);
return res.status(401).json({ error: 'Invalid credentials' });
return sendError(res, 401, ErrorCodes.AUTH_INVALID_CREDENTIALS, 'Invalid credentials');
}

// Successful login - clear failed attempts
Expand Down Expand Up @@ -264,14 +270,15 @@ router.post('/login', loginRateLimiter, userRules, validateBody, async (req, res
*/
router.post('/refresh', (req, res) => {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'Refresh token missing or expired' });
if (!refreshToken)
return sendError(res, 401, ErrorCodes.AUTH_INVALID_TOKEN, 'Refresh token missing or expired');
try {
const { sub, username } = verifyToken(refreshToken);
const newRefreshToken = signRefreshToken({ sub, username });
setRefreshTokenCookie(res, newRefreshToken);
res.json({ accessToken: signAccessToken({ sub, username }) });
} catch {
res.status(401).json({ error: 'Invalid or expired refresh token' });
sendError(res, 401, ErrorCodes.AUTH_INVALID_TOKEN, 'Invalid or expired refresh token');
}
});

Expand Down Expand Up @@ -335,7 +342,7 @@ router.post('/logout', requireAuth, (_req, res) => {
*/
router.get('/profile', requireAuth, (req, res) => {
const user = getUserById(req.user.sub);
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) return sendError(res, 404, ErrorCodes.NOT_FOUND, 'User not found');
res.json({ id: user.id, username: user.username, createdAt: user.createdAt });
});

Expand Down Expand Up @@ -386,7 +393,7 @@ router.post('/admin/unlock', requireAuth, async (req, res) => {

const user = getUserById(req.user.sub);
if (!user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
return sendError(res, 403, ErrorCodes.FORBIDDEN, 'Admin access required');
}

try {
Expand All @@ -395,7 +402,7 @@ router.post('/admin/unlock', requireAuth, async (req, res) => {
res.json({ message: `Account ${username} has been unlocked` });
} catch (err) {
logger.error({ err, username }, 'Failed to unlock account');
res.status(500).json({ error: 'Failed to unlock account' });
sendError(res, 500, ErrorCodes.INTERNAL_ERROR, 'Failed to unlock account');
}
});

Expand All @@ -412,20 +419,20 @@ router.post('/mfa/setup', requireAuth, async (req, res) => {
message: 'Scan the QR code with your authenticator app',
});
} catch (error) {
res.status(500).json({ error: error.message });
sendError(res, 500, ErrorCodes.INTERNAL_ERROR, error.message);
}
});

router.post('/mfa/verify', requireAuth, (req, res) => {
const { token } = req.body;
if (!token) return res.status(400).json({ error: 'Token required' });
if (!token) return sendError(res, 400, ErrorCodes.VALIDATION_MISSING_FIELD, 'Token required');
try {
const mfa = mfaManager.userMFA.get(req.user.sub);
if (!mfa) return res.status(400).json({ error: 'MFA setup not initiated' });
if (!mfa) return sendError(res, 400, ErrorCodes.VALIDATION_INVALID_INPUT, 'MFA setup not initiated');
mfaManager.verifyTOTP(req.user.sub, token, mfa.secret);
res.json({ message: 'MFA enabled successfully' });
} catch (error) {
res.status(403).json({ error: error.message });
sendError(res, 403, ErrorCodes.FORBIDDEN, error.message);
}
});

Expand Down Expand Up @@ -495,7 +502,7 @@ router.get('/oauth/google/callback', async (req, res) => {
const storedState = req.cookies.oauth_state;

if (!code || !state || state !== storedState) {
return res.status(400).json({ error: 'Invalid state or authorization code' });
return sendError(res, 400, ErrorCodes.VALIDATION_INVALID_INPUT, 'Invalid state or authorization code');
}

try {
Expand Down Expand Up @@ -531,7 +538,7 @@ router.get('/oauth/google/callback', async (req, res) => {
`${frontendUrl}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`
);
} catch (error) {
res.status(400).json({ error: error.message });
sendError(res, 400, ErrorCodes.INTERNAL_ERROR, error.message);
}
});

Expand Down Expand Up @@ -607,15 +614,15 @@ router.get('/data-export', requireAuth, async (req, res) => {
},
},
});
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) return sendError(res, 404, ErrorCodes.NOT_FOUND, 'User not found');

const { passwordHash: _omit, ...exportData } = user;

res.setHeader('Content-Disposition', 'attachment; filename="data-export.json"');
res.json({ exportedAt: new Date().toISOString(), data: exportData });
} catch (error) {
logger.error({ err: error, userId }, 'data-export failed');
res.status(500).json({ error: 'Failed to export user data' });
sendError(res, 500, ErrorCodes.INTERNAL_ERROR, 'Failed to export user data');
}
});

Expand Down Expand Up @@ -695,10 +702,10 @@ router.delete('/account', requireAuth, async (req, res) => {
});
} catch (error) {
if (error.code === 'P2025') {
return res.status(404).json({ error: 'User not found' });
return sendError(res, 404, ErrorCodes.NOT_FOUND, 'User not found');
}
logger.error({ err: error, userId }, 'account deletion failed');
res.status(500).json({ error: 'Failed to delete account' });
sendError(res, 500, ErrorCodes.INTERNAL_ERROR, 'Failed to delete account');
}
});

Expand Down
50 changes: 38 additions & 12 deletions backend/src/services/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,28 @@ export function initWebSocket(server) {
stats.totalConnections++;
stats.activeConnections++;
ws.isAlive = true;
ws.authenticated = false;

// Authenticate at connection time so clients receive close code 4001
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
const url = new URL(req.url, 'ws://localhost');
const token =
url.searchParams.get('token') ||
(req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null);
const claims = token ? verifyToken(token) : null;
if (!claims) {
stats.authFailures++;
stats.activeConnections = Math.max(0, stats.activeConnections - 1);
ws.close(4001, 'Unauthorized');
return;
}
ws.userId = claims.sub ?? claims.userId;
ws.userPublicKey = claims.publicKey ?? null;
}

ws.authenticated = true;

ws.on('pong', () => { ws.isAlive = true; });

Expand Down Expand Up @@ -151,26 +172,25 @@ function handleMessage(ws, msg) {
}

function handleAuth(ws, msg) {
// Authentication is handled at handshake time; this message is a no-op for
// already-authenticated connections but kept for protocol compatibility.
if (ws.authenticated) {
ws.send(JSON.stringify({ type: 'auth_ok' }));
return;
}
// Dev mode (no JWT_SECRET): allow post-connection auth via message.
const jwtSecret = process.env.JWT_SECRET;
// If no JWT_SECRET configured, allow unauthenticated (dev mode)
if (!jwtSecret) {
ws.authenticated = true;
ws.send(JSON.stringify({ type: 'auth_ok' }));
return;
}
const claims = verifyToken(msg.token);
if (!claims) {
stats.authFailures++;
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid or expired token' }));
return;
}
ws.authenticated = true;
ws.userId = claims.sub ?? claims.userId;
ws.send(JSON.stringify({ type: 'auth_ok' }));
stats.authFailures++;
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid or expired token' }));
}

function handleSubscribe(ws, msg) {
if (process.env.JWT_SECRET && !ws.authenticated) {
if (!ws.authenticated) {
ws.send(JSON.stringify({ type: 'error', message: 'Authenticate first' }));
return;
}
Expand All @@ -179,6 +199,12 @@ function handleSubscribe(ws, msg) {
ws.send(JSON.stringify({ type: 'error', message: 'publicKey required' }));
return;
}
// Scope check: reject subscriptions to keys the user does not own.
// ws.userPublicKey is populated from the JWT claim; null means dev mode (no restriction).
if (ws.userPublicKey && publicKey !== 'rates' && publicKey !== ws.userPublicKey) {
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized: cannot subscribe to another account' }));
return;
}
if (connectionCount(publicKey) >= MAX_CONNECTIONS_PER_KEY) {
ws.send(JSON.stringify({ type: 'error', message: 'Connection limit reached for this account' }));
return;
Expand Down
43 changes: 43 additions & 0 deletions backend/src/utils/errorMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const STELLAR_RESULT_CODES: Record<string, string> = {
tx_success: 'Transaction completed successfully.',
tx_failed: 'Transaction failed.',
tx_too_early: 'Transaction timestamp is too early.',
tx_too_late: 'Transaction timestamp is too late.',
tx_missing_operation: 'Transaction has no operations.',
tx_bad_seq: 'Transaction sequence error. Please refresh and try again.',
tx_bad_auth: 'Transaction authentication failed.',
tx_insufficient_balance: 'Insufficient balance for this transaction.',
tx_no_source_account: 'Source account does not exist.',
tx_insufficient_fee: 'Transaction fee is too low.',
tx_fee_bump_inner_failed: 'Inner transaction of fee bump failed.',
tx_bad_auth_extra: 'Extra signers provided but not required.',
tx_internal_error: 'Internal Stellar network error.',
tx_not_supported: 'Transaction type is not supported.',
tx_bad_sponsorship: 'Sponsorship setup is invalid.',
tx_bad_min_seq_age: 'Minimum sequence age requirement not met.',
tx_malformed: 'Transaction is malformed.',
op_success: 'Operation completed successfully.',
op_inner: 'Operation failed with inner error.',
op_bad_auth: 'Operation authentication failed.',
op_no_destination: 'Destination account does not exist.',
op_no_trust: 'Destination has no trust line for this asset.',
op_not_authorized: 'Operation not authorized.',
op_underfunded: 'Insufficient funds — please top up your account.',
op_line_full: 'Destination trust line is full.',
op_self_not_allowed: 'Cannot send to your own account.',
op_not_supported: 'Operation type is not supported.',
op_too_many_subentries: 'Account has too many subentries.',
op_exceed_work_limit: 'Operation exceeded the network work limit.',
op_too_many_sponsoring: 'Too many sponsored entries.',
};

export function getStellarErrorKey(code: string): string | null {
if (code in STELLAR_RESULT_CODES) return `stellarErrors.${code}`;
return null;
}

export function getFriendlyError(code: string): string {
return STELLAR_RESULT_CODES[code] ?? `Unknown error: ${code}`;
}

export { STELLAR_RESULT_CODES };
Loading
Loading