0
Fork 0
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:
Charles Zhao 2022-11-21 16:49:14 +08:00 committed by GitHub
commit add9fb12c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 181 additions and 4 deletions

View file

@ -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 */

View file

@ -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();
}
);
}