mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(core): social related profile session apis
This commit is contained in:
parent
8a7a9e418e
commit
89ad89cb42
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 type { CreateUser, User } from '@logto/schemas';
|
||||||
import { SignUpIdentifier } from '@logto/schemas';
|
import { ConnectorType, SignUpIdentifier } from '@logto/schemas';
|
||||||
import { getUnixTime } from 'date-fns';
|
import { getUnixTime } from 'date-fns';
|
||||||
import { Provider } from 'oidc-provider';
|
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 { createRequester } from '@/utils/test-utils';
|
||||||
|
|
||||||
import profileRoutes, { profileRoute } from './profile';
|
import profileRoutes, { profileRoute } from './profile';
|
||||||
|
@ -18,6 +25,7 @@ const mockUpdateUserById = jest.fn(
|
||||||
...data,
|
...data,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const mockDeleteUserIdentity = jest.fn();
|
||||||
const encryptUserPassword = jest.fn(async (password: string) => ({
|
const encryptUserPassword = jest.fn(async (password: string) => ({
|
||||||
passwordEncrypted: password + '_user1',
|
passwordEncrypted: password + '_user1',
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
passwordEncryptionMethod: 'Argon2i',
|
||||||
|
@ -38,6 +46,28 @@ jest.mock('@/lib/user', () => ({
|
||||||
encryptUserPassword: async (password: string) => encryptUserPassword(password),
|
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.mock('@/queries/user', () => ({
|
||||||
...jest.requireActual('@/queries/user'),
|
...jest.requireActual('@/queries/user'),
|
||||||
findUserById: async () => mockFindUserById(),
|
findUserById: async () => mockFindUserById(),
|
||||||
|
@ -45,6 +75,7 @@ jest.mock('@/queries/user', () => ({
|
||||||
hasUserWithEmail: async () => mockHasUserWithEmail(),
|
hasUserWithEmail: async () => mockHasUserWithEmail(),
|
||||||
hasUserWithPhone: async () => mockHasUserWithPhone(),
|
hasUserWithPhone: async () => mockHasUserWithPhone(),
|
||||||
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
|
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
|
||||||
|
deleteUserIdentity: async (...args: unknown[]) => mockDeleteUserIdentity(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFindDefaultSignInExperience = jest.fn(async () => ({
|
const mockFindDefaultSignInExperience = jest.fn(async () => ({
|
||||||
|
@ -382,4 +413,85 @@ describe('session -> profileRoutes', () => {
|
||||||
expect(mockUpdateUserById).not.toBeCalled();
|
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 { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
|
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
|
||||||
|
import { has } from '@silverhand/essentials';
|
||||||
import { argon2Verify } from 'hash-wasm';
|
import { argon2Verify } from 'hash-wasm';
|
||||||
import pick from 'lodash.pick';
|
import pick from 'lodash.pick';
|
||||||
import type { Provider } from 'oidc-provider';
|
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 RequestError from '@/errors/RequestError';
|
||||||
import { checkSessionHealth } from '@/lib/session';
|
import { checkSessionHealth } from '@/lib/session';
|
||||||
|
import { getUserInfoByAuthCode } from '@/lib/social';
|
||||||
import { encryptUserPassword } from '@/lib/user';
|
import { encryptUserPassword } from '@/lib/user';
|
||||||
import koaGuard from '@/middleware/koa-guard';
|
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 assertThat from '@/utils/assert-that';
|
||||||
|
|
||||||
import type { AnonymousRouter } from '../types';
|
import type { AnonymousRouter } from '../types';
|
||||||
|
@ -178,4 +181,66 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
|
||||||
|
|
||||||
return next();
|
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