diff --git a/.changeset/rest-logout-session-cleanup.md b/.changeset/rest-logout-session-cleanup.md new file mode 100644 index 0000000000000..f2f4338311629 --- /dev/null +++ b/.changeset/rest-logout-session-cleanup.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Marks the user session as logged out in the Sessions collection when logging out via `POST /v1/logout`. Previously the session cleanup relied on an indirect chain through `watch.users` → `Accounts.onLogout` that could be broken by a race condition, leaving orphaned sessions visible in Device Manager. diff --git a/apps/meteor/server/api/ApiClass.ts b/apps/meteor/server/api/ApiClass.ts index 71dfc86bc0020..783a9439a905a 100644 --- a/apps/meteor/server/api/ApiClass.ts +++ b/apps/meteor/server/api/ApiClass.ts @@ -2,7 +2,7 @@ import type { IMethodConnection, IUser } from '@rocket.chat/core-typings'; import type { Route, Router } from '@rocket.chat/http-router'; import { License } from '@rocket.chat/license'; import { Logger } from '@rocket.chat/logger'; -import { Users } from '@rocket.chat/models'; +import { Sessions, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings'; @@ -1135,8 +1135,7 @@ export class APIClass { const userTokens = await Users.findOneById(this.userId, { projection: { [tokenPath]: 1 } }); if (!userTokens) { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 156330b68626f..74589e3aa97d4 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -5476,6 +5476,57 @@ describe('[Users]', () => { }); }); + describe('[/api/v1/logout]', () => { + let user: TestUser; + + before(async () => { + user = await createUser(); + }); + + after(() => deleteUser(user)); + + it('should logout the current user and invalidate the session', async () => { + const userCredentials = await login(user.username, password); + + await request.post(api('logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200); + + const meRes = await request.get(api('me')).set(userCredentials); + expect(meRes.statusCode).to.equal(401); + }); + + (IS_EE ? it : it.skip)('should remove the session from the list after logout', async () => { + const credentials = await login(user.username, password); + const authToken = credentials['X-Auth-Token']; + + await request.post(api('login')).send({ resume: authToken }).expect(200); + + const sessionsBefore = await request.get(api('sessions/list')).set(credentials).expect(200); + expect(sessionsBefore.body.sessions).to.have.lengthOf(1); + const sessionIdBefore = sessionsBefore.body.sessions[0]._id; + + await request.post(api('logout')).set(credentials).expect(200); + + const newCredentials = await login(user.username, password); + const newAuthToken = newCredentials['X-Auth-Token']; + + await request.post(api('login')).send({ resume: newAuthToken }).expect(200); + + const sessionsAfter = await request.get(api('sessions/list')).set(newCredentials).expect(200); + expect(sessionsAfter.body.sessions).to.have.lengthOf(1); + expect(sessionsAfter.body.sessions[0]._id).to.not.equal(sessionIdBefore); + }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('logout')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + describe('[/users.autocomplete]', () => { after(() => updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']));