0
Fork 0
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:
Wang Sijie 2022-01-31 11:04:55 +08:00 committed by GitHub
parent 3cb1cae486
commit d14f1a8841
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 139 additions and 51 deletions

View 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);
};

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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

View file

@ -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`,`)}

View file

@ -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}`);
}
);

View file

@ -0,0 +1 @@
export const emailReg = /^\S+@\S+\.\S+$/;

View file

@ -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.',

View file

@ -34,6 +34,7 @@ const errors = {
},
user: {
username_exists: '用户名已存在。',
invalid_email: '邮箱地址不正确。',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}。',

View file

@ -12,6 +12,7 @@ export enum PasscodeType {
}
export enum UserLogType {
SignInUsernameAndPassword = 'SignInUsernameAndPassword',
SignInEmail = 'SignInEmail',
ExchangeAccessToken = 'ExchangeAccessToken',
}
export enum UserLogResult {

View file

@ -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');