diff --git a/.changeset/quiet-lizards-jump.md b/.changeset/quiet-lizards-jump.md new file mode 100644 index 0000000000000..f9858cac0d1f9 --- /dev/null +++ b/.changeset/quiet-lizards-jump.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the `users.CreateToken` endpoint checks for the `user-generate-access-token` permission when generating a login token for another user diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index e73f84dff2564..bde056ca5185f 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -842,7 +842,7 @@ const usersEndpoints = API.v1.post( async function action() { const user = await getUserFromParams(this.bodyParams); - const data = await generateAccessToken(user._id, this.bodyParams.secret); + const data = await generateAccessToken(user._id, this.bodyParams.secret, this.user); return API.v1.success({ data }); }, diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 63de5b98f4210..6dab21fd8b51a 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -1,4 +1,5 @@ -import { MeteorError, User } from '@rocket.chat/core-services'; +import { Authorization, MeteorError, User } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; import { Accounts } from 'meteor/accounts-base'; declare module '@rocket.chat/ddp-client' { @@ -10,11 +11,15 @@ declare module '@rocket.chat/ddp-client' { const { CREATE_TOKENS_FOR_USERS_SECRET } = process.env; -export async function generateAccessToken(userId: string, secret: string) { +export async function generateAccessToken(userId: string, secret: string, caller: IUser) { if (secret !== CREATE_TOKENS_FOR_USERS_SECRET) { throw new MeteorError('error-not-authorized', 'Not authorized'); } + if (caller._id !== userId && !(await Authorization.hasPermission(caller._id, 'user-generate-access-token'))) { + throw new MeteorError('error-not-authorized', 'Not authorized'); + } + const token = Accounts._generateStampedLoginToken(); Accounts._insertLoginToken(userId, token); diff --git a/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts new file mode 100644 index 0000000000000..c398906726f99 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const hasPermissionStub = sinon.stub(); + +const AccountsMock = { + _generateStampedLoginToken: sinon.stub(), + _insertLoginToken: sinon.stub(), +}; + +const UserMock = { + ensureLoginTokensLimit: sinon.stub(), +}; + +const AuthorizationMock = { + hasPermission: hasPermissionStub, +}; + +class MeteorErrorMock extends Error { + error: string; + + constructor(error: string, reason: string) { + super(reason); + this.error = error; + } +} + +process.env.CREATE_TOKENS_FOR_USERS_SECRET = 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7'; + +const { generateAccessToken } = p.noCallThru().load('../../../../../../app/lib/server/methods/createToken.ts', { + '@rocket.chat/core-services': { Authorization: AuthorizationMock, MeteorError: MeteorErrorMock, User: UserMock }, + 'meteor/accounts-base': { Accounts: AccountsMock }, +}); + +describe('generateAccessToken', () => { + beforeEach(() => { + hasPermissionStub.reset(); + AccountsMock._generateStampedLoginToken.reset(); + AccountsMock._insertLoginToken.reset(); + UserMock.ensureLoginTokensLimit.reset(); + }); + + it('should throw if secret does not match', async () => { + await expect(generateAccessToken('targetId', 'wrong-secret', { _id: 'callerId' })).to.be.rejectedWith('Not authorized'); + }); + + it('should throw if caller targets another user and lacks user-generate-access-token permission', async () => { + hasPermissionStub.resolves(false); + + await expect(generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' })).to.be.rejectedWith( + 'Not authorized', + ); + sinon.assert.calledOnceWithExactly(hasPermissionStub, 'callerId', 'user-generate-access-token'); + }); + + it('should succeed if caller targets another user and has user-generate-access-token permission', async () => { + hasPermissionStub.resolves(true); + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' }); + + expect(result).to.deep.equal({ userId: 'targetId', authToken: 'abc123' }); + sinon.assert.calledOnceWithExactly(hasPermissionStub, 'callerId', 'user-generate-access-token'); + }); + + it('should succeed without permission check when caller generates token for themselves', async () => { + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('userId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'userId' }); + + expect(result).to.deep.equal({ userId: 'userId', authToken: 'abc123' }); + sinon.assert.notCalled(hasPermissionStub); + }); +});