mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): sign in with email (#209)
* feat(core): sign in with email * fix: jti comment * refactor: move sign in methods into lib
This commit is contained in:
parent
3cb1cae486
commit
d14f1a8841
11 changed files with 139 additions and 51 deletions
61
packages/core/src/lib/sign-in.ts
Normal file
61
packages/core/src/lib/sign-in.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { PasscodeType, UserLogType } from '@logto/schemas';
|
||||
import { Context } from 'koa';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { WithUserLogContext } from '@/middleware/koa-user-log';
|
||||
import { findUserByEmail } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
import { emailReg } from '@/utils/regex';
|
||||
|
||||
import { createPasscode, sendPasscode, verifyPasscode } from './passcode';
|
||||
import { findUserByUsernameAndPassword } from './user';
|
||||
|
||||
const assignSignInResult = async (ctx: Context, provider: Provider, userId: string) => {
|
||||
const redirectTo = await provider.interactionResult(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
{
|
||||
login: { accountId: userId },
|
||||
},
|
||||
{ mergeWithLastSubmission: false }
|
||||
);
|
||||
ctx.body = { redirectTo };
|
||||
};
|
||||
|
||||
export const sendSignInWithEmailPasscode = async (ctx: Context, jti: string, email: string) => {
|
||||
assertThat(!email || emailReg.test(email), new RequestError('user.invalid_email'));
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
|
||||
await sendPasscode(passcode);
|
||||
ctx.state = 204;
|
||||
};
|
||||
|
||||
export const signInWithUsernameAndPassword = async (
|
||||
ctx: WithUserLogContext<Context>,
|
||||
provider: Provider,
|
||||
username: string,
|
||||
password: string
|
||||
) => {
|
||||
assertThat(username && password, 'session.insufficient_info');
|
||||
|
||||
ctx.userLog.username = username;
|
||||
ctx.userLog.type = UserLogType.SignInUsernameAndPassword;
|
||||
|
||||
const { id } = await findUserByUsernameAndPassword(username, password);
|
||||
await assignSignInResult(ctx, provider, id);
|
||||
};
|
||||
|
||||
export const signInWithEmailAndPasscode = async (
|
||||
ctx: WithUserLogContext<Context>,
|
||||
provider: Provider,
|
||||
{ jti, email, code }: { jti: string; email: string; code: string }
|
||||
) => {
|
||||
ctx.userLog.email = email;
|
||||
ctx.userLog.type = UserLogType.SignInEmail;
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
|
||||
const { id } = await findUserByEmail(email);
|
||||
|
||||
await assignSignInResult(ctx, provider, id);
|
||||
};
|
|
@ -1,8 +1,9 @@
|
|||
import { PasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { PasswordEncryptionMethod, User } from '@logto/schemas';
|
||||
import { nanoid } from 'nanoid';
|
||||
import pRetry from 'p-retry';
|
||||
|
||||
import { hasUserWithId } from '@/queries/user';
|
||||
import { findUserByUsername, hasUserWithId } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
import { buildIdGenerator } from '@/utils/id';
|
||||
import { encryptPassword } from '@/utils/password';
|
||||
|
||||
|
@ -41,3 +42,24 @@ export const encryptUserPassword = (
|
|||
|
||||
return { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt };
|
||||
};
|
||||
|
||||
export const findUserByUsernameAndPassword = async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<User> => {
|
||||
const user = await findUserByUsername(username);
|
||||
const { id, passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } = user;
|
||||
|
||||
assertThat(
|
||||
passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt,
|
||||
'session.invalid_sign_in_method'
|
||||
);
|
||||
|
||||
assertThat(
|
||||
encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) ===
|
||||
passwordEncrypted,
|
||||
'session.invalid_credentials'
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
|
@ -11,6 +11,9 @@ export type WithUserLogContext<ContextT> = ContextT & {
|
|||
export interface LogContext {
|
||||
type?: UserLogType;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
payload: UserLogPayload;
|
||||
createdAt: number;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ export const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: Pass
|
|||
`);
|
||||
|
||||
export const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: PasscodeType) =>
|
||||
pool.many<Passcode>(sql`
|
||||
pool.any<Passcode>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false
|
||||
|
|
|
@ -16,6 +16,13 @@ export const findUserByUsername = async (username: string) =>
|
|||
where ${fields.username}=${username}
|
||||
`);
|
||||
|
||||
export const findUserByEmail = async (email: string) =>
|
||||
pool.one<User>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
from ${table}
|
||||
where ${fields.primaryEmail}=${email}
|
||||
`);
|
||||
|
||||
export const findUserById = async (id: string) =>
|
||||
pool.one<User>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
|
|
|
@ -1,74 +1,65 @@
|
|||
import { LogtoErrorCode } from '@logto/phrases';
|
||||
import { UserLogType } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import {
|
||||
sendSignInWithEmailPasscode,
|
||||
signInWithEmailAndPasscode,
|
||||
signInWithUsernameAndPassword,
|
||||
} from '@/lib/sign-in';
|
||||
import { encryptUserPassword, generateUserId } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { findUserByUsername, hasUser, insertUser } from '@/queries/user';
|
||||
import { hasUser, insertUser } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
import { encryptPassword } from '@/utils/password';
|
||||
|
||||
import { AnonymousRouter } from './types';
|
||||
|
||||
export default function sessionRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
|
||||
router.post(
|
||||
'/session',
|
||||
koaGuard({ body: object({ username: string().optional(), password: string().optional() }) }),
|
||||
koaGuard({
|
||||
body: object({
|
||||
username: string().optional(),
|
||||
password: string().optional(),
|
||||
email: string().optional(),
|
||||
code: string().optional(),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const {
|
||||
// Interaction's JWT identity: jti
|
||||
// https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#user-flows
|
||||
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
|
||||
jti,
|
||||
prompt: { name },
|
||||
} = interaction;
|
||||
|
||||
if (name === 'login') {
|
||||
const { username, password } = ctx.guard.body;
|
||||
|
||||
assertThat(username && password, 'session.insufficient_info');
|
||||
|
||||
try {
|
||||
const { id, passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } =
|
||||
await findUserByUsername(username);
|
||||
|
||||
ctx.userLog.userId = id;
|
||||
ctx.userLog.type = UserLogType.SignInUsernameAndPassword;
|
||||
|
||||
assertThat(
|
||||
passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt,
|
||||
'session.invalid_sign_in_method'
|
||||
);
|
||||
|
||||
assertThat(
|
||||
encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) ===
|
||||
passwordEncrypted,
|
||||
'session.invalid_credentials'
|
||||
);
|
||||
|
||||
const redirectTo = await provider.interactionResult(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
{
|
||||
login: { accountId: id },
|
||||
},
|
||||
{ mergeWithLastSubmission: false }
|
||||
);
|
||||
ctx.body = { redirectTo };
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof RequestError)) {
|
||||
throw new RequestError('session.invalid_credentials');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else if (name === 'consent') {
|
||||
if (name === 'consent') {
|
||||
ctx.body = { redirectTo: ctx.request.origin + '/session/consent' };
|
||||
} else {
|
||||
throw new Error(`Prompt not supported: ${name}`);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
return next();
|
||||
if (name === 'login') {
|
||||
const { username, password, email, code } = ctx.guard.body;
|
||||
|
||||
if (email && !code) {
|
||||
await sendSignInWithEmailPasscode(ctx, jti, email);
|
||||
} else if (email && code) {
|
||||
await signInWithEmailAndPasscode(ctx, provider, { jti, email, code });
|
||||
} else if (username && password) {
|
||||
await signInWithUsernameAndPassword(ctx, provider, username, password);
|
||||
} else {
|
||||
throw new RequestError('session.insufficient_info');
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
throw new Error(`Prompt not supported: ${name}`);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
1
packages/core/src/utils/regex.ts
Normal file
1
packages/core/src/utils/regex.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const emailReg = /^\S+@\S+\.\S+$/;
|
|
@ -32,6 +32,7 @@ const errors = {
|
|||
},
|
||||
user: {
|
||||
username_exists: 'The username already exists.',
|
||||
invalid_email: 'Invalid email address.',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
|
||||
|
|
|
@ -34,6 +34,7 @@ const errors = {
|
|||
},
|
||||
user: {
|
||||
username_exists: '用户名已存在。',
|
||||
invalid_email: '邮箱地址不正确。',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}。',
|
||||
|
|
|
@ -12,6 +12,7 @@ export enum PasscodeType {
|
|||
}
|
||||
export enum UserLogType {
|
||||
SignInUsernameAndPassword = 'SignInUsernameAndPassword',
|
||||
SignInEmail = 'SignInEmail',
|
||||
ExchangeAccessToken = 'ExchangeAccessToken',
|
||||
}
|
||||
export enum UserLogResult {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
create type user_log_type as enum ('SignInUsernameAndPassword', 'ExchangeAccessToken');
|
||||
create type user_log_type as enum ('SignInUsernameAndPassword', 'SignInEmail', 'ExchangeAccessToken');
|
||||
|
||||
create type user_log_result as enum ('Success', 'Failed');
|
||||
|
||||
|
|
Loading…
Reference in a new issue