0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #2399 from logto-io/charles-log-4082-session-api-change-username

feat(core): add change username session api
This commit is contained in:
Charles Zhao 2022-11-15 17:05:31 +08:00 committed by GitHub
commit 4fa7a9e421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 176 additions and 6 deletions

View file

@ -23,7 +23,7 @@ import {
} from '@/queries/user'; } from '@/queries/user';
import assertThat from '@/utils/assert-that'; import assertThat from '@/utils/assert-that';
import { checkExistingSignUpIdentifiers } from './session/utils'; import { checkSignUpIdentifierCollision } from './session/utils';
import type { AuthedRouter } from './types'; import type { AuthedRouter } from './types';
export default function adminUserRoutes<T extends AuthedRouter>(router: T) { export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
@ -187,7 +187,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
} = ctx.guard; } = ctx.guard;
await findUserById(userId); await findUserById(userId);
await checkExistingSignUpIdentifiers(body, userId); await checkSignUpIdentifierCollision(body, userId);
// Temp solution to validate the existence of input roleNames // Temp solution to validate the existence of input roleNames
if (body.roleNames?.length) { if (body.roleNames?.length) {

View file

@ -18,6 +18,7 @@ import forgotPasswordRoutes from './forgot-password';
import koaGuardSessionAction from './middleware/koa-guard-session-action'; import koaGuardSessionAction from './middleware/koa-guard-session-action';
import passwordRoutes from './password'; import passwordRoutes from './password';
import passwordlessRoutes from './passwordless'; import passwordlessRoutes from './passwordless';
import profileRoutes from './profile';
import socialRoutes from './social'; import socialRoutes from './social';
import { getRoutePrefix } from './utils'; import { getRoutePrefix } from './utils';
@ -51,8 +52,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
const { accountId } = session; const { accountId } = session;
// Temp solution before migrating to RBAC. Block non-admin user from consent to admin console // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console
if (String(client_id) === adminConsoleApplicationId) { if (String(client_id) === adminConsoleApplicationId) {
const { roleNames } = await findUserById(accountId); const { roleNames } = await findUserById(accountId);
@ -105,6 +105,6 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
passwordlessRoutes(router, provider); passwordlessRoutes(router, provider);
socialRoutes(router, provider); socialRoutes(router, provider);
continueRoutes(router, provider); continueRoutes(router, provider);
forgotPasswordRoutes(router, provider); forgotPasswordRoutes(router, provider);
profileRoutes(router, provider);
} }

View file

@ -0,0 +1,93 @@
import type { CreateUser, User } from '@logto/schemas';
import { SignUpIdentifier } from '@logto/schemas';
import { getUnixTime } from 'date-fns';
import { Provider } from 'oidc-provider';
import { mockUser, mockUserResponse } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import profileRoutes, { profileRoute } from './profile';
const mockFindUserById = jest.fn(async (): Promise<User> => mockUser);
const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false);
const mockUpdateUserById = jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
);
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
Session: {
get: jest.fn(async () => ({ accountId: 'id', loginTs: getUnixTime(new Date()) - 60 })),
},
})),
}));
jest.mock('@/queries/user', () => ({
...jest.requireActual('@/queries/user'),
findUserById: async () => mockFindUserById(),
hasUser: async () => mockHasUser(),
hasUserWithEmail: async () => mockHasUserWithEmail(),
hasUserWithPhone: async () => mockHasUserWithPhone(),
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
}));
const mockFindDefaultSignInExperience = jest.fn(async () => ({
signUp: {
identifier: SignUpIdentifier.None,
password: false,
verify: false,
},
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()),
}));
describe('session -> profileRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: profileRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
test('GET /session/profile should return current user data', async () => {
const response = await sessionRequest.get(profileRoute);
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual(mockUserResponse);
});
describe('PATCH /session/profile/username', () => {
it('should update username with the new value', async () => {
const newUsername = 'charles';
const response = await sessionRequest
.patch(`${profileRoute}/username`)
.send({ username: newUsername });
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({ ...mockUserResponse, username: newUsername });
});
it('should throw when username is already in use', async () => {
mockHasUser.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/username`)
.send({ username: 'test' });
expect(response.statusCode).toEqual(422);
});
});
});

View file

@ -0,0 +1,55 @@
import { usernameRegEx } from '@logto/core-kit';
import { userInfoSelectFields } from '@logto/schemas';
import pick from 'lodash.pick';
import type { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { checkSessionHealth } from '@/lib/session';
import koaGuard from '@/middleware/koa-guard';
import { findUserById, updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types';
import { verificationTimeout } from './consts';
import { checkSignUpIdentifierCollision } from './utils';
export const profileRoute = '/session/profile';
export default function profileRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
router.get(profileRoute, async (ctx, next) => {
const { accountId } = await provider.Session.get(ctx);
if (!accountId) {
throw new RequestError('auth.unauthorized');
}
const user = await findUserById(accountId);
ctx.body = pick(user, ...userInfoSelectFields);
return next();
});
router.patch(
`${profileRoute}/username`,
koaGuard({
body: object({ username: string().regex(usernameRegEx) }),
}),
async (ctx, next) => {
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
assertThat(userId, new RequestError('auth.unauthorized'));
const { username } = ctx.guard.body;
await checkSignUpIdentifierCollision({ username }, userId);
const user = await updateUserById(userId, { username }, 'replace');
ctx.body = pick(user, ...userInfoSelectFields);
return next();
}
);
}

View file

@ -196,9 +196,31 @@ export const checkRequiredProfile = async (
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 }); throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
} }
}; };
export const checkMissingRequiredSignUpIdentifiers = async (identifiers: {
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
}) => {
// We do not check username as we decided to prohibit the removal of username from user profile.
const { primaryEmail, primaryPhone } = identifiers;
const { signUp } = await getSignInExperienceForApplication();
if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) {
throw new RequestError({ code: 'user.require_email', status: 422 });
}
if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) {
throw new RequestError({ code: 'user.require_sms', status: 422 });
}
if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) {
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
}
};
/* eslint-enable complexity */ /* eslint-enable complexity */
export const checkExistingSignUpIdentifiers = async ( export const checkSignUpIdentifierCollision = async (
identifiers: { identifiers: {
username?: Nullable<string>; username?: Nullable<string>;
primaryEmail?: Nullable<string>; primaryEmail?: Nullable<string>;