mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #2470 from logto-io/charles-log-4079-log-4080-log-4081-social-related-profile-session-apis
feat(core): social related profile session apis
This commit is contained in:
commit
add9fb12c2
2 changed files with 181 additions and 4 deletions
|
@ -1,9 +1,16 @@
|
|||
/* eslint-disable max-lines */
|
||||
import type { CreateUser, User } from '@logto/schemas';
|
||||
import { SignUpIdentifier } from '@logto/schemas';
|
||||
import { ConnectorType, SignUpIdentifier } from '@logto/schemas';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockPasswordEncrypted, mockUser, mockUserResponse } from '@/__mocks__';
|
||||
import {
|
||||
mockLogtoConnectorList,
|
||||
mockPasswordEncrypted,
|
||||
mockUser,
|
||||
mockUserResponse,
|
||||
} from '@/__mocks__';
|
||||
import type { SocialUserInfo } from '@/connectors/types';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
import profileRoutes, { profileRoute } from './profile';
|
||||
|
@ -18,6 +25,7 @@ const mockUpdateUserById = jest.fn(
|
|||
...data,
|
||||
})
|
||||
);
|
||||
const mockDeleteUserIdentity = jest.fn();
|
||||
const encryptUserPassword = jest.fn(async (password: string) => ({
|
||||
passwordEncrypted: password + '_user1',
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
|
@ -38,6 +46,28 @@ jest.mock('@/lib/user', () => ({
|
|||
encryptUserPassword: async (password: string) => encryptUserPassword(password),
|
||||
}));
|
||||
|
||||
const mockGetLogtoConnectorById = jest.fn(async () => ({
|
||||
dbEntry: { enabled: true },
|
||||
metadata: { id: 'connectorId', target: 'mock_social' },
|
||||
type: ConnectorType.Social,
|
||||
getAuthorizationUri: jest.fn(async () => ''),
|
||||
}));
|
||||
|
||||
jest.mock('@/connectors', () => ({
|
||||
getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList),
|
||||
getLogtoConnectorById: jest.fn(async () => mockGetLogtoConnectorById()),
|
||||
}));
|
||||
|
||||
const mockFindSocialRelatedUser = jest.fn(async () => [
|
||||
{ id: 'user1', identities: {}, isSuspended: false },
|
||||
]);
|
||||
const mockGetUserInfoByAuthCode = jest.fn();
|
||||
jest.mock('@/lib/social', () => ({
|
||||
...jest.requireActual('@/lib/social'),
|
||||
findSocialRelatedUser: async () => mockFindSocialRelatedUser(),
|
||||
getUserInfoByAuthCode: async () => mockGetUserInfoByAuthCode(),
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/user', () => ({
|
||||
...jest.requireActual('@/queries/user'),
|
||||
findUserById: async () => mockFindUserById(),
|
||||
|
@ -45,6 +75,7 @@ jest.mock('@/queries/user', () => ({
|
|||
hasUserWithEmail: async () => mockHasUserWithEmail(),
|
||||
hasUserWithPhone: async () => mockHasUserWithPhone(),
|
||||
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
|
||||
deleteUserIdentity: async (...args: unknown[]) => mockDeleteUserIdentity(...args),
|
||||
}));
|
||||
|
||||
const mockFindDefaultSignInExperience = jest.fn(async () => ({
|
||||
|
@ -382,4 +413,85 @@ describe('session -> profileRoutes', () => {
|
|||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('social identities related APIs', () => {
|
||||
it('should update social identities for current user', async () => {
|
||||
const mockSocialUserInfo: SocialUserInfo = {
|
||||
id: 'social_user_id',
|
||||
name: 'John Doe',
|
||||
avatar: 'https://avatar.social.com/johndoe',
|
||||
email: 'johndoe@social.com',
|
||||
phone: '123456789',
|
||||
};
|
||||
mockGetUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo);
|
||||
|
||||
const response = await sessionRequest.patch(`${profileRoute}/identities`).send({
|
||||
connectorId: 'connectorId',
|
||||
data: { code: '123456' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(mockUpdateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
identities: {
|
||||
...mockUser.identities,
|
||||
mock_social: { userId: mockSocialUserInfo.id, details: mockSocialUserInfo },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when the user is not authenticated', async () => {
|
||||
mockGetSession.mockImplementationOnce(
|
||||
jest.fn(async () => ({
|
||||
accountId: undefined,
|
||||
loginTs: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await sessionRequest.patch(`${profileRoute}/identities`).send({
|
||||
connectorId: 'connectorId',
|
||||
data: { code: '123456' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(401);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw if last authentication time is over 10 mins ago on linking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/identities`)
|
||||
.send({ connectorId: 'connectorId', data: { code: '123456' } });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unlink social identities from user', async () => {
|
||||
mockFindUserById.mockImplementationOnce(async () => ({
|
||||
...mockUser,
|
||||
identities: {
|
||||
mock_social: {
|
||||
userId: 'social_user_id',
|
||||
details: {
|
||||
id: 'social_user_id',
|
||||
name: 'John Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await sessionRequest.delete(`${profileRoute}/identities/mock_social`);
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(mockDeleteUserIdentity).toBeCalledWith('id', 'mock_social');
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
|
||||
import { has } from '@silverhand/essentials';
|
||||
import { argon2Verify } from 'hash-wasm';
|
||||
import pick from 'lodash.pick';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import { object, string } from 'zod';
|
||||
import { object, string, unknown } from 'zod';
|
||||
|
||||
import { getLogtoConnectorById } from '@/connectors';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { checkSessionHealth } from '@/lib/session';
|
||||
import { getUserInfoByAuthCode } from '@/lib/social';
|
||||
import { encryptUserPassword } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { findUserById, updateUserById } from '@/queries/user';
|
||||
import { deleteUserIdentity, findUserById, updateUserById } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import type { AnonymousRouter } from '../types';
|
||||
|
@ -178,4 +181,66 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/identities`,
|
||||
koaGuard({
|
||||
body: object({
|
||||
connectorId: string(),
|
||||
data: unknown(),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { connectorId, data } = ctx.guard.body;
|
||||
|
||||
const {
|
||||
metadata: { target },
|
||||
} = await getLogtoConnectorById(connectorId);
|
||||
|
||||
const socialUserInfo = await getUserInfoByAuthCode(connectorId, data);
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
await updateUserById(userId, {
|
||||
identities: {
|
||||
...identities,
|
||||
[target]: { userId: socialUserInfo.id, details: socialUserInfo },
|
||||
},
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
`${profileRoute}/identities/:target`,
|
||||
koaGuard({
|
||||
params: object({ target: string() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { accountId: userId } = await provider.Session.get(ctx);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { target } = ctx.guard.params;
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
has(identities, target),
|
||||
new RequestError({ code: 'user.identity_not_exists', status: 404 })
|
||||
);
|
||||
|
||||
console.log('##############shit:', userId, target);
|
||||
await deleteUserIdentity(userId, target);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue