0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): require set a password (#2272)

This commit is contained in:
wangsijie 2022-10-31 11:37:06 +08:00 committed by GitHub
parent e1d3d34523
commit bf73837839
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 283 additions and 19 deletions

View file

@ -8,8 +8,8 @@ export const mockUser: User = {
primaryEmail: 'foo@logto.io',
primaryPhone: '111111',
roleNames: ['admin'],
passwordEncrypted: null,
passwordEncryptionMethod: null,
passwordEncrypted: 'password',
passwordEncryptionMethod: UsersPasswordEncryptionMethod.Argon2i,
name: null,
avatar: null,
identities: {

View file

@ -1 +1,2 @@
export const verificationTimeout = 10 * 60; // 10 mins.
export const continueSignInTimeout = 10 * 60; // 10 mins.

View file

@ -0,0 +1,113 @@
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import continueRoutes, { continueRoute } from './continue';
const checkRequiredProfile = jest.fn();
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
checkRequiredProfile: () => checkRequiredProfile(),
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: jest.fn(),
}));
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
const findUserById = jest.fn(async (..._args: unknown[]) => mockUser);
jest.mock('@/queries/user', () => ({
updateUserById: async (...args: unknown[]) => updateUserById(...args),
findUserById: async () => findUserById(),
}));
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 -> continueRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: continueRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session/sign-in/continue/password', () => {
it('updates user password, checks required profile, and sign in', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().add(1, 'day').toISOString(),
},
},
});
findUserById.mockResolvedValueOnce({
...mockUser,
passwordEncrypted: null,
identities: {},
});
const response = await sessionRequest.post(`${continueRoute}/password`).send({
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(checkRequiredProfile).toHaveBeenCalled();
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: mockUser.id } }),
expect.anything()
);
});
it('throws on empty continue sign in storage', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {},
});
const response = await sessionRequest.post(`${continueRoute}/password`).send({
password: 'password',
});
expect(response.statusCode).toEqual(401);
});
it('throws on expired continue sign in storage', async () => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
result: {
continueSignIn: {
userId: mockUser.id,
expiresAt: dayjs().subtract(1, 'second').toISOString(),
},
},
});
const response = await sessionRequest.post(`${continueRoute}/password`).send({
password: 'password',
});
expect(response.statusCode).toEqual(401);
});
});
});

View file

@ -0,0 +1,51 @@
import { passwordRegEx } from '@logto/core-kit';
import type { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { encryptUserPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { findUserById, updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types';
import { checkRequiredProfile, getContinueSignInResult, getRoutePrefix } from './utils';
export const continueRoute = getRoutePrefix('sign-in', 'continue');
export default function continueRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
router.post(
`${continueRoute}/password`,
koaGuard({
body: object({
password: string().regex(passwordRegEx),
}),
}),
async (ctx, next) => {
const { password } = ctx.guard.body;
const { userId } = await getContinueSignInResult(ctx, provider);
const user = await findUserById(userId);
// Social identities can take place the role of password
assertThat(
!user.passwordEncrypted && Object.keys(user.identities).length === 0,
new RequestError({
code: 'user.password_exists',
})
);
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const updatedUser = await updateUserById(userId, {
passwordEncrypted,
passwordEncryptionMethod,
});
const signInExperience = await findDefaultSignInExperience();
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
return next();
}
);
}

View file

@ -13,6 +13,7 @@ import { findUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types';
import continueRoutes from './continue';
import forgotPasswordRoutes from './forgot-password';
import koaGuardSessionAction from './middleware/koa-guard-session-action';
import passwordRoutes from './password';
@ -103,6 +104,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
passwordRoutes(router, provider);
passwordlessRoutes(router, provider);
socialRoutes(router, provider);
continueRoutes(router, provider);
forgotPasswordRoutes(router, provider);
}

View file

@ -21,6 +21,7 @@ import {
getVerificationStorageFromInteraction,
getPasswordlessRelatedLogType,
checkValidateExpiration,
checkRequiredProfile,
} from '../utils';
export const smsSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
@ -57,9 +58,11 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
new RequestError({ code: 'user.phone_not_exists', status: 404 })
);
const { id } = await findUserByPhone(phone);
const user = await findUserByPhone(phone);
const { id } = user;
ctx.log(type, { userId: id });
await checkRequiredProfile(ctx, provider, user, signInExperience);
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
@ -101,9 +104,11 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
new RequestError({ code: 'user.email_not_exists', status: 404 })
);
const { id } = await findUserByEmail(email);
const user = await findUserByEmail(email);
const { id } = user;
ctx.log(type, { userId: id });
await checkRequiredProfile(ctx, provider, user, signInExperience);
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
@ -145,7 +150,8 @@ export const smsRegisterAction = <StateT, ContextT extends WithLogContext, Respo
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
const user = await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
await checkRequiredProfile(ctx, provider, user, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
@ -186,7 +192,8 @@ export const emailRegisterAction = <StateT, ContextT extends WithLogContext, Res
const id = await generateUserId();
ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
const user = await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
await checkRequiredProfile(ctx, provider, user, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();

View file

@ -12,14 +12,17 @@ import { verificationTimeout } from './consts';
import * as passwordlessActions from './middleware/passwordless-action';
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'foo' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserByEmail = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'foo' }));
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Username,
password: false,
verify: true,
},
}));
@ -30,8 +33,8 @@ jest.mock('@/lib/user', () => ({
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByPhone: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ id: 'id' }),
findUserByPhone: async () => ({ id: 'foo' }),
findUserByEmail: async () => findUserByEmail(),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
hasUserWithPhone: async (phone: string) => phone === '13000000000',
@ -247,7 +250,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.objectContaining({
verification: {
userId: 'id',
userId: 'foo',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
flow: PasscodeType.ForgotPassword,
},
@ -343,7 +346,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.objectContaining({
verification: {
userId: 'id',
userId: 'foo',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
flow: PasscodeType.ForgotPassword,
},
@ -388,7 +391,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
login: { accountId: 'foo' },
}),
expect.anything()
);
@ -410,7 +413,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
login: { accountId: 'foo' },
}),
expect.anything()
);
@ -523,6 +526,8 @@ describe('session -> passwordlessRoutes', () => {
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Email,
password: false,
verify: true,
},
});
});
@ -549,7 +554,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
login: { accountId: 'foo' },
}),
expect.anything()
);
@ -573,7 +578,7 @@ describe('session -> passwordlessRoutes', () => {
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
login: { accountId: 'foo' },
}),
expect.anything()
);
@ -657,6 +662,7 @@ describe('session -> passwordlessRoutes', () => {
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Sms,
password: false,
},
});
});
@ -784,6 +790,7 @@ describe('session -> passwordlessRoutes', () => {
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Email,
password: false,
},
});
});

View file

@ -51,3 +51,10 @@ export type VerificationStorage =
| ForgotPasswordSessionStorage;
export type VerificationResult<T = VerificationStorage> = { verification: T };
export const continueSignInStorageGuard = z.object({
userId: z.string(),
expiresAt: z.string(),
});
export type ContinueSignInStorage = z.infer<typeof continueSignInStorageGuard>;

View file

@ -1,4 +1,11 @@
import type { LogPayload, LogType, PasscodeType, SignInIdentifier, User } from '@logto/schemas';
import type {
LogPayload,
LogType,
PasscodeType,
SignInExperience,
SignInIdentifier,
User,
} from '@logto/schemas';
import { logTypeGuard } from '@logto/schemas';
import type { Nullable, Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs';
@ -15,12 +22,13 @@ import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { verificationTimeout } from './consts';
import { continueSignInTimeout, verificationTimeout } from './consts';
import type { Method, Operation, VerificationResult, VerificationStorage } from './types';
import { continueSignInStorageGuard } from './types';
export const getRoutePrefix = (
type: 'sign-in' | 'register' | 'forgot-password',
method?: 'passwordless' | 'password' | 'social'
method?: 'passwordless' | 'password' | 'social' | 'continue'
) => {
return ['session', type, method]
.filter((value): value is Truthy<typeof value> => value !== undefined)
@ -100,6 +108,62 @@ export const clearVerificationResult = async (ctx: Context, provider: Provider)
}
};
export const assignContinueSignInResult = async (
ctx: Context,
provider: Provider,
payload: { userId: string }
) => {
await provider.interactionResult(ctx.req, ctx.res, {
continueSignIn: {
...payload,
expiresAt: dayjs().add(continueSignInTimeout, 'second').toISOString(),
},
});
};
export const getContinueSignInResult = async (ctx: Context, provider: Provider) => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const signInResult = z
.object({
continueSignIn: continueSignInStorageGuard,
})
.safeParse(result);
if (!signInResult.success) {
throw new RequestError({
code: 'session.unauthorized',
status: 401,
});
}
const { expiresAt, ...rest } = signInResult.data.continueSignIn;
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.unauthorized', status: 401 })
);
return rest;
};
export const checkRequiredProfile = async (
ctx: Context,
provider: Provider,
user: User,
signInExperience: SignInExperience
) => {
const { signUp } = signInExperience;
const { passwordEncrypted, id } = user;
// If check failed, save the sign in result, the user can continue after requirements are meet
if (signUp.password && !passwordEncrypted) {
await assignContinueSignInResult(ctx, provider, { userId: id });
throw new RequestError({ code: 'user.require_password', status: 422 });
}
};
type SignInWithPasswordParameter = {
identifier: SignInIdentifier;
password: string;

View file

@ -45,6 +45,8 @@ const errors = {
sign_up_method_not_enabled: 'This sign up method is not enabled.',
sign_in_method_not_enabled: 'This sign in method is not enabled.',
same_password: 'Your new password cant be the same as your current password.',
require_password: 'You need to set a password before sign in.',
password_exists: 'Your password has been set.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',

View file

@ -46,6 +46,8 @@ const errors = {
sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
password_exists: 'Your password has been set.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",

View file

@ -44,6 +44,8 @@ const errors = {
sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
password_exists: 'Your password has been set.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',

View file

@ -44,6 +44,8 @@ const errors = {
sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
password_exists: 'Your password has been set.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',

View file

@ -45,6 +45,8 @@ const errors = {
sign_up_method_not_enabled: 'This sign up method is not enabled.', // UNTRANSLATED
sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED
same_password: 'Your new password cant be the same as your current password.', // UNTRANSLATED
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
password_exists: 'Your password has been set.', // UNTRANSLATED
},
password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',

View file

@ -44,6 +44,8 @@ const errors = {
sign_up_method_not_enabled: '注册方式尚未启用',
sign_in_method_not_enabled: '登录方式尚未启用',
same_password: '为确保你的账户安全,新密码不能与旧密码一致',
require_password: '请设置密码',
password_exists: '密码已设置过',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}',