0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -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 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 { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
import { Provider } from 'oidc-provider'; import { Provider } from 'oidc-provider';
import { mockSignInExperience, mockSignInMethod, mockUser } from '@/__mocks__'; import { mockSignInExperience, mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils'; import { createRequester } from '@/utils/test-utils';
import passwordRoutes, { registerRoute, signInRoute } from './password'; 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 findUserById = jest.fn(async (): Promise<User> => mockUser);
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
const hasActiveUsers = jest.fn(async () => true); const hasActiveUsers = jest.fn(async () => true);
const findDefaultSignInExperience = jest.fn(async () => ({ const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
...mockSignInExperience,
signUp: {
...mockSignInExperience.signUp,
identifier: SignUpIdentifier.Username,
},
}));
jest.mock('@/queries/user', () => ({ jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(), findUserById: async () => findUserById(),
@ -36,7 +29,7 @@ jest.mock('@/queries/user', () => ({
async findUserByUsername(username: string) { async findUserByUsername(username: string) {
const roleNames = username === 'admin' ? [UserRole.Admin] : []; 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', () => ({ jest.mock('@/lib/user', () => ({
async verifyUserPassword(user: User, password: string) { async verifyUserPassword(user: User) {
const { username } = user;
if (username !== 'username' && username !== 'admin') {
throw new RequestError('session.invalid_credentials');
}
if (password !== 'password') {
throw new RequestError('session.invalid_credentials');
}
return user; return user;
}, },
generateUserId: () => 'user1', generateUserId: () => 'user1',
@ -116,60 +99,52 @@ describe('session -> password routes', () => {
], ],
}); });
describe('POST /session/sign-in/password/username', () => { it('POST /session/sign-in/password/username', async () => {
it('assign result and redirect', async () => { interactionDetails.mockResolvedValueOnce({ params: {} });
interactionDetails.mockResolvedValueOnce({ params: {} }); const response = await sessionRequest.post(`${signInRoute}/username`).send({
const response = await sessionRequest.post(`${signInRoute}/username`).send({ username: 'username',
username: 'username', password: 'password',
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()
);
}); });
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 () => { it('POST /session/sign-in/password/email', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} }); interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({ const response = await sessionRequest.post(`${signInRoute}/email`).send({
username: 'notexistuser', email: 'email',
password: 'password', password: 'password',
});
expect(response.statusCode).toEqual(400);
}); });
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 () => { it('POST /session/sign-in/password/sms', async () => {
interactionDetails.mockResolvedValueOnce({ params: {} }); interactionDetails.mockResolvedValueOnce({ params: {} });
const response = await sessionRequest.post(`${signInRoute}/username`).send({ const response = await sessionRequest.post(`${signInRoute}/sms`).send({
username: 'username', phone: 'phone',
password: '_password', 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);
}); });
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', () => { describe('POST /session/register/password/username', () => {

View file

@ -6,14 +6,20 @@ import { object, string } from 'zod';
import RequestError from '@/errors/RequestError'; import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session'; 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 koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; 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 assertThat from '@/utils/assert-that';
import type { AnonymousRouter } from '../types'; import type { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils'; import { getRoutePrefix, signInWithPassword } from './utils';
export const registerRoute = getRoutePrefix('register', 'password'); export const registerRoute = getRoutePrefix('register', 'password');
export const signInRoute = getRoutePrefix('sign-in', '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) => { 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 { username, password } = ctx.guard.body;
const type = 'SignInUsernamePassword'; 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); return next();
const { id } = await verifyUserPassword(user, password); }
);
ctx.log(type, { userId: id }); router.post(
await updateUserById(id, { lastSignInAt: Date.now() }); `${signInRoute}/email`,
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); 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(); 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 { logTypeGuard } from '@logto/schemas';
import type { Truthy } from '@silverhand/essentials'; import type { Nullable, Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Context } from 'koa'; import type { Context } from 'koa';
import type { Provider } from 'oidc-provider'; import type { Provider } from 'oidc-provider';
@ -8,6 +8,11 @@ import type { ZodType } from 'zod';
import { z } from 'zod'; import { z } from 'zod';
import RequestError from '@/errors/RequestError'; 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 assertThat from '@/utils/assert-that';
import { verificationTimeout } from './consts'; import { verificationTimeout } from './consts';
@ -94,3 +99,38 @@ export const clearVerificationResult = async (ctx: Context, provider: Provider)
await provider.interactionResult(ctx.req, ctx.res, rest); 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')); assert(connector.enabled, new Error('Connector Setup Failed'));
}; };
export const setSignUpIdentifier = async (identifier: SignUpIdentifier) => { export const setSignUpIdentifier = async (
await updateSignInExperience({ signUp: { identifier, password: true, verify: true } }); identifier: SignUpIdentifier,
password = true,
verify = true
) => {
await updateSignInExperience({ signUp: { identifier, password, verify } });
}; };
type PasscodeRecord = { type PasscodeRecord = {

View file

@ -47,7 +47,7 @@ describe('username and password flow', () => {
describe('email passwordless flow', () => { describe('email passwordless flow', () => {
beforeAll(async () => { beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig); 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. // 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', () => { describe('sms passwordless flow', () => {
beforeAll(async () => { beforeAll(async () => {
await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig); 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. // 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( const signInEmailSendPasscodeLogPayloadGuard = arbitraryLogPayloadGuard.and(
z.object({ z.object({
email: z.string().optional(), email: z.string().optional(),
@ -197,6 +211,8 @@ const logPayloadsGuard = z.object({
RegisterSocialBind: registerSocialBindLogPayloadGuard, RegisterSocialBind: registerSocialBindLogPayloadGuard,
RegisterSocial: registerSocialLogPayloadGuard, RegisterSocial: registerSocialLogPayloadGuard,
SignInUsernamePassword: signInUsernamePasswordLogPayloadGuard, SignInUsernamePassword: signInUsernamePasswordLogPayloadGuard,
SignInEmailPassword: signInEmailPasswordLogPayloadGuard,
SignInSmsPassword: signInSmsPasswordLogPayloadGuard,
SignInEmailSendPasscode: signInEmailSendPasscodeLogPayloadGuard, SignInEmailSendPasscode: signInEmailSendPasscodeLogPayloadGuard,
SignInEmail: signInEmailLogPayloadGuard, SignInEmail: signInEmailLogPayloadGuard,
SignInSmsSendPasscode: signInSmsSendPasscodeLogPayloadGuard, SignInSmsSendPasscode: signInSmsSendPasscodeLogPayloadGuard,