0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core): block suspended user from sign in (#2366)

This commit is contained in:
wangsijie 2022-11-10 15:49:26 +08:00 committed by GitHub
parent fc44c9a503
commit f75d5e605e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 312 additions and 61 deletions

View file

@ -59,7 +59,8 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
);
const user = await findUserByPhone(phone);
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);
@ -105,7 +106,8 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
);
const user = await findUserByEmail(email);
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

@ -15,6 +15,7 @@ import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const findUserByEmail = jest.fn(async (): Promise<User> => mockUser);
const findUserByPhone = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
@ -34,7 +35,7 @@ jest.mock('@/lib/user', () => ({
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByPhone: async () => mockUser,
findUserByPhone: async () => findUserByPhone(),
findUserByEmail: async () => findUserByEmail(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
@ -503,6 +504,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,
@ -639,6 +658,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,193 @@
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(),
expect.objectContaining({ login: { accountId: 'user1' } }),
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

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

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.',
sms_exists: 'Your phone has been set.',
require_email_or_sms: 'You need to set a phone or email before sign in.',
suspended: 'This account is suspended.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',

View file

@ -55,6 +55,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign 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

@ -53,6 +53,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',

View file

@ -53,6 +53,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',

View file

@ -54,6 +54,7 @@ const errors = {
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
sms_exists: 'Your phone has been set.', // UNTRANSLATED
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
suspended: 'This account is suspended.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',

View file

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