mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture.
171 lines
6.8 KiB
JavaScript
171 lines
6.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import jwt from 'jsonwebtoken';
|
|
import * as otplib from 'otplib';
|
|
import { authenticateUser } from '../../backend/controllers/shared/authenticate.js';
|
|
import { Common } from '../../backend/utils/common.js';
|
|
|
|
const { authenticator } = otplib;
|
|
|
|
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
|
const PASSWORD_HASH = 'hashed-password';
|
|
|
|
const setupAppConfig = (enable2FA, secret2FA) => {
|
|
Common.appConfig = {
|
|
defaultNodeIndex: 0,
|
|
selectedNodeIndex: 0,
|
|
rtlConfFilePath: '',
|
|
dbDirectoryPath: '',
|
|
rtlPass: PASSWORD_HASH,
|
|
allowPasswordUpdate: true,
|
|
enable2FA: enable2FA,
|
|
secret2FA: secret2FA,
|
|
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' },
|
|
nodes: []
|
|
};
|
|
Common.selectedNode = null;
|
|
Common.nodes = [];
|
|
};
|
|
|
|
// failedLoginAttempts is module-level state in authenticate.js, keyed by the request IP
|
|
// from common.getRequestIP, which prefers x-forwarded-for (server/utils/common.ts).
|
|
// Unique IPs give each call a fresh counter; tests exercising the counter itself pass an
|
|
// explicit ip to share one key across calls.
|
|
let ipCounter = 0;
|
|
const nextIP = () => '10.0.0.' + (ipCounter = ipCounter + 1);
|
|
const mockRequest = ({ twoFAToken, ip, authToken, password } = {}) => {
|
|
const headers = { 'x-forwarded-for': ip || nextIP() };
|
|
if (authToken) { headers.authorization = 'Bearer ' + authToken; }
|
|
return {
|
|
body: { authenticateWith: 'PASSWORD', authenticationValue: password || PASSWORD_HASH, twoFAToken: twoFAToken },
|
|
session: {},
|
|
headers: headers,
|
|
connection: {},
|
|
socket: {}
|
|
};
|
|
};
|
|
|
|
const mockResponse = () => {
|
|
const res = { statusCode: null, body: null };
|
|
res.status = (code) => {
|
|
res.statusCode = code;
|
|
return { json: (body) => { res.body = body; } };
|
|
};
|
|
return res;
|
|
};
|
|
|
|
const mockSessionToken = () => jwt.sign({ user: 'NODE_USER' }, Common.secret_key);
|
|
|
|
test('rejects login without a 2FA token when 2FA is enabled', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
for (const missingToken of [undefined, '']) {
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: missingToken }), res, null);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body.error, /2FA/);
|
|
}
|
|
});
|
|
|
|
test('rejects login with an invalid 2FA token when 2FA is enabled', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: '000000' }), res, null);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body.error, /2FA/);
|
|
});
|
|
|
|
test('rejects a non-string 2FA token when 2FA is enabled', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
// A JSON body can carry an array/object/number. otplib 12.0.1 coerces and rejects these
|
|
// (digit regex, then strict === against the string token), but the typeof guard keeps the
|
|
// rejection explicit and independent of otplib internals.
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: ['1', '2', '3', '4', '5', '6'] }), res, null);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body.error, /2FA/);
|
|
});
|
|
|
|
test('accepts login with a valid 2FA token when 2FA is enabled', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET) }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|
|
|
|
test('accepts password-only re-authorization from an authenticated session when 2FA is enabled', () => {
|
|
// In-app re-authorization (e.g. the password prompt before on-chain sends) carries the
|
|
// session JWT via the auth interceptor; that session was itself minted after 2FA.
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken() }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|
|
|
|
test('rejects a wrong password even with an authenticated session when 2FA is enabled', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken(), password: 'wrong-hash' }), res, null);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body.error, /Invalid Password/);
|
|
});
|
|
|
|
test('locks out after five failed 2FA attempts, even for a then-valid token', () => {
|
|
setupAppConfig(true, TOTP_SECRET);
|
|
const ip = nextIP(); // one shared counter key for every attempt in this test
|
|
for (let i = 0; i < 4; i++) {
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), res, null);
|
|
assert.equal(res.statusCode, 401);
|
|
assert.match(res.body.error, /2FA/);
|
|
}
|
|
const fifth = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), fifth, null);
|
|
assert.equal(fifth.statusCode, 401);
|
|
assert.match(fifth.body.error, /locked/);
|
|
const sixth = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET), ip: ip }), sixth, null);
|
|
assert.equal(sixth.statusCode, 401);
|
|
assert.match(sixth.body.error, /locked/);
|
|
});
|
|
|
|
test('accepts password-only login when 2FA is not configured', () => {
|
|
setupAppConfig(false, '');
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|
|
|
|
test('accepts a stale token in the request when 2FA is not configured', () => {
|
|
// Pins an intentional behavior change: previously a non-empty twoFAToken with no
|
|
// configured secret was rejected (verifyToken short-circuits on the empty secret);
|
|
// with no 2FA configured the token is now ignored entirely.
|
|
setupAppConfig(false, '');
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: '123456' }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|
|
|
|
test('does not require a token when 2FA is disabled but a stale secret remains', () => {
|
|
// The login UI prompts only when enable2FA is set, so enforcing a token on a stale
|
|
// secret would lock the operator out of a UI that never asks for one.
|
|
setupAppConfig(false, TOTP_SECRET);
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|
|
|
|
test('does not enforce a token when 2FA is enabled without a secret', () => {
|
|
// Divergence is only reachable via a crafted settings update; a token could never
|
|
// verify against an empty secret, so enforcing would lock everyone out.
|
|
setupAppConfig(true, '');
|
|
const res = mockResponse();
|
|
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
|
|
assert.equal(res.statusCode, 200);
|
|
assert.equal(typeof res.body.token, 'string');
|
|
});
|