0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

Merge pull request from logto-io/feature/suspend

feat: suspend user
This commit is contained in:
wangsijie 2022-11-14 11:47:22 +08:00 committed by GitHub
commit 4a4a59f44d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 394 additions and 61 deletions

View file

@ -19,6 +19,7 @@ export const mockUser: User = {
applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
createdAt: 1_650_969_000_000,
isSuspended: false,
};
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
@ -41,6 +42,7 @@ export const mockUserWithPassword: User = {
applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
createdAt: 1_650_969_000_000,
isSuspended: false,
};
export const mockUserList: User[] = [
@ -59,6 +61,7 @@ export const mockUserList: User[] = [
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
isSuspended: false,
},
{
id: '2',
@ -75,6 +78,7 @@ export const mockUserList: User[] = [
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
isSuspended: false,
},
{
id: '3',
@ -91,6 +95,7 @@ export const mockUserList: User[] = [
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
isSuspended: false,
},
{
id: '4',
@ -107,6 +112,7 @@ export const mockUserList: User[] = [
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
isSuspended: false,
},
{
id: '5',
@ -123,6 +129,7 @@ export const mockUserList: User[] = [
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
isSuspended: false,
},
];

View file

@ -108,3 +108,11 @@ export const revokeInstanceByGrantId = async (modelName: string, grantId: string
and ${fields.payload}->>'grantId'=${grantId}
`);
};
export const revokeInstanceByUserId = async (modelName: string, userId: string) => {
await envSet.pool.query(sql`
delete from ${table}
where ${fields.modelName}=${modelName}
and ${fields.payload}->>'accountId'=${userId}
`);
};

View file

@ -80,6 +80,12 @@ jest.mock('@/queries/roles', () => ({
),
}));
const revokeInstanceByUserId = jest.fn();
jest.mock('@/queries/oidc-model-instance', () => ({
revokeInstanceByUserId: async (modelName: string, userId: string) =>
revokeInstanceByUserId(modelName, userId),
}));
describe('adminUserRoutes', () => {
const userRequest = createRequester({ authedRoutes: adminUserRoutes });
@ -331,6 +337,19 @@ describe('adminUserRoutes', () => {
expect(updateUserById).not.toHaveBeenCalled();
});
it('PATCH /users/:userId/is-suspended', async () => {
const mockedUserId = 'foo';
const response = await userRequest
.patch(`/users/${mockedUserId}/is-suspended`)
.send({ isSuspended: true });
expect(updateUserById).toHaveBeenCalledWith(mockedUserId, { isSuspended: true });
expect(revokeInstanceByUserId).toHaveBeenCalledWith('refreshToken', mockedUserId);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
...mockUserResponse,
});
});
it('DELETE /users/:userId', async () => {
const userId = 'fooUser';
const response = await userRequest.delete(`/users/${userId}`);

View file

@ -2,13 +2,14 @@ import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/cor
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
import { has } from '@silverhand/essentials';
import pick from 'lodash.pick';
import { literal, object, string } from 'zod';
import { boolean, literal, object, string } from 'zod';
import { isTrue } from '@/env-set/parameters';
import RequestError from '@/errors/RequestError';
import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import koaPagination from '@/middleware/koa-pagination';
import { revokeInstanceByUserId } from '@/queries/oidc-model-instance';
import { findRolesByRoleNames } from '@/queries/roles';
import {
deleteUserById,
@ -242,6 +243,34 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
}
);
router.patch(
'/users/:userId/is-suspended',
koaGuard({
params: object({ userId: string() }),
body: object({ isSuspended: boolean() }),
}),
async (ctx, next) => {
const {
params: { userId },
body: { isSuspended },
} = ctx.guard;
await findUserById(userId);
const user = await updateUserById(userId, {
isSuspended,
});
if (isSuspended) {
await revokeInstanceByUserId('refreshToken', user.id);
}
ctx.body = pick(user, ...userInfoSelectFields);
return next();
}
);
router.delete(
'/users/:userId',
koaGuard({

View file

@ -55,7 +55,8 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
const user = await findUserByPhone(phone);
assertThat(user, new RequestError({ code: 'user.phone_not_exists', status: 404 }));
const { id } = user;
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });
await checkRequiredProfile(ctx, provider, user, signInExperience);
@ -97,7 +98,8 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
const user = await findUserByEmail(email);
assertThat(user, new RequestError({ code: 'user.email_not_exists', status: 404 }));
const { id } = user;
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });
await checkRequiredProfile(ctx, provider, user, signInExperience);

View file

@ -508,6 +508,24 @@ describe('session -> passwordlessRoutes', () => {
expect(response.statusCode).toEqual(404);
});
it('throw when user is suspended', async () => {
findUserByPhone.mockResolvedValueOnce({
...mockUser,
isSuspended: true,
});
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
phone: '13000000000',
flow: PasscodeType.SignIn,
expiresAt: getTomorrowIsoString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/sms`);
expect(response.statusCode).toEqual(401);
});
it('throw error if sign in method is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
@ -645,6 +663,24 @@ describe('session -> passwordlessRoutes', () => {
expect(response.statusCode).toEqual(404);
});
it('throw when user is suspended', async () => {
findUserByEmail.mockResolvedValueOnce({
...mockUser,
isSuspended: true,
});
interactionDetails.mockResolvedValueOnce({
result: {
verification: {
email: 'a@a.com',
flow: PasscodeType.SignIn,
expiresAt: getTomorrowIsoString(),
},
},
});
const response = await sessionRequest.post(`${signInRoute}/email`);
expect(response.statusCode).toEqual(401);
});
it('throw error if sign in method is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,

View file

@ -0,0 +1,194 @@
import { ConnectorType } from '@logto/connector-kit';
import type { User } from '@logto/schemas';
import { SignUpIdentifier } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '@/__mocks__';
import { getLogtoConnectorById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import socialRoutes, { registerRoute } from './social';
const findSocialRelatedUser = jest.fn(async () => [
'phone',
{ id: 'user1', identities: {}, isSuspended: false },
]);
jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'),
findSocialRelatedUser: async () => findSocialRelatedUser(),
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
if (connectorId === '_connectorId') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
});
}
if (data.code === '123456') {
return { id: mockUser.id };
}
// This mocks the case that can not get userInfo with access token and auth code
// (most likely third-party social connectors' problem).
throw new Error(' ');
},
}));
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserByIdentity = jest.fn(async () => mockUser);
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => findUserByIdentity(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUserWithIdentity: async (target: string, userId: string) =>
target === 'connectorTarget' && userId === mockUser.id,
}));
jest.mock('@/lib/user', () => ({
generateUserId: () => 'user1',
insertUser: async (...args: unknown[]) => insertUser(...args),
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => ({
...mockSignInExperience,
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.None,
},
}),
}));
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
const database = {
enabled: connectorId === 'social_enabled',
};
const metadata = {
id:
connectorId === 'social_enabled'
? 'social_enabled'
: connectorId === 'social_disabled'
? 'social_disabled'
: 'others',
};
return {
dbEntry: database,
metadata,
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
getAuthorizationUri: jest.fn(async () => ''),
};
});
jest.mock('@/connectors', () => ({
getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList),
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connector;
}),
}));
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
interactionResult.mockClear();
});
describe('session -> socialRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: socialRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session/register/social', () => {
beforeEach(() => {
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
mockGetLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'connectorTarget' },
});
});
it('register with social, assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } },
},
});
const response = await sessionRequest
.post(`${registerRoute}`)
.send({ connectorId: 'connectorId' });
expect(insertUser).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user1',
identities: { connectorTarget: { userId: 'user1', details: { id: 'user1' } } },
})
);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ login: { accountId: 'user1', ts: expect.anything() } }),
expect.anything()
);
});
it('throw error if no result can be found in interactionResults', async () => {
interactionDetails.mockResolvedValueOnce({});
const response = await sessionRequest
.post(`${registerRoute}`)
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error if result parsing fails', async () => {
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: mockUser.id } } });
const response = await sessionRequest
.post(`${registerRoute}`)
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
it('throw error when user with identity exists', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: mockUser.id } },
},
});
const response = await sessionRequest
.post(`${registerRoute}`)
.send({ connectorId: 'connectorId' });
expect(response.statusCode).toEqual(400);
});
});
});

View file

@ -10,11 +10,13 @@ import { createRequester } from '@/utils/test-utils';
import socialRoutes, { registerRoute, signInRoute } from './social';
const findSocialRelatedUser = jest.fn(async () => [
'phone',
{ id: 'user1', identities: {}, isSuspended: false },
]);
jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'),
async findSocialRelatedUser() {
return ['phone', { id: 'user1', identities: {} }];
},
findSocialRelatedUser: async () => findSocialRelatedUser(),
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
if (connectorId === '_connectorId') {
throw new RequestError({
@ -36,10 +38,11 @@ jest.mock('@/lib/social', () => ({
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserByIdentity = jest.fn(async () => mockUser);
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => mockUser,
findUserByIdentity: async () => findUserByIdentity(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUserWithIdentity: async (target: string, userId: string) =>
target === 'connectorTarget' && userId === mockUser.id,
@ -238,6 +241,25 @@ describe('session -> socialRoutes', () => {
);
});
it('throw error when user is suspended', async () => {
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
metadata: { target: connectorTarget },
});
findUserByIdentity.mockResolvedValueOnce({
...mockUser,
isSuspended: true,
});
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
connectorId: 'connectorId',
data: {
state: 'state',
redirectUri: 'https://logto.dev',
code: '123456',
},
});
expect(response.statusCode).toEqual(401);
});
it('throw error when identity exists', async () => {
const wrongConnectorTarget = 'wrongConnectorTarget';
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
@ -287,6 +309,28 @@ describe('session -> socialRoutes', () => {
.send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw error when user is suspended', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
findSocialRelatedUser.mockResolvedValueOnce([
'phone',
{
...mockUser,
isSuspended: true,
},
]);
const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(401);
});
it('updates user identities and sign in', async () => {
interactionDetails.mockResolvedValueOnce({
result: {
@ -384,55 +428,4 @@ describe('session -> socialRoutes', () => {
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/bind-social', () => {
beforeEach(() => {
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
mockGetLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'connectorTarget' },
});
});
it('throw if session is not authorized', async () => {
interactionDetails.mockResolvedValueOnce({});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('throw if no social info in session', async () => {
interactionDetails.mockResolvedValueOnce({
result: { login: { accountId: 'user1' } },
});
await expect(
sessionRequest.post('/session/bind-social').send({ connectorId: 'connectorId' })
).resolves.toHaveProperty('statusCode', 400);
});
it('updates user identities', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
login: { accountId: 'user1' },
socialUserInfo: {
connectorId: 'connectorId',
userInfo: { id: 'connectorUser', phone: 'phone' },
},
},
});
const response = await sessionRequest.post('/session/bind-social').send({
connectorId: 'connectorId',
});
expect(response.statusCode).toEqual(200);
expect(updateUserById).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
identities: {
connectorTarget: {
details: { id: 'connectorUser', phone: 'phone' },
userId: 'connectorUser',
},
connector1: { userId: 'connector1', details: {} },
},
})
);
});
});
});

View file

@ -94,7 +94,8 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
}
const user = await findUserByIdentity(target, userInfo.id);
const { id, identities } = user;
const { id, identities, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });
// Update social connector's user info
@ -133,7 +134,8 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
const relatedInfo = await findSocialRelatedUser(userInfo);
assertThat(relatedInfo, 'session.connector_session_not_found');
const { id, identities } = relatedInfo[1];
const { id, identities, isSuspended } = relatedInfo[1];
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(type, { userId: id });
const user = await updateUserById(id, {

View file

@ -152,6 +152,22 @@ describe('signInWithPassword()', () => {
).rejects.toThrowError(new RequestError('session.invalid_credentials'));
});
it('throw if user is suspended', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
await expect(
signInWithPassword(createContext(), createProvider(), {
identifier: SignInIdentifier.Username,
password: 'password',
findUser: jest.fn(async () => ({
...mockUser,
isSuspended: true,
})),
logType: 'SignInUsernamePassword',
logPayload: { username: 'username' },
})
).rejects.toThrowError(new RequestError('user.suspended'));
});
it('throw if sign in method is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,

View file

@ -276,7 +276,8 @@ export const signInWithPassword = async (
const user = await findUser();
const verifiedUser = await verifyUserPassword(user, password);
const { id } = verifiedUser;
const { id, isSuspended } = verifiedUser;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
ctx.log(logType, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });

View file

@ -55,6 +55,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',

View file

@ -55,6 +55,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.',
sms_exists: 'This phone number is associated with an existing account.',
require_email_or_sms: 'You need to add an email address or phone number before signing-in.',
suspended: 'This account is suspended.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',

View file

@ -56,6 +56,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',

View file

@ -55,6 +55,7 @@ const errors = {
require_sms: 'You need to add a phone number before signing-in.', // UNTRANSLATED
sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED
require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: '请绑定手机号码',
sms_exists: '该手机号码已被其它账户绑定',
require_email_or_sms: '请绑定邮箱地址或手机号码',
suspended: '账号已被禁用',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}',

View file

@ -0,0 +1,18 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table users add column is_suspended boolean not null default false;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table users drop column is_suspended;
`);
},
};
export default alteration;

View file

@ -13,6 +13,7 @@ create table users (
role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb,
identities jsonb /* @use Identities */ not null default '{}'::jsonb,
custom_data jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
is_suspended boolean not null default false,
last_sign_in_at timestamptz,
created_at timestamptz not null default (now()),
primary key (id)