mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
refactor: extract smirk auth routes from index.js
The three /api/auth/smirk/* endpoints move to src/routes/auth.js as a DI factory createAuthRoutes(ctx), mounted conditionally on SMIRK_ENABLED. Handler bodies unchanged. index.js: 1047 -> 882 lines (was 2029 before the split began). Adds an auth smoke test. Full suite: 21 suites / 155 tests.
This commit is contained in:
+3
-168
@@ -33,6 +33,7 @@ const SocketHandlers = require('./network/socketHandlers');
|
||||
const { verifyGame, hashSeed } = require('./game/provablyFair');
|
||||
const { renderVerifyPage } = require('./views/verifyPage');
|
||||
const createAdminRoutes = require('./routes/admin');
|
||||
const createAuthRoutes = require('./routes/auth');
|
||||
|
||||
// Initialize payment configuration
|
||||
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
||||
@@ -494,176 +495,10 @@ app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
|
||||
// Smirk is a browser extension wallet for Wownero/Monero.
|
||||
// Disable for Monero stagenet deployments (Smirk doesn't support stagenet).
|
||||
const smirkEnabled = process.env.SMIRK_ENABLED !== 'false';
|
||||
|
||||
if (smirkEnabled) {
|
||||
/**
|
||||
* POST /api/auth/smirk/challenge
|
||||
* Generate a challenge for Smirk wallet signature verification
|
||||
* Body: { socketId: string }
|
||||
*/
|
||||
app.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.body || {};
|
||||
app.use(createAuthRoutes({ db: databaseManager }));
|
||||
}
|
||||
|
||||
if (!socketId || typeof socketId !== 'string') {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId is required to generate a challenge.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a cryptographically secure challenge
|
||||
const challenge = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Store challenge in database (expires in 5 minutes)
|
||||
await db.query(`
|
||||
INSERT INTO smirk_challenges (challenge, socket_id)
|
||||
VALUES ($1, $2)
|
||||
`, [challenge, socketId]);
|
||||
|
||||
// Clean up old/expired challenges periodically
|
||||
await db.query(`
|
||||
DELETE FROM smirk_challenges
|
||||
WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour')
|
||||
`);
|
||||
|
||||
res.json({
|
||||
challenge,
|
||||
expiresIn: 300 // 5 minutes in seconds
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/auth/smirk/verify
|
||||
* Verify a Smirk wallet signature and link to user session
|
||||
* Body: { socketId: string, challenge: string, publicKey: string, signature: string }
|
||||
*/
|
||||
app.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
|
||||
const { socketId, challenge, publicKey, signature } = req.body || {};
|
||||
|
||||
if (!socketId || !challenge || !publicKey || !signature) {
|
||||
throw new ValidationError('Missing required fields', {
|
||||
safeMessage: 'socketId, challenge, publicKey, and signature are required.'
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Verify challenge exists, is not used, and has not expired
|
||||
const challengeResult = await db.query(`
|
||||
SELECT * FROM smirk_challenges
|
||||
WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
|
||||
`, [challenge, socketId]);
|
||||
|
||||
if (challengeResult.rows.length === 0) {
|
||||
throw new ValidationError('Invalid or expired challenge', {
|
||||
safeMessage: 'The challenge is invalid, expired, or has already been used.'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Mark challenge as used immediately (prevent replay attacks)
|
||||
await db.query(`
|
||||
UPDATE smirk_challenges SET used = TRUE WHERE id = $1
|
||||
`, [challengeResult.rows[0].id]);
|
||||
|
||||
// 3. Verify signature using tweetnacl
|
||||
// Accepts both formats during transition:
|
||||
// - RFC 8032 standard: signature over raw UTF-8 message bytes (new extension)
|
||||
// - Legacy: signature over SHA256(message) (old extension, remove after 2026-07-01)
|
||||
let isValidSignature = false;
|
||||
try {
|
||||
const nacl = require('tweetnacl');
|
||||
const signatureBytes = Buffer.from(signature, 'hex');
|
||||
const publicKeyBytes = Buffer.from(publicKey, 'hex');
|
||||
|
||||
// Try RFC 8032 standard first (raw message bytes)
|
||||
const challengeBytes = Buffer.from(challenge, 'utf8');
|
||||
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
|
||||
|
||||
// Fall back to legacy SHA256 pre-hash (DEPRECATED — remove after 2026-07-01)
|
||||
if (!isValidSignature) {
|
||||
const challengeHash = crypto.createHash('sha256').update(challenge).digest();
|
||||
isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
|
||||
}
|
||||
} catch (verifyError) {
|
||||
console.error('Signature verification error:', verifyError.message);
|
||||
throw new ValidationError('Signature verification failed', {
|
||||
safeMessage: 'Unable to verify the wallet signature.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidSignature) {
|
||||
throw new ValidationError('Invalid signature', {
|
||||
safeMessage: 'The wallet signature is invalid.'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Link public key to user session
|
||||
const userResult = await db.query(`
|
||||
SELECT id FROM users WHERE socket_id = $1
|
||||
`, [socketId]);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new NotFoundError('User session not found', {
|
||||
safeMessage: 'No active session found. Please connect to the game first.'
|
||||
});
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
// Check if this public key is already linked to another user
|
||||
const existingLink = await db.query(`
|
||||
SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
|
||||
`, [publicKey, userId]);
|
||||
|
||||
if (existingLink.rows.length > 0) {
|
||||
throw new ValidationError('Wallet already linked', {
|
||||
safeMessage: 'This wallet is already linked to another account.'
|
||||
});
|
||||
}
|
||||
|
||||
// Link the wallet to this user
|
||||
await db.query(`
|
||||
UPDATE users SET smirk_public_key = $1 WHERE id = $2
|
||||
`, [publicKey, userId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
linked: true,
|
||||
message: 'Smirk wallet linked successfully'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/auth/smirk/status
|
||||
* Check if a session has a linked Smirk wallet
|
||||
* Query: socketId=string
|
||||
*/
|
||||
app.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.query;
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId query parameter is required.'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1
|
||||
`, [socketId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.json({
|
||||
linked: false,
|
||||
hasPayoutAddress: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
res.json({
|
||||
linked: !!user.smirk_public_key,
|
||||
hasPayoutAddress: !!user.payout_address
|
||||
});
|
||||
}));
|
||||
|
||||
} // End of smirkEnabled block
|
||||
|
||||
// =============================================================================
|
||||
// Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Smirk wallet auth routes (extracted from index.js).
|
||||
* DI via ctx so handler bodies stay identical. ctx: { db }
|
||||
*/
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const asyncHandler = require('../middleware/asyncHandler');
|
||||
const { ValidationError, NotFoundError } = require('../utils/errors');
|
||||
|
||||
module.exports = function createAuthRoutes(ctx) {
|
||||
const { db } = ctx;
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/smirk/challenge
|
||||
* Generate a challenge for Smirk wallet signature verification
|
||||
* Body: { socketId: string }
|
||||
*/
|
||||
router.post('/api/auth/smirk/challenge', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.body || {};
|
||||
|
||||
if (!socketId || typeof socketId !== 'string') {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId is required to generate a challenge.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a cryptographically secure challenge
|
||||
const challenge = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Store challenge in database (expires in 5 minutes)
|
||||
await db.query(`
|
||||
INSERT INTO smirk_challenges (challenge, socket_id)
|
||||
VALUES ($1, $2)
|
||||
`, [challenge, socketId]);
|
||||
|
||||
// Clean up old/expired challenges periodically
|
||||
await db.query(`
|
||||
DELETE FROM smirk_challenges
|
||||
WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour')
|
||||
`);
|
||||
|
||||
res.json({
|
||||
challenge,
|
||||
expiresIn: 300 // 5 minutes in seconds
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/auth/smirk/verify
|
||||
* Verify a Smirk wallet signature and link to user session
|
||||
* Body: { socketId: string, challenge: string, publicKey: string, signature: string }
|
||||
*/
|
||||
router.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => {
|
||||
const { socketId, challenge, publicKey, signature } = req.body || {};
|
||||
|
||||
if (!socketId || !challenge || !publicKey || !signature) {
|
||||
throw new ValidationError('Missing required fields', {
|
||||
safeMessage: 'socketId, challenge, publicKey, and signature are required.'
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Verify challenge exists, is not used, and has not expired
|
||||
const challengeResult = await db.query(`
|
||||
SELECT * FROM smirk_challenges
|
||||
WHERE challenge = $1 AND socket_id = $2 AND used = FALSE AND expires_at > NOW()
|
||||
`, [challenge, socketId]);
|
||||
|
||||
if (challengeResult.rows.length === 0) {
|
||||
throw new ValidationError('Invalid or expired challenge', {
|
||||
safeMessage: 'The challenge is invalid, expired, or has already been used.'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Mark challenge as used immediately (prevent replay attacks)
|
||||
await db.query(`
|
||||
UPDATE smirk_challenges SET used = TRUE WHERE id = $1
|
||||
`, [challengeResult.rows[0].id]);
|
||||
|
||||
// 3. Verify signature using tweetnacl
|
||||
// Accepts both formats during transition:
|
||||
// - RFC 8032 standard: signature over raw UTF-8 message bytes (new extension)
|
||||
// - Legacy: signature over SHA256(message) (old extension, remove after 2026-07-01)
|
||||
let isValidSignature = false;
|
||||
try {
|
||||
const nacl = require('tweetnacl');
|
||||
const signatureBytes = Buffer.from(signature, 'hex');
|
||||
const publicKeyBytes = Buffer.from(publicKey, 'hex');
|
||||
|
||||
// Try RFC 8032 standard first (raw message bytes)
|
||||
const challengeBytes = Buffer.from(challenge, 'utf8');
|
||||
isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
|
||||
|
||||
// Fall back to legacy SHA256 pre-hash (DEPRECATED — remove after 2026-07-01)
|
||||
if (!isValidSignature) {
|
||||
const challengeHash = crypto.createHash('sha256').update(challenge).digest();
|
||||
isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
|
||||
}
|
||||
} catch (verifyError) {
|
||||
console.error('Signature verification error:', verifyError.message);
|
||||
throw new ValidationError('Signature verification failed', {
|
||||
safeMessage: 'Unable to verify the wallet signature.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidSignature) {
|
||||
throw new ValidationError('Invalid signature', {
|
||||
safeMessage: 'The wallet signature is invalid.'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Link public key to user session
|
||||
const userResult = await db.query(`
|
||||
SELECT id FROM users WHERE socket_id = $1
|
||||
`, [socketId]);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new NotFoundError('User session not found', {
|
||||
safeMessage: 'No active session found. Please connect to the game first.'
|
||||
});
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
// Check if this public key is already linked to another user
|
||||
const existingLink = await db.query(`
|
||||
SELECT id FROM users WHERE smirk_public_key = $1 AND id != $2
|
||||
`, [publicKey, userId]);
|
||||
|
||||
if (existingLink.rows.length > 0) {
|
||||
throw new ValidationError('Wallet already linked', {
|
||||
safeMessage: 'This wallet is already linked to another account.'
|
||||
});
|
||||
}
|
||||
|
||||
// Link the wallet to this user
|
||||
await db.query(`
|
||||
UPDATE users SET smirk_public_key = $1 WHERE id = $2
|
||||
`, [publicKey, userId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
linked: true,
|
||||
message: 'Smirk wallet linked successfully'
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/auth/smirk/status
|
||||
* Check if a session has a linked Smirk wallet
|
||||
* Query: socketId=string
|
||||
*/
|
||||
router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.query;
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId query parameter is required.'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT smirk_public_key, payout_address FROM users WHERE socket_id = $1
|
||||
`, [socketId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.json({
|
||||
linked: false,
|
||||
hasPayoutAddress: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
res.json({
|
||||
linked: !!user.smirk_public_key,
|
||||
hasPayoutAddress: !!user.payout_address
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
const createAdminRoutes = require('../src/routes/admin');
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
function stubCtx() {
|
||||
return {
|
||||
@@ -48,3 +49,16 @@ describe('admin routes factory', () => {
|
||||
expect(paths.length).toBe(expected.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth (smirk) routes factory', () => {
|
||||
test('builds a router and registers the smirk endpoints', () => {
|
||||
const router = createAuthRoutes({ db: { query: jest.fn() } });
|
||||
const paths = router.stack.filter(l => l.route).map(l => l.route.path);
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'/api/auth/smirk/challenge',
|
||||
'/api/auth/smirk/verify',
|
||||
'/api/auth/smirk/status'
|
||||
]));
|
||||
expect(paths.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user