Skip to content

Commit 6fa5378

Browse files
authored
fix(api): honor bypassTwoFactor PAT on twoFactorRequired endpoints (#41065)
1 parent bc2d631 commit 6fa5378

5 files changed

Lines changed: 156 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/meteor': patch
3+
'@rocket.chat/core-typings': patch
4+
---
5+
6+
Fixes REST API endpoints that require two-factor authentication (such as `users.update`) rejecting requests authenticated with a Personal Access Token created with "Ignore Two Factor Authentication", returning `totp-required` even though the token was meant to bypass the check. The two-factor authorization check now resolves the login token from the REST connection, so `bypassTwoFactor` tokens are honored again.

apps/meteor/.mocharc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module.exports = {
2727
'tests/unit/lib/**/*.spec.ts',
2828
'tests/unit/server/**/*.tests.ts',
2929
'tests/unit/server/**/*.spec.ts',
30+
'app/2fa/server/**/*.spec.ts',
3031
'app/api/server/lib/**/*.spec.ts',
3132
'app/file-upload/server/**/*.spec.ts',
3233
'app/statistics/server/**/*.spec.ts',
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { expect } from 'chai';
2+
import { afterEach, beforeEach, describe, it } from 'mocha';
3+
import proxyquire from 'proxyquire';
4+
import sinon from 'sinon';
5+
6+
const settingsMock = sinon.stub();
7+
const getLoginTokenStub = sinon.stub();
8+
9+
class TOTPCheckMock {
10+
name = 'totp';
11+
12+
isEnabled() {
13+
return true;
14+
}
15+
16+
async processInvalidCode() {
17+
return {};
18+
}
19+
20+
async verify() {
21+
return false;
22+
}
23+
24+
async maxFaildedAttemtpsReached() {
25+
return false;
26+
}
27+
}
28+
29+
class DisabledCheckMock {
30+
name = 'email';
31+
32+
isEnabled() {
33+
return false;
34+
}
35+
}
36+
37+
class MeteorErrorMock extends Error {
38+
error: string;
39+
40+
details: unknown;
41+
42+
constructor(error: string, reason?: string, details?: unknown) {
43+
super(reason);
44+
this.error = error;
45+
this.details = details;
46+
}
47+
}
48+
49+
const { checkCodeForUser } = proxyquire.noCallThru().load('./index', {
50+
'./TOTPCheck': { TOTPCheck: TOTPCheckMock },
51+
'./EmailCheck': { EmailCheck: DisabledCheckMock },
52+
'./PasswordCheckFallback': { PasswordCheckFallback: class extends DisabledCheckMock {} },
53+
'../../../lib/server/functions/getModifiedHttpHeaders': { normalizeHeaders: (headers: unknown) => headers },
54+
'../../../settings/server': { settings: { get: settingsMock } },
55+
'@rocket.chat/models': {
56+
Users: {
57+
findOneById: async () => null,
58+
setTwoFactorAuthorizationHashAndUntilForUserIdAndToken: async () => undefined,
59+
},
60+
},
61+
'meteor/accounts-base': { Accounts: { _getLoginToken: getLoginTokenStub } },
62+
'meteor/meteor': { Meteor: { Error: MeteorErrorMock } },
63+
});
64+
65+
const HASHED_TOKEN = 'hashed-login-token';
66+
67+
const userWithBypassToken = {
68+
_id: 'user-id',
69+
services: { resume: { loginTokens: [{ hashedToken: HASHED_TOKEN, bypassTwoFactor: true }] } },
70+
} as any;
71+
72+
const connection = {
73+
id: 'connection-id',
74+
clientAddress: '127.0.0.1',
75+
httpHeaders: {},
76+
} as any;
77+
78+
describe('checkCodeForUser - bypassTwoFactor token resolution (SUP-1064)', () => {
79+
let testMode: string | undefined;
80+
81+
beforeEach(() => {
82+
// TEST_MODE short-circuits the whole check; remove it so the token resolution path runs.
83+
testMode = process.env.TEST_MODE;
84+
delete process.env.TEST_MODE;
85+
86+
settingsMock.reset();
87+
settingsMock.withArgs('Accounts_TwoFactorAuthentication_Enabled').returns(true);
88+
getLoginTokenStub.reset();
89+
});
90+
91+
afterEach(() => {
92+
if (testMode !== undefined) {
93+
process.env.TEST_MODE = testMode;
94+
}
95+
});
96+
97+
it('should honor a bypassTwoFactor token resolved from the REST connection (connection.token)', async () => {
98+
// REST: the token is not registered in account data, only carried on the connection.
99+
getLoginTokenStub.returns(undefined);
100+
101+
const authorized = await checkCodeForUser({
102+
user: userWithBypassToken,
103+
connection: { ...connection, token: HASHED_TOKEN },
104+
options: {},
105+
});
106+
107+
expect(authorized).to.be.equal(true);
108+
});
109+
110+
it('should honor a bypassTwoFactor token resolved from account data (DDP, _getLoginToken)', async () => {
111+
// DDP: the token is registered in Accounts._accountData and read via _getLoginToken.
112+
getLoginTokenStub.returns(HASHED_TOKEN);
113+
114+
const authorized = await checkCodeForUser({
115+
user: userWithBypassToken,
116+
connection: { ...connection, token: undefined },
117+
options: {},
118+
});
119+
120+
expect(authorized).to.be.equal(true);
121+
});
122+
123+
it('should still require a second factor when the token cannot be resolved from either source', async () => {
124+
// Regression guard: this is the buggy state (#38017) the fix addresses.
125+
getLoginTokenStub.returns(undefined);
126+
127+
await expect(
128+
checkCodeForUser({
129+
user: userWithBypassToken,
130+
connection: { ...connection, token: undefined },
131+
options: {},
132+
}),
133+
).to.be.rejectedWith('TOTP Required');
134+
});
135+
});

apps/meteor/app/2fa/server/code/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ function getRememberDate(from: Date = new Date()): Date | undefined {
7777
}
7878

7979
function isAuthorizedForToken(connection: IMethodConnection, user: IUser, options: ITwoFactorOptions): boolean {
80-
const currentToken = Accounts._getLoginToken(connection.id);
80+
// Resolve the current login token from both transports:
81+
// - DDP: the login flow registers it in `Accounts._accountData`, read via `_getLoginToken`.
82+
// - REST: it is not registered in account data, so it is carried on `connection.token`.
83+
// Both are needed; REST is the fallback that fixes `bypassTwoFactor` PATs (SUP-1064).
84+
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
8185
const tokenObject = user.services?.resume?.loginTokens?.find((i) => i.hashedToken === currentToken);
8286

8387
if (!tokenObject) {
@@ -119,7 +123,9 @@ function isAuthorizedForToken(connection: IMethodConnection, user: IUser, option
119123
}
120124

121125
async function rememberAuthorization(connection: IMethodConnection, user: IUser): Promise<void> {
122-
const currentToken = Accounts._getLoginToken(connection.id);
126+
// Same dual-transport resolution as `isAuthorizedForToken`: DDP reads from `Accounts._accountData`
127+
// via `_getLoginToken`, REST falls back to the token carried on `connection.token`.
128+
const currentToken = Accounts._getLoginToken(connection.id) || connection.token;
123129

124130
const expires = getRememberDate();
125131
if (!expires) {

packages/core-typings/src/IMethodConnection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,10 @@ export interface IMethodConnection {
44
onClose(fn: (...args: any[]) => void): void;
55
clientAddress: string;
66
httpHeaders: Record<string, any>;
7+
/**
8+
* Hashed login token carried by REST connections. Unlike DDP connections, REST requests do not
9+
* register the login token in `Accounts._accountData`, so it is exposed here for the two-factor
10+
* authorization check to resolve the token without mutating global account data.
11+
*/
12+
token?: string;
713
}

0 commit comments

Comments
 (0)