mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
import path from 'path';
|
|
|
|
import { LogtoErrorCode } from '@logto/phrases';
|
|
import { PasscodeType, UserLogType, userInfoSelectFields } from '@logto/schemas';
|
|
import { conditional } from '@silverhand/essentials';
|
|
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 } from '@/lib/session';
|
|
import {
|
|
findSocialRelatedUser,
|
|
getUserInfoByAuthCode,
|
|
getUserInfoFromInteractionResult,
|
|
} from '@/lib/social';
|
|
import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } from '@/lib/user';
|
|
import koaGuard from '@/middleware/koa-guard';
|
|
import {
|
|
hasUserWithEmail,
|
|
hasUserWithPhone,
|
|
hasUser,
|
|
hasUserWithIdentity,
|
|
insertUser,
|
|
findUserById,
|
|
updateUserById,
|
|
findUserByEmail,
|
|
findUserByPhone,
|
|
findUserByIdentity,
|
|
} from '@/queries/user';
|
|
import assertThat from '@/utils/assert-that';
|
|
import { emailRegEx, phoneRegEx, usernameRegEx } from '@/utils/regex';
|
|
|
|
import { AnonymousRouter } from './types';
|
|
|
|
export default function sessionRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
|
|
router.post('/session', async (ctx, next) => {
|
|
const {
|
|
prompt: { name },
|
|
} = await provider.interactionDetails(ctx.req, ctx.res);
|
|
|
|
if (name === 'consent') {
|
|
ctx.body = { redirectTo: path.join(ctx.request.origin, '/session/consent') };
|
|
|
|
return next();
|
|
}
|
|
|
|
throw new RequestError('session.unsupported_prompt_name');
|
|
});
|
|
|
|
router.post(
|
|
'/session/sign-in/username-password',
|
|
koaGuard({ body: object({ username: string().regex(usernameRegEx), password: string() }) }),
|
|
async (ctx, next) => {
|
|
const { username, password } = ctx.guard.body;
|
|
ctx.userLog.username = username;
|
|
ctx.userLog.type = UserLogType.SignInUsernameAndPassword;
|
|
|
|
assertThat(password, 'session.insufficient_info');
|
|
|
|
const { id } = await findUserByUsernameAndPassword(username, password);
|
|
ctx.userLog.userId = id;
|
|
|
|
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/passwordless/phone/send-passcode',
|
|
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { phone } = ctx.guard.body;
|
|
ctx.userLog.phone = phone;
|
|
ctx.userLog.type = UserLogType.SignInPhone;
|
|
|
|
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;
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/passwordless/phone/verify-passcode',
|
|
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { phone, code } = ctx.guard.body;
|
|
ctx.userLog.phone = phone;
|
|
ctx.userLog.type = UserLogType.SignInPhone;
|
|
|
|
assertThat(
|
|
await hasUserWithPhone(phone),
|
|
new RequestError({ code: 'user.phone_not_exists', status: 422 })
|
|
);
|
|
|
|
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();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/passwordless/email/send-passcode',
|
|
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { email } = ctx.guard.body;
|
|
ctx.userLog.email = email;
|
|
ctx.userLog.type = UserLogType.SignInEmail;
|
|
|
|
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;
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/passwordless/email/verify-passcode',
|
|
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { email, code } = ctx.guard.body;
|
|
ctx.userLog.email = email;
|
|
ctx.userLog.type = UserLogType.SignInEmail;
|
|
|
|
assertThat(
|
|
await hasUserWithEmail(email),
|
|
new RequestError({ code: 'user.email_not_exists', status: 422 })
|
|
);
|
|
|
|
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();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/social',
|
|
koaGuard({
|
|
body: object({
|
|
connectorId: string(),
|
|
code: string().optional(),
|
|
state: string(),
|
|
redirectUri: string(),
|
|
}),
|
|
}),
|
|
async (ctx, next) => {
|
|
const { connectorId, code, state, redirectUri } = ctx.guard.body;
|
|
ctx.userLog.connectorId = connectorId;
|
|
ctx.userLog.type = UserLogType.SignInSocial;
|
|
|
|
if (!code) {
|
|
assertThat(state && redirectUri, 'session.insufficient_info');
|
|
const connector = await getSocialConnectorInstanceById(connectorId);
|
|
assertThat(connector.connector.enabled, 'connector.not_enabled');
|
|
const redirectTo = await connector.getAuthorizationUri(redirectUri, state);
|
|
ctx.body = { redirectTo };
|
|
|
|
return next();
|
|
}
|
|
|
|
const userInfo = await getUserInfoByAuthCode(connectorId, code);
|
|
|
|
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();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/sign-in/bind-social-related-user-and-sign-in',
|
|
koaGuard({
|
|
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');
|
|
|
|
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();
|
|
}
|
|
);
|
|
|
|
router.post('/session/consent', async (ctx, next) => {
|
|
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { session, grantId, params, prompt } = interaction;
|
|
assertThat(session, 'session.not_found');
|
|
|
|
const { accountId } = session;
|
|
const grant =
|
|
conditional(grantId && (await provider.Grant.find(grantId))) ??
|
|
new provider.Grant({ accountId, clientId: String(params.client_id) });
|
|
|
|
// V2: fulfill missing claims / resources
|
|
const PromptDetailsBody = object({
|
|
missingOIDCScope: string().array().optional(),
|
|
missingResourceScopes: object({}).catchall(string().array()).optional(),
|
|
});
|
|
const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details);
|
|
|
|
if (missingOIDCScope) {
|
|
grant.addOIDCScope(missingOIDCScope.join(' '));
|
|
}
|
|
|
|
if (missingResourceScopes) {
|
|
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
|
|
grant.addResourceScope(indicator, scope.join(' '));
|
|
}
|
|
}
|
|
|
|
const finalGrantId = await grant.save();
|
|
|
|
// V2: configure consent
|
|
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
|
|
|
|
return next();
|
|
});
|
|
|
|
router.post(
|
|
'/session/register/username-password',
|
|
koaGuard({ body: object({ username: string().regex(usernameRegEx), password: string() }) }),
|
|
async (ctx, next) => {
|
|
const { username, password } = ctx.guard.body;
|
|
ctx.userLog.username = username;
|
|
ctx.userLog.type = UserLogType.RegisterUsernameAndPassword;
|
|
|
|
assertThat(
|
|
password,
|
|
new RequestError({
|
|
code: 'session.insufficient_info',
|
|
status: 400,
|
|
})
|
|
);
|
|
assertThat(
|
|
!(await hasUser(username)),
|
|
new RequestError({
|
|
code: 'user.username_exists_register',
|
|
status: 422,
|
|
})
|
|
);
|
|
|
|
const id = await generateUserId();
|
|
ctx.userLog.userId = id;
|
|
|
|
const { passwordEncryptionSalt, passwordEncrypted, passwordEncryptionMethod } =
|
|
encryptUserPassword(id, password);
|
|
|
|
await insertUser({
|
|
id,
|
|
username,
|
|
passwordEncrypted,
|
|
passwordEncryptionMethod,
|
|
passwordEncryptionSalt,
|
|
});
|
|
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/session/register/:username/existence',
|
|
koaGuard({ params: object({ username: string().regex(usernameRegEx) }) }),
|
|
async (ctx, next) => {
|
|
const { username } = ctx.guard.params;
|
|
|
|
ctx.body = { existence: await hasUser(username) };
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/register/passwordless/phone/send-passcode',
|
|
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
|
|
async (ctx, next) => {
|
|
ctx.userLog.type = UserLogType.RegisterPhone;
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { phone } = ctx.guard.body;
|
|
ctx.userLog.phone = phone;
|
|
|
|
assertThat(
|
|
!(await hasUserWithPhone(phone)),
|
|
new RequestError({ code: 'user.phone_exists_register', status: 422 })
|
|
);
|
|
|
|
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
|
|
await sendPasscode(passcode);
|
|
ctx.state = 204;
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/register/passwordless/phone/verify-passcode',
|
|
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { phone, code } = ctx.guard.body;
|
|
ctx.userLog.phone = phone;
|
|
ctx.userLog.type = UserLogType.RegisterPhone;
|
|
|
|
assertThat(
|
|
!(await hasUserWithPhone(phone)),
|
|
new RequestError({ code: 'user.phone_exists_register', status: 422 })
|
|
);
|
|
|
|
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
|
|
const id = await generateUserId();
|
|
ctx.userLog.userId = id;
|
|
|
|
await insertUser({ id, primaryPhone: phone });
|
|
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/register/passwordless/email/send-passcode',
|
|
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { email } = ctx.guard.body;
|
|
ctx.userLog.email = email;
|
|
ctx.userLog.type = UserLogType.RegisterEmail;
|
|
|
|
assertThat(
|
|
!(await hasUserWithEmail(email)),
|
|
new RequestError({ code: 'user.email_exists_register', status: 422 })
|
|
);
|
|
|
|
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
|
|
await sendPasscode(passcode);
|
|
ctx.state = 204;
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/register/passwordless/email/verify-passcode',
|
|
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
|
|
async (ctx, next) => {
|
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
const { email, code } = ctx.guard.body;
|
|
ctx.userLog.email = email;
|
|
ctx.userLog.type = UserLogType.RegisterEmail;
|
|
|
|
assertThat(
|
|
!(await hasUserWithEmail(email)),
|
|
new RequestError({ code: 'user.email_exists_register', status: 422 })
|
|
);
|
|
|
|
await verifyPasscode(jti, PasscodeType.Register, code, { email });
|
|
const id = await generateUserId();
|
|
ctx.userLog.userId = id;
|
|
|
|
await insertUser({ id, primaryEmail: email });
|
|
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/register/social',
|
|
koaGuard({
|
|
body: object({
|
|
connectorId: string(),
|
|
}),
|
|
}),
|
|
async (ctx, next) => {
|
|
const { connectorId } = ctx.guard.body;
|
|
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
|
|
// User can not regsiter with social directly,
|
|
// need to try to sign in with social first, then confirm to register and continue,
|
|
// so the result is expected to be exists.
|
|
assertThat(result, 'session.connector_session_not_found');
|
|
|
|
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
|
assertThat(!(await hasUserWithIdentity(connectorId, userInfo.id)), 'user.identity_exists');
|
|
|
|
const id = await generateUserId();
|
|
await insertUser({
|
|
id,
|
|
name: userInfo.name ?? null,
|
|
avatar: userInfo.avatar ?? null,
|
|
identities: {
|
|
[connectorId]: {
|
|
userId: userInfo.id,
|
|
details: userInfo,
|
|
},
|
|
},
|
|
});
|
|
|
|
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/session/bind-social',
|
|
koaGuard({
|
|
body: object({
|
|
connectorId: string(),
|
|
}),
|
|
}),
|
|
async (ctx, next) => {
|
|
const { connectorId } = ctx.guard.body;
|
|
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
assertThat(result, 'session.connector_session_not_found');
|
|
assertThat(result.login?.accountId, 'session.unauthorized');
|
|
|
|
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
|
const user = await findUserById(result.login.accountId);
|
|
|
|
const updatedUser = await updateUserById(user.id, {
|
|
identities: {
|
|
...user.identities,
|
|
[connectorId]: { userId: userInfo.id, details: userInfo },
|
|
},
|
|
});
|
|
|
|
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
|
|
|
return next();
|
|
}
|
|
);
|
|
|
|
router.delete('/session', async (ctx, next) => {
|
|
await provider.interactionDetails(ctx.req, ctx.res);
|
|
const error: LogtoErrorCode = 'oidc.aborted';
|
|
await assignInteractionResults(ctx, provider, { error });
|
|
|
|
return next();
|
|
});
|
|
}
|