0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

chore(core): refactor sign-in routes (#273)

* chore(core): refactor sign-in routes

* feat(core): fix order of userLog assignments
This commit is contained in:
Darcy Ye 2022-02-24 11:58:52 +08:00 committed by GitHub
parent 44e2be0972
commit 89a185c845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 137 additions and 271 deletions

View file

@ -0,0 +1,18 @@
import { Context } from 'koa';
import { InteractionResults, Provider } from 'oidc-provider';
// TODO: change this after frontend is ready.
// Should combine baseUrl(domain) from database with a 'callback' endpoint.
export const connectorRedirectUrl = 'https://logto.dev/callback';
export const assignInteractionResults = async (
ctx: Context,
provider: Provider,
result: InteractionResults,
merge = false
) => {
const redirectTo = await provider.interactionResult(ctx.req, ctx.res, result, {
mergeWithLastSubmission: merge,
});
ctx.body = { redirectTo };
};

View file

@ -1,193 +0,0 @@
import { PasscodeType, UserLogType } from '@logto/schemas';
import { Context } from 'koa';
import { InteractionResults, Provider } from 'oidc-provider';
import { getSocialConnectorInstanceById } from '@/connectors';
import { SocialUserInfo } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import { WithUserLogContext } from '@/middleware/koa-user-log';
import {
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
hasUserWithIdentity,
findUserByIdentity,
updateUserById,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { emailRegEx, phoneRegEx } from '@/utils/regex';
import { createPasscode, sendPasscode, verifyPasscode } from './passcode';
import {
findSocialRelatedUser,
getUserInfoFromInteractionResult,
SocialUserInfoSession,
} from './social';
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(emailRegEx.test(email), new RequestError('user.invalid_email'));
assertThat(
await hasUserWithEmail(email),
new RequestError({
code: 'user.email_not_exists',
status: 422,
})
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
await sendPasscode(passcode);
ctx.state = 204;
};
export const sendSignInWithPhonePasscode = async (ctx: Context, jti: string, phone: string) => {
assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone'));
assertThat(
await hasUserWithPhone(phone),
new RequestError({
code: 'user.phone_not_exists',
status: 422,
})
);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
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');
const { id } = await findUserByUsernameAndPassword(username, password);
await assignSignInResult(ctx, provider, id);
ctx.userLog.userId = id;
ctx.userLog.username = username;
ctx.userLog.type = UserLogType.SignInUsernameAndPassword;
};
export const signInWithEmailAndPasscode = async (
ctx: WithUserLogContext<Context>,
provider: Provider,
{ jti, email, code }: { jti: string; email: string; code: string }
) => {
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
await assignSignInResult(ctx, provider, id);
ctx.userLog.userId = id;
ctx.userLog.email = email;
ctx.userLog.type = UserLogType.SignInEmail;
};
export const signInWithPhoneAndPasscode = async (
ctx: WithUserLogContext<Context>,
provider: Provider,
{ jti, phone, code }: { jti: string; phone: string; code: string }
) => {
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
await assignSignInResult(ctx, provider, id);
ctx.userLog.userId = id;
ctx.userLog.phone = phone;
ctx.userLog.type = UserLogType.SignInPhone;
};
// TODO: change this after frontend is ready.
// Should combine baseUrl(domain) from database with a 'callback' endpoint.
const connectorRedirectUrl = 'https://logto.dev/callback';
export const assignRedirectUrlForSocial = async (
ctx: WithUserLogContext<Context>,
connectorId: string,
state: string
) => {
const connector = await getSocialConnectorInstanceById(connectorId);
assertThat(connector.connector.enabled, 'connector.not_enabled');
const redirectTo = await connector.getAuthorizationUri(connectorRedirectUrl, state);
ctx.body = { redirectTo };
};
const saveUserInfoToSession = async (
ctx: Context,
provider: Provider,
socialUserInfo: SocialUserInfoSession
) => {
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{
socialUserInfo,
},
{ mergeWithLastSubmission: true }
);
ctx.body = { redirectTo };
};
export const signInWithSocial = async (
ctx: WithUserLogContext<Context>,
provider: Provider,
connectorId: string,
userInfo: SocialUserInfo
) => {
ctx.userLog.connectorId = connectorId;
ctx.userLog.type = UserLogType.SignInSocial;
if (!(await hasUserWithIdentity(connectorId, userInfo.id))) {
await saveUserInfoToSession(ctx, provider, { connectorId, userInfo });
const relatedInfo = await findSocialRelatedUser(userInfo);
throw new RequestError(
{
code: 'user.identity_not_exists',
status: 422,
},
relatedInfo && { relatedUser: relatedInfo[0] }
);
}
const { id, identities } = await findUserByIdentity(connectorId, userInfo.id);
// Update social connector's user info
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
ctx.userLog.userId = id;
await assignSignInResult(ctx, provider, id);
};
export const signInWithSocialRelatedUser = async (
ctx: WithUserLogContext<Context>,
provider: Provider,
{ connectorId, result }: { connectorId: string; result: InteractionResults }
) => {
ctx.userLog.connectorId = connectorId;
ctx.userLog.type = UserLogType.SignInSocial;
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
const relatedInfo = await findSocialRelatedUser(userInfo);
assertThat(relatedInfo, 'session.connector_session_not_found');
const { id, identities } = relatedInfo[1];
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
ctx.userLog.userId = id;
await assignSignInResult(ctx, provider, id);
};

View file

@ -7,20 +7,16 @@ import pick from 'lodash.pick';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import { getSocialConnectorInstanceById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults, connectorRedirectUrl } from '@/lib/session';
import {
assignRedirectUrlForSocial,
sendSignInWithEmailPasscode,
sendSignInWithPhonePasscode,
signInWithSocial,
signInWithEmailAndPasscode,
signInWithPhoneAndPasscode,
signInWithUsernameAndPassword,
signInWithSocialRelatedUser,
} from '@/lib/sign-in';
import { getUserInfoByAuthCode, getUserInfoFromInteractionResult } from '@/lib/social';
import { encryptUserPassword, generateUserId } from '@/lib/user';
findSocialRelatedUser,
getUserInfoByAuthCode,
getUserInfoFromInteractionResult,
} from '@/lib/social';
import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import {
hasUser,
@ -30,6 +26,9 @@ import {
insertUser,
findUserById,
updateUserById,
findUserByEmail,
findUserByPhone,
findUserByIdentity,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { emailRegEx, phoneRegEx } from '@/utils/regex';
@ -55,8 +54,16 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/sign-in/username-password',
koaGuard({ body: object({ username: string(), password: string() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.SignInUsernameAndPassword;
const { username, password } = ctx.guard.body;
await signInWithUsernameAndPassword(ctx, provider, username, password);
assertThat(username && password, 'session.insufficient_info');
ctx.userLog.username = username;
const { id } = await findUserByUsernameAndPassword(username, password);
ctx.userLog.userId = id;
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -66,16 +73,33 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/sign-in/passwordless/phone',
koaGuard({ body: object({ phone: string(), code: string().optional() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.SignInPhone;
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone'));
assertThat(
await hasUserWithPhone(phone),
new RequestError({
code: 'user.phone_not_exists',
status: 422,
})
);
ctx.userLog.phone = phone;
if (!code) {
await sendSignInWithPhonePasscode(ctx, jti, phone);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
await sendPasscode(passcode);
ctx.state = 204;
return next();
}
await signInWithPhoneAndPasscode(ctx, provider, { jti, phone, code });
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.userLog.userId = id;
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -85,16 +109,33 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/sign-in/passwordless/email',
koaGuard({ body: object({ email: string(), code: string().optional() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.SignInEmail;
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
assertThat(emailRegEx.test(email), new RequestError('user.invalid_email'));
assertThat(
await hasUserWithEmail(email),
new RequestError({
code: 'user.email_not_exists',
status: 422,
})
);
ctx.userLog.email = email;
if (!code) {
await sendSignInWithEmailPasscode(ctx, jti, email);
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
await sendPasscode(passcode);
ctx.state = 204;
return next();
}
await signInWithEmailAndPasscode(ctx, provider, { jti, email, code });
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
const { id } = await findUserByEmail(email);
ctx.userLog.userId = id;
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -106,17 +147,43 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
body: object({ connectorId: string(), code: string().optional(), state: string() }),
}),
async (ctx, next) => {
ctx.userLog.type = UserLogType.SignInSocial;
const { connectorId, code, state } = ctx.guard.body;
ctx.userLog.connectorId = connectorId;
if (!code) {
assertThat(state, 'session.insufficient_info');
await assignRedirectUrlForSocial(ctx, connectorId, state);
const connector = await getSocialConnectorInstanceById(connectorId);
assertThat(connector.connector.enabled, 'connector.not_enabled');
const redirectTo = await connector.getAuthorizationUri(connectorRedirectUrl, state);
ctx.body = { redirectTo };
return next();
}
const userInfo = await getUserInfoByAuthCode(connectorId, code);
await signInWithSocial(ctx, provider, connectorId, userInfo);
if (!(await hasUserWithIdentity(connectorId, userInfo.id))) {
await assignInteractionResults(ctx, provider, { connectorId, userInfo }, true);
const relatedInfo = await findSocialRelatedUser(userInfo);
throw new RequestError(
{
code: 'user.identity_not_exists',
status: 422,
},
relatedInfo && { relatedUser: relatedInfo[0] }
);
}
const { id, identities } = await findUserByIdentity(connectorId, userInfo.id);
ctx.userLog.userId = id;
// Update social connector's user info
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -128,12 +195,26 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
body: object({ connectorId: string() }),
}),
async (ctx, next) => {
ctx.userLog.type = UserLogType.SignInSocial;
const { connectorId } = ctx.guard.body;
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
assertThat(result, 'session.connector_session_not_found');
await signInWithSocialRelatedUser(ctx, provider, { connectorId, result });
ctx.userLog.connectorId = connectorId;
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
const relatedInfo = await findSocialRelatedUser(userInfo);
assertThat(relatedInfo, 'session.connector_session_not_found');
const { id, identities } = relatedInfo[1];
ctx.userLog.userId = id;
await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
});
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -169,13 +250,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
const finalGrantId = await grant.save();
// V2: configure consent
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{ consent: { grantId: finalGrantId } },
{ mergeWithLastSubmission: true }
);
ctx.body = { redirectTo };
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
return next();
});
@ -184,7 +259,9 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/register/username-password',
koaGuard({ body: object({ username: string(), password: string() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.RegisterUsernameAndPassword;
const { username, password } = ctx.guard.body;
assertThat(
username && password,
new RequestError({
@ -199,8 +276,10 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
status: 422,
})
);
ctx.userLog.username = username;
const id = await generateUserId();
ctx.userLog.userId = id;
const { passwordEncryptionSalt, passwordEncrypted, passwordEncryptionMethod } =
encryptUserPassword(id, password);
@ -212,20 +291,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
passwordEncryptionMethod,
passwordEncryptionSalt,
});
ctx.userLog.userId = id;
ctx.userLog.username = username;
ctx.userLog.type = UserLogType.RegisterUsernameAndPassword;
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{ login: { accountId: id } },
{
mergeWithLastSubmission: false,
}
);
ctx.body = { redirectTo };
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -235,6 +301,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/register/passwordless/phone',
koaGuard({ body: object({ phone: string(), code: string().optional() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.RegisterPhone;
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
@ -243,6 +310,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
!(await hasUserWithPhone(phone)),
new RequestError({ code: 'user.phone_exists_register', status: 422 })
);
ctx.userLog.phone = phone;
if (!code) {
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
@ -254,20 +322,10 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
const id = await generateUserId();
ctx.userLog.userId = id;
await insertUser({ id, primaryPhone: phone });
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{ login: { accountId: id } },
{ mergeWithLastSubmission: false }
);
ctx.body = { redirectTo };
ctx.userLog = {
...ctx.userLog,
type: UserLogType.RegisterPhone,
userId: id,
phone,
};
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -277,6 +335,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
'/session/register/passwordless/email',
koaGuard({ body: object({ email: string(), code: string().optional() }) }),
async (ctx, next) => {
ctx.userLog.type = UserLogType.RegisterPhone;
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
@ -285,6 +344,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
!(await hasUserWithEmail(email)),
new RequestError({ code: 'user.email_exists_register', status: 422 })
);
ctx.userLog.email = email;
if (!code) {
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
@ -296,20 +356,10 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
await verifyPasscode(jti, PasscodeType.Register, code, { email });
const id = await generateUserId();
ctx.userLog.userId = id;
await insertUser({ id, primaryEmail: email });
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{ login: { accountId: id } },
{ mergeWithLastSubmission: false }
);
ctx.body = { redirectTo };
ctx.userLog = {
...ctx.userLog,
type: UserLogType.RegisterPhone,
userId: id,
email,
};
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -347,13 +397,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
},
});
const redirectTo = await provider.interactionResult(
ctx.req,
ctx.res,
{ login: { accountId: id } },
{ mergeWithLastSubmission: false }
);
ctx.body = { redirectTo };
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();
}
@ -391,10 +435,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
router.delete('/session', async (ctx, next) => {
await provider.interactionDetails(ctx.req, ctx.res);
const error: LogtoErrorCode = 'oidc.aborted';
const redirectTo = await provider.interactionResult(ctx.req, ctx.res, {
error,
});
ctx.body = { redirectTo };
await assignInteractionResults(ctx, provider, { error });
return next();
});