mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
Merge pull request #2320 from logto-io/feature/suspend
feat: suspend user
This commit is contained in:
commit
4a4a59f44d
20 changed files with 394 additions and 61 deletions
packages
core/src
__mocks__
queries
routes
phrases/src/locales
schemas
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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}
|
||||
`);
|
||||
};
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
194
packages/core/src/routes/session/social.bind-social.test.ts
Normal file
194
packages/core/src/routes/session/social.bind-social.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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: {} },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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}} 암호화 방법을 지원하지 않아요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -54,6 +54,7 @@ const errors = {
|
|||
require_sms: '请绑定手机号码',
|
||||
sms_exists: '该手机号码已被其它账户绑定',
|
||||
require_email_or_sms: '请绑定邮箱地址或手机号码',
|
||||
suspended: '账号已被禁用',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}',
|
||||
|
|
18
packages/schemas/alterations/next-1667374974-user-suspend.ts
Normal file
18
packages/schemas/alterations/next-1667374974-user-suspend.ts
Normal 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;
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue