0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

fix(core): get per app sie (#2433)

This commit is contained in:
wangsijie 2022-11-14 17:57:47 +08:00 committed by GitHub
parent 9118b59288
commit d634cb1b0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 167 additions and 124 deletions

View file

@ -1,6 +1,7 @@
import { getUnixTime } from 'date-fns';
import type { Context } from 'koa';
import type { InteractionResults, Provider } from 'oidc-provider';
import { errors } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { findUserById, updateUserById } from '@/queries/user';
@ -66,3 +67,27 @@ export const saveUserFirstConsentedAppId = async (userId: string, applicationId:
await updateUserById(userId, { applicationId });
}
};
export const getApplicationIdFromInteraction = async (
ctx: Context,
provider: Provider
): Promise<string | undefined> => {
const interaction = await provider
.interactionDetails(ctx.req, ctx.res)
.catch((error: unknown) => {
// Should not block if interaction is not found
if (error instanceof errors.SessionNotFound) {
return null;
}
throw error;
});
if (!interaction?.params) {
return;
}
return typeof interaction.params.client_id === 'string'
? interaction.params.client_id
: undefined;
};

View file

@ -1,6 +1,12 @@
import { builtInLanguages } from '@logto/phrases-ui';
import type { Branding, LanguageInfo, TermsOfUse } from '@logto/schemas';
import { ConnectorType, BrandingStyle } from '@logto/schemas';
import type { Branding, LanguageInfo, SignInExperience, TermsOfUse } from '@logto/schemas';
import { SignInMode, ConnectorType, BrandingStyle } from '@logto/schemas';
import {
adminConsoleApplicationId,
adminConsoleSignInExperience,
demoAppApplicationId,
} from '@logto/schemas/lib/seeds';
import i18next from 'i18next';
import { getLogtoConnectors } from '@/connectors';
import RequestError from '@/errors/RequestError';
@ -9,6 +15,7 @@ import {
findDefaultSignInExperience,
updateDefaultSignInExperience,
} from '@/queries/sign-in-experience';
import { hasActiveUsers } from '@/queries/user';
import assertThat from '@/utils/assert-that';
export * from './sign-up';
@ -56,3 +63,44 @@ export const removeUnavailableSocialConnectorTargets = async () => {
),
});
};
export const getSignInExperienceForApplication = async (
applicationId?: string
): Promise<SignInExperience & { notification?: string }> => {
const signInExperience = await findDefaultSignInExperience();
// Hard code AdminConsole sign-in methods settings.
if (applicationId === adminConsoleApplicationId) {
return {
...adminConsoleSignInExperience,
branding: {
...adminConsoleSignInExperience.branding,
slogan: i18next.t('admin_console.welcome.title'),
},
languageInfo: signInExperience.languageInfo,
signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register,
socialSignInConnectorTargets: [],
};
}
// Insert Demo App Notification
if (applicationId === demoAppApplicationId) {
const {
socialSignInConnectorTargets,
languageInfo: { autoDetect, fallbackLanguage },
} = signInExperience;
const notification = i18next.t(
'demo_app.notification',
autoDetect ? undefined : { lng: fallbackLanguage }
);
return {
...signInExperience,
socialSignInConnectorTargets,
notification,
};
}
return signInExperience;
};

View file

@ -7,6 +7,11 @@ import { validateSignUp } from './sign-up';
const enabledConnectors = [mockAliyunDmConnector, mockAliyunSmsConnector];
jest.mock('@/lib/session', () => ({
...jest.requireActual('@/lib/session'),
getApplicationIdFromInteraction: jest.fn(),
}));
describe('validate sign-up', () => {
describe('There must be at least one enabled connector for the specific identifier.', () => {
test('should throw when there is no enabled email connector and identifier is email', async () => {

View file

@ -3,10 +3,10 @@ import type { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import { encryptUserPassword } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import {
findUserById,
hasUser,
@ -54,7 +54,9 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
passwordEncrypted,
passwordEncryptionMethod,
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
@ -92,7 +94,9 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
const updatedUser = await updateUserById(userId, {
username,
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
@ -127,7 +131,9 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
const updatedUser = await updateUserById(userId, {
primaryEmail: email,
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
@ -161,7 +167,9 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
const updatedUser = await updateUserById(userId, {
primaryPhone: phone,
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });

View file

@ -5,7 +5,8 @@ import type { Provider } from 'oidc-provider';
import { errors } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import assertThat from '@/utils/assert-that';
export default function koaGuardSessionAction<StateT, ContextT, ResponseBodyT>(
@ -34,7 +35,9 @@ export default function koaGuardSessionAction<StateT, ContextT, ResponseBodyT>(
return next();
}
const { signInMode } = await findDefaultSignInExperience();
const { signInMode } = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
if (forType === 'sign-in') {
assertThat(signInMode !== SignInMode.Register, forbiddenError);

View file

@ -3,10 +3,10 @@ import type { MiddlewareType } from 'koa';
import type { Provider } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import { generateUserId, insertUser } from '@/lib/user';
import type { WithLogContext } from '@/middleware/koa-log';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import {
hasUserWithPhone,
hasUserWithEmail,
@ -28,7 +28,9 @@ export const smsSignInAction = <StateT, ContextT extends WithLogContext, Respons
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signIn.methods.some(
({ identifier, verificationCode }) =>
@ -71,7 +73,9 @@ export const emailSignInAction = <StateT, ContextT extends WithLogContext, Respo
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signIn.methods.some(
({ identifier, verificationCode }) =>
@ -114,7 +118,9 @@ export const smsRegisterAction = <StateT, ContextT extends WithLogContext, Respo
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signUp.identifier === SignUpIdentifier.Sms ||
signInExperience.signUp.identifier === SignUpIdentifier.EmailOrSms,
@ -156,7 +162,9 @@ export const emailRegisterAction = <StateT, ContextT extends WithLogContext, Res
provider: Provider
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
return async (ctx, next) => {
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signUp.identifier === SignUpIdentifier.Email ||
signInExperience.signUp.identifier === SignUpIdentifier.EmailOrSms,

View file

@ -51,6 +51,11 @@ jest.mock('@/lib/user', () => ({
insertUser: async (...args: unknown[]) => insertUser(...args),
}));
jest.mock('@/lib/session', () => ({
...jest.requireActual('@/lib/session'),
getApplicationIdFromInteraction: jest.fn(),
}));
const grantSave = jest.fn(async () => 'finalGrantId');
const grantAddOIDCScope = jest.fn();
const grantAddResourceScope = jest.fn();

View file

@ -5,10 +5,10 @@ import type { Provider } from 'oidc-provider';
import { object, string } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import { encryptUserPassword, generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import {
findUserByEmail,
findUserByPhone,
@ -104,7 +104,9 @@ export default function passwordRoutes<T extends AnonymousRouter>(router: T, pro
async (ctx, next) => {
const { username } = ctx.guard.body;
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signUp.identifier === SignUpIdentifier.Username,
new RequestError({
@ -140,7 +142,9 @@ export default function passwordRoutes<T extends AnonymousRouter>(router: T, pro
const type = 'RegisterUsernamePassword';
ctx.log(type, { username });
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signUp.identifier === SignUpIdentifier.Username,
new RequestError({

View file

@ -34,6 +34,11 @@ jest.mock('@/lib/user', () => ({
insertUser: async (...args: unknown[]) => insertUser(...args),
}));
jest.mock('@/lib/session', () => ({
...jest.requireActual('@/lib/session'),
getApplicationIdFromInteraction: jest.fn(),
}));
jest.mock('@/queries/user', () => ({
findUserById: async () => findUserById(),
findUserByPhone: async () => findUserByPhone(),

View file

@ -6,7 +6,8 @@ import { object, string, unknown } from 'zod';
import { getLogtoConnectorById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import {
findSocialRelatedUser,
getUserInfoByAuthCode,
@ -14,7 +15,6 @@ import {
} from '@/lib/social';
import { generateUserId, insertUser } from '@/lib/user';
import koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import {
hasUserWithIdentity,
findUserById,
@ -104,7 +104,9 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
lastSignInAt: Date.now(),
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, user, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
@ -143,7 +145,9 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
lastSignInAt: Date.now(),
});
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, user, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
@ -191,7 +195,9 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
});
ctx.log(type, { userId: id });
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
await checkRequiredProfile(ctx, provider, user, signInExperience);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });

View file

@ -15,10 +15,10 @@ import type { ZodType } from 'zod';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import { assignInteractionResults } from '@/lib/session';
import { assignInteractionResults, getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import { verifyUserPassword } from '@/lib/user';
import type { LogContext } from '@/middleware/koa-log';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { hasUser, hasUserWithEmail, hasUserWithPhone, updateUserById } from '@/queries/user';
import assertThat from '@/utils/assert-that';
@ -196,32 +196,6 @@ export const checkRequiredProfile = async (
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
}
};
export const checkRequiredSignUpIdentifiers = async (identifiers: {
username?: Nullable<string>;
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
}) => {
const { username, primaryEmail, primaryPhone } = identifiers;
const { signUp } = await findDefaultSignInExperience();
if (signUp.identifier === SignUpIdentifier.Username && !username) {
throw new RequestError({ code: 'user.require_username', status: 422 });
}
if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) {
throw new RequestError({ code: 'user.require_email', status: 422 });
}
if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) {
throw new RequestError({ code: 'user.require_sms', status: 422 });
}
if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) {
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
}
};
/* eslint-enable complexity */
export const checkExistingSignUpIdentifiers = async (
@ -260,7 +234,9 @@ export const signInWithPassword = async (
provider: Provider,
{ identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter
) => {
const signInExperience = await findDefaultSignInExperience();
const signInExperience = await getSignInExperienceForApplication(
await getApplicationIdFromInteraction(ctx, provider)
);
assertThat(
signInExperience.signIn.methods.some(
(method) => method.password && method.identifier === identifier

View file

@ -1,19 +1,12 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorType } from '@logto/connector-kit';
import { SignInMode } from '@logto/schemas';
import {
adminConsoleApplicationId,
adminConsoleSignInExperience,
demoAppApplicationId,
} from '@logto/schemas/lib/seeds';
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
import etag from 'etag';
import i18next from 'i18next';
import type { Provider } from 'oidc-provider';
import { errors } from 'oidc-provider';
import { getLogtoConnectors } from '@/connectors';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { hasActiveUsers } from '@/queries/user';
import { getApplicationIdFromInteraction } from '@/lib/session';
import { getSignInExperienceForApplication } from '@/lib/sign-in-experience';
import type { AnonymousRouter } from './types';
@ -21,19 +14,10 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
router.get(
'/.well-known/sign-in-exp',
async (ctx, next) => {
const interaction = await provider
.interactionDetails(ctx.req, ctx.res)
.catch((error: unknown) => {
// Should not block if interaction is not found
if (error instanceof errors.SessionNotFound) {
return null;
}
throw error;
});
const applicationId = await getApplicationIdFromInteraction(ctx, provider);
const [signInExperience, logtoConnectors] = await Promise.all([
findDefaultSignInExperience(),
getSignInExperienceForApplication(applicationId),
getLogtoConnectors(),
]);
@ -46,56 +30,22 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
),
};
// Hard code AdminConsole sign-in methods settings.
if (interaction?.params.client_id === adminConsoleApplicationId) {
ctx.body = {
...adminConsoleSignInExperience,
branding: {
...adminConsoleSignInExperience.branding,
slogan: i18next.t('admin_console.welcome.title'),
},
languageInfo: signInExperience.languageInfo,
signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register,
socialConnectors: [],
forgotPassword,
};
const socialConnectors =
applicationId === adminConsoleApplicationId
? []
: signInExperience.socialSignInConnectorTargets.reduce<
Array<ConnectorMetadata & { id: string }>
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target }, dbEntry: { enabled } }) =>
target === connectorTarget && enabled
);
return next();
}
// Custom Applications
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
Array<ConnectorMetadata & { id: string }>
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target }, dbEntry: { enabled } }) => target === connectorTarget && enabled
);
return [
...previous,
...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })),
];
}, []);
// Insert Demo App Notification
if (interaction?.params.client_id === demoAppApplicationId) {
const {
languageInfo: { autoDetect, fallbackLanguage },
} = signInExperience;
ctx.body = {
...signInExperience,
socialConnectors,
notification: i18next.t(
'demo_app.notification',
autoDetect ? undefined : { lng: fallbackLanguage }
),
forgotPassword,
};
return next();
}
return [
...previous,
...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })),
];
}, []);
ctx.body = {
...signInExperience,