mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
fix(core): should sign out user after deletion or suspension (#5857)
fixed #5572
This commit is contained in:
parent
1e24843a28
commit
5660c54cb5
5 changed files with 44 additions and 9 deletions
7
.changeset/green-cougars-behave.md
Normal file
7
.changeset/green-cougars-behave.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
Sign out user after deletion or suspension
|
||||
|
||||
When a user is deleted or suspended through Management API, they should be signed out immediately, including sessions and refresh tokens.
|
|
@ -89,6 +89,7 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
rolesScopes: { findRolesScopesByRoleIds },
|
||||
scopes: { findScopesByIdsAndResourceIndicator },
|
||||
organizations,
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
} = queries;
|
||||
|
||||
const generateUserId = async (retries = 500) =>
|
||||
|
@ -270,6 +271,14 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
return user;
|
||||
};
|
||||
|
||||
const signOutUser = async (userId: string) => {
|
||||
await Promise.all([
|
||||
revokeInstanceByUserId('AccessToken', userId),
|
||||
revokeInstanceByUserId('RefreshToken', userId),
|
||||
revokeInstanceByUserId('Session', userId),
|
||||
]);
|
||||
};
|
||||
|
||||
return {
|
||||
generateUserId,
|
||||
insertUser,
|
||||
|
@ -279,5 +288,6 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
findUserRoles,
|
||||
addUserMfaVerification,
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -14,7 +14,6 @@ const { jest } = import.meta;
|
|||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const mockedQueries = {
|
||||
oidcModelInstances: { revokeInstanceByUserId: jest.fn() },
|
||||
signInExperiences: {
|
||||
findDefaultSignInExperience: jest.fn(
|
||||
async () =>
|
||||
|
@ -65,7 +64,6 @@ const mockHasUser = jest.fn(async () => false);
|
|||
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||
|
||||
const { revokeInstanceByUserId } = mockedQueries.oidcModelInstances;
|
||||
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
|
||||
mockedQueries.users;
|
||||
|
||||
|
@ -77,6 +75,7 @@ const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js'
|
|||
}));
|
||||
|
||||
const verifyUserPassword = jest.fn();
|
||||
const signOutUser = jest.fn();
|
||||
const usersLibraries = {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
|
@ -86,6 +85,7 @@ const usersLibraries = {
|
|||
})
|
||||
),
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
const adminUserRoutes = await pickDefault(import('./basics.js'));
|
||||
|
@ -377,7 +377,7 @@ describe('adminUserRoutes', () => {
|
|||
.patch(`/users/${mockedUserId}/is-suspended`)
|
||||
.send({ isSuspended: true });
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockedUserId, { isSuspended: true });
|
||||
expect(revokeInstanceByUserId).toHaveBeenCalledWith('refreshToken', mockedUserId);
|
||||
expect(signOutUser).toHaveBeenCalledWith(mockedUserId);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
...mockUserResponse,
|
||||
|
@ -389,6 +389,7 @@ describe('adminUserRoutes', () => {
|
|||
const userId = 'fooUser';
|
||||
const response = await userRequest.delete(`/users/${userId}`);
|
||||
expect(response.status).toEqual(204);
|
||||
expect(signOutUser).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId should throw if user is deleting self', async () => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import {
|
||||
UsersPasswordEncryptionMethod,
|
||||
|
@ -21,7 +22,6 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
) {
|
||||
const [router, { queries, libraries }] = args;
|
||||
const {
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
users: {
|
||||
deleteUserById,
|
||||
findUserById,
|
||||
|
@ -33,7 +33,13 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
userSsoIdentities,
|
||||
} = queries;
|
||||
const {
|
||||
users: { checkIdentifierCollision, generateUserId, insertUser, verifyUserPassword },
|
||||
users: {
|
||||
checkIdentifierCollision,
|
||||
generateUserId,
|
||||
insertUser,
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
},
|
||||
} = libraries;
|
||||
|
||||
router.get(
|
||||
|
@ -343,12 +349,11 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
});
|
||||
|
||||
if (isSuspended) {
|
||||
await revokeInstanceByUserId('refreshToken', user.id);
|
||||
await signOutUser(user.id);
|
||||
}
|
||||
|
||||
ctx.body = pick(user, ...userInfoSelectFields);
|
||||
|
||||
// eslint-disable-next-line max-lines
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
@ -368,6 +373,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
throw new RequestError('user.cannot_delete_self');
|
||||
}
|
||||
|
||||
await signOutUser(userId);
|
||||
await deleteUserById(userId);
|
||||
|
||||
ctx.status = 204;
|
||||
|
@ -376,3 +382,4 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
}
|
||||
);
|
||||
}
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -22,7 +22,11 @@ import {
|
|||
} from '#src/api/index.js';
|
||||
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
|
||||
import { createUserByAdmin, expectRejects } from '#src/helpers/index.js';
|
||||
import { createNewSocialUserWithUsernameAndPassword } from '#src/helpers/interactions.js';
|
||||
import {
|
||||
createNewSocialUserWithUsernameAndPassword,
|
||||
signInWithPassword,
|
||||
} from '#src/helpers/interactions.js';
|
||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import {
|
||||
generateUsername,
|
||||
generateEmail,
|
||||
|
@ -177,7 +181,9 @@ describe('admin console user management', () => {
|
|||
});
|
||||
|
||||
it('should delete user successfully', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
const username = generateUsername();
|
||||
const password = 'password';
|
||||
const user = await createUserByAdmin({ username, password });
|
||||
|
||||
const userEntity = await getUser(user.id);
|
||||
expect(userEntity).toMatchObject(user);
|
||||
|
@ -186,6 +192,10 @@ describe('admin console user management', () => {
|
|||
|
||||
const response = await getUser(user.id).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.status === 404).toBe(true);
|
||||
|
||||
await enableAllPasswordSignInMethods();
|
||||
// Sign in with deleted user should throw error
|
||||
await expect(signInWithPassword({ username, password })).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('should update user password successfully', async () => {
|
||||
|
|
Loading…
Reference in a new issue