0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core): password sign in with phone or email (#2266)

This commit is contained in:
wangsijie 2022-10-28 15:05:02 +08:00 committed by GitHub
parent 3e6021ad16
commit 415c24aace
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 356 additions and 98 deletions

View file

@ -1,10 +1,9 @@
import type { User } from '@logto/schemas';
import { UserRole, SignInIdentifier, SignUpIdentifier } from '@logto/schemas';
import { UserRole, SignUpIdentifier } from '@logto/schemas';
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
import { Provider } from 'oidc-provider';
import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { mockSignInExperience, mockUser } from '@/__mocks__';
import { createRequester } from '@/utils/test-utils';
import passwordRoutes, { registerRoute, signInRoute } from './password';
@ -13,13 +12,7 @@ const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const hasActiveUsers = jest.fn(async () => true);
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Username,
},
}));
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
@ -36,7 +29,7 @@ jest.mock('@/queries/user', () => ({
async findUserByUsername(username: string) {
const roleNames = username === 'admin' ? [UserRole.Admin] : [];
return { id: 'user1', username, roleNames };
return { id: 'id', username, roleNames };
},
}));
@ -45,17 +38,7 @@ jest.mock('@/queries/sign-in-experience', () => ({
}));
jest.mock('@/lib/user', () => ({
async verifyUserPassword(user: User, password: string) {
const { username } = user;
if (username !== 'username' && username !== 'admin') {
throw new RequestError('session.invalid_credentials');
}
if (password !== 'password') {
throw new RequestError('session.invalid_credentials');
}
async verifyUserPassword(user: User) {
return user;
},
generateUserId: () => 'user1',
@ -116,60 +99,52 @@ describe('session -> password routes', () => {
],
});
describe('POST /session/sign-in/password/username', () => {
it('assign result and redirect', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({
username: 'username',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'user1' } }),
expect.anything()
);
it('POST /session/sign-in/password/username', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({
username: 'username',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw if user not found', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({
username: 'notexistuser',
password: 'password',
});
expect(response.statusCode).toEqual(400);
it('POST /session/sign-in/password/email', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/email`).send({
email: 'email',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
it('throw if user found but wrong password', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({
username: 'username',
password: '_password',
});
expect(response.statusCode).toEqual(400);
});
it('throw if sign in method is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signIn: {
methods: [
{
...mockSignInMethod,
identifier: SignInIdentifier.Sms,
password: false,
},
],
},
});
const response = await sessionRequest.post(`${signInRoute}/username`).send({
username: 'username',
password: 'password',
});
expect(response.statusCode).toEqual(422);
it('POST /session/sign-in/password/sms', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/sms`).send({
phone: 'phone',
password: 'password',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: 'id' } }),
expect.anything()
);
});
describe('POST /session/register/password/username', () => {

View file

@ -6,14 +6,20 @@ import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { verifyUserPassword, encryptUserPassword, generateUserId, insertUser } from '@/lib/user';
import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { findUserByUsername, hasActiveUsers, hasUser, updateUserById } from '@/queries/user';
import {
findUserByEmail,
findUserByPhone,
findUserByUsername,
hasActiveUsers,
hasUser,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils';
import { getRoutePrefix, signInWithPassword } from './utils';
export const registerRoute = getRoutePrefix('register', 'password');
export const signInRoute = getRoutePrefix('sign-in', 'password');
@ -28,28 +34,61 @@ export default function passwordRoutes<T extends AnonymousRouter>(router: T, pro
}),
}),
async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
assertThat(
signInExperience.signIn.methods.some(
({ identifier, password }) => identifier === SignInIdentifier.Username && password
),
new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
})
);
await provider.interactionDetails(ctx.req, ctx.res);
const { username, password } = ctx.guard.body;
const type = 'SignInUsernamePassword';
ctx.log(type, { username });
await signInWithPassword(ctx, provider, {
identifier: SignInIdentifier.Username,
password,
logType: type,
logPayload: { username },
findUser: async () => findUserByUsername(username),
});
const user = await findUserByUsername(username);
const { id } = await verifyUserPassword(user, password);
return next();
}
);
ctx.log(type, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
router.post(
`${signInRoute}/email`,
koaGuard({
body: object({
email: string().min(1),
password: string().min(1),
}),
}),
async (ctx, next) => {
const { email, password } = ctx.guard.body;
const type = 'SignInEmailPassword';
await signInWithPassword(ctx, provider, {
identifier: SignInIdentifier.Email,
password,
logType: type,
logPayload: { email },
findUser: async () => findUserByEmail(email),
});
return next();
}
);
router.post(
`${signInRoute}/sms`,
koaGuard({
body: object({
phone: string().min(1),
password: string().min(1),
}),
}),
async (ctx, next) => {
const { phone, password } = ctx.guard.body;
const type = 'SignInSmsPassword';
await signInWithPassword(ctx, provider, {
identifier: SignInIdentifier.Sms,
password,
logType: type,
logPayload: { phone },
findUser: async () => findUserByPhone(phone),
});
return next();
}

View file

@ -0,0 +1,184 @@
import type { User } from '@logto/schemas';
import { UserRole, SignInIdentifier, SignUpIdentifier } from '@logto/schemas';
import { createMockContext } from '@shopify/jest-koa-mocks';
import type { Nullable } from '@silverhand/essentials';
import { Provider } from 'oidc-provider';
import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { signInWithPassword } from './utils';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const hasActiveUsers = jest.fn(async () => true);
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Username,
},
}));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
findUserByPhone: async () => ({ id: 'id' }),
findUserByEmail: async () => ({ id: 'id' }),
updateUserById: async (...args: unknown[]) => updateUserById(...args),
hasUser: async (username: string) => username === 'username1',
hasUserWithIdentity: async (connectorId: string, userId: string) =>
connectorId === 'connectorId' && userId === 'id',
hasUserWithPhone: async (phone: string) => phone === '13000000000',
hasUserWithEmail: async (email: string) => email === 'a@a.com',
hasActiveUsers: async () => hasActiveUsers(),
async findUserByUsername(username: string) {
const roleNames = username === 'admin' ? [UserRole.Admin] : [];
return { id: 'user1', username, roleNames };
},
}));
jest.mock('@/queries/sign-in-experience', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
}));
jest.mock('@/lib/user', () => ({
async verifyUserPassword(user: Nullable<User>, password: string) {
if (!user) {
throw new RequestError('session.invalid_credentials');
}
if (password !== 'password') {
throw new RequestError('session.invalid_credentials');
}
return user;
},
generateUserId: () => 'user1',
encryptUserPassword: (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}),
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
insertUser: async (...args: unknown[]) => insertUser(...args),
}));
const grantSave = jest.fn(async () => 'finalGrantId');
const grantAddOIDCScope = jest.fn();
const grantAddResourceScope = jest.fn();
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
class Grant {
static async find(id: string) {
return id === 'exists' ? new Grant() : undefined;
}
save: typeof grantSave;
addOIDCScope: typeof grantAddOIDCScope;
addResourceScope: typeof grantAddResourceScope;
constructor() {
this.save = grantSave;
this.addOIDCScope = grantAddOIDCScope;
this.addResourceScope = grantAddResourceScope;
}
}
const createContext = () => ({
...createMockContext(),
addLogContext: jest.fn(),
log: jest.fn(),
});
const createProvider = () => new Provider('');
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
Grant,
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
grantSave.mockClear();
interactionResult.mockClear();
});
describe('signInWithPassword()', () => {
it('assign result', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
await signInWithPassword(createContext(), createProvider(), {
identifier: SignInIdentifier.Username,
password: 'password',
findUser: jest.fn(async () => mockUser),
logType: 'SignInUsernamePassword',
logPayload: { username: 'username' },
});
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ login: { accountId: mockUser.id } }),
expect.anything()
);
});
it('throw if user not found', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
await expect(
signInWithPassword(createContext(), createProvider(), {
identifier: SignInIdentifier.Username,
password: 'password',
findUser: jest.fn(async () => null),
logType: 'SignInUsernamePassword',
logPayload: { username: 'username' },
})
).rejects.toThrowError(new RequestError('session.invalid_credentials'));
});
it('throw if user found but wrong password', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} });
await expect(
signInWithPassword(createContext(), createProvider(), {
identifier: SignInIdentifier.Username,
password: '_password',
findUser: jest.fn(async () => mockUser),
logType: 'SignInUsernamePassword',
logPayload: { username: 'username' },
})
).rejects.toThrowError(new RequestError('session.invalid_credentials'));
});
it('throw if sign in method is not enabled', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signIn: {
methods: [
{
...mockSignInMethod,
identifier: SignInIdentifier.Sms,
password: false,
},
],
},
});
interactionDetails.mockResolvedValueOnce({ params: {} });
await expect(
signInWithPassword(createContext(), createProvider(), {
identifier: SignInIdentifier.Username,
password: 'password',
findUser: jest.fn(async () => mockUser),
logType: 'SignInUsernamePassword',
logPayload: { username: 'username' },
})
).rejects.toThrowError(
new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
})
);
});
});

View file

@ -1,6 +1,6 @@
import type { LogType, PasscodeType } from '@logto/schemas';
import type { LogPayload, LogType, PasscodeType, SignInIdentifier, User } from '@logto/schemas';
import { logTypeGuard } from '@logto/schemas';
import type { Truthy } from '@silverhand/essentials';
import type { Nullable, Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
@ -8,6 +8,11 @@ import type { ZodType } from 'zod';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { verifyUserPassword } from '@/lib/user';
import type { LogContext } from '@/middleware/koa-log';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { verificationTimeout } from './consts';
@ -94,3 +99,38 @@ export const clearVerificationResult = async (ctx: Context, provider: Provider)
await provider.interactionResult(ctx.req, ctx.res, rest);
}
};
type SignInWithPasswordParameter = {
identifier: SignInIdentifier;
password: string;
logType: LogType;
logPayload: LogPayload;
findUser: () => Promise<Nullable<User>>;
};
export const signInWithPassword = async (
ctx: Context & LogContext,
provider: Provider,
{ identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter
) => {
const signInExperience = await findDefaultSignInExperience();
assertThat(
signInExperience.signIn.methods.some(
(method) => method.password && method.identifier === identifier
),
new RequestError({
code: 'user.sign_in_method_not_enabled',
status: 422,
})
);
await provider.interactionDetails(ctx.req, ctx.res);
ctx.log(logType, logPayload);
const user = await findUser();
const { id } = await verifyUserPassword(user, password);
ctx.log(logType, { userId: id });
await updateUserById(id, { lastSignInAt: Date.now() });
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
};

View file

@ -72,8 +72,12 @@ export const setUpConnector = async (connectorId: string, config: Record<string,
assert(connector.enabled, new Error('Connector Setup Failed'));
};
export const setSignUpIdentifier = async (identifier: SignUpIdentifier) => {
await updateSignInExperience({ signUp: { identifier, password: true, verify: true } });
export const setSignUpIdentifier = async (
identifier: SignUpIdentifier,
password = true,
verify = true
) => {
await updateSignInExperience({ signUp: { identifier, password, verify } });
};
type PasscodeRecord = {

View file

@ -47,7 +47,7 @@ describe('username and password flow', () => {
describe('email passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig);
await setSignUpIdentifier(SignUpIdentifier.Email);
await setSignUpIdentifier(SignUpIdentifier.Email, false);
});
// Since we can not create a email register user throw admin. Have to run the register then sign-in concurrently.
@ -121,7 +121,7 @@ describe('email passwordless flow', () => {
describe('sms passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig);
await setSignUpIdentifier(SignUpIdentifier.Sms);
await setSignUpIdentifier(SignUpIdentifier.Sms, false);
});
// Since we can not create a sms register user throw admin. Have to run the register then sign-in concurrently.

View file

@ -79,6 +79,20 @@ const signInUsernamePasswordLogPayloadGuard = arbitraryLogPayloadGuard.and(
})
);
const signInEmailPasswordLogPayloadGuard = arbitraryLogPayloadGuard.and(
z.object({
userId: z.string().optional(),
email: z.string().optional(),
})
);
const signInSmsPasswordLogPayloadGuard = arbitraryLogPayloadGuard.and(
z.object({
userId: z.string().optional(),
sms: z.string().optional(),
})
);
const signInEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and(
z.object({
email: z.string().optional(),
@ -197,6 +211,8 @@ const logPayloadsGuard = z.object({
RegisterSocialBind: registerSocialBindLogPayloadGuard,
RegisterSocial: registerSocialLogPayloadGuard,
SignInUsernamePassword: signInUsernamePasswordLogPayloadGuard,
SignInEmailPassword: signInEmailPasswordLogPayloadGuard,
SignInSmsPassword: signInSmsPasswordLogPayloadGuard,
SignInEmailSendPasscode: signInEmailSendPasscodeLogPayloadGuard,
SignInEmail: signInEmailLogPayloadGuard,
SignInSmsSendPasscode: signInSmsSendPasscodeLogPayloadGuard,