0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core,console): organization mfa requirement

This commit is contained in:
Gao Sun 2024-06-08 14:15:07 +08:00
parent efa884c409
commit 75ab459c0a
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 173 additions and 37 deletions

View file

@ -3,8 +3,20 @@
.jitContent {
margin-top: _.unit(3);
.description {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-top: _.unit(1.5);
margin-left: _.unit(6.5);
}
.emailDomains {
margin-top: _.unit(2);
margin-top: _.unit(1);
margin-left: _.unit(6);
}
}
.warning {
margin-top: _.unit(3);
}

View file

@ -1,19 +1,23 @@
import { type Organization } from '@logto/schemas';
import { type SignInExperience, type Organization } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import useSWR from 'swr';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import MultiOptionInput from '@/components/MultiOptionInput';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { isDevFeaturesEnabled } from '@/consts/env';
import CodeEditor from '@/ds-components/CodeEditor';
import FormField from '@/ds-components/FormField';
import InlineNotification from '@/ds-components/InlineNotification';
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
import useApi from '@/hooks/use-api';
import useApi, { type RequestError } from '@/hooks/use-api';
import { domainRegExp } from '@/pages/EnterpriseSsoDetails/Experience/DomainsInput/consts';
import { trySubmitSafe } from '@/utils/form';
@ -52,6 +56,7 @@ const assembleData = ({
function Settings() {
const { isDeleting, data, emailDomains, onUpdated } =
useOutletContext<OrganizationDetailsOutletContext>();
const { data: signInExperience } = useSWR<SignInExperience, RequestError>('api/sign-in-exp');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
register,
@ -61,6 +66,7 @@ function Settings() {
formState: { isDirty, isSubmitting, errors },
setError,
clearErrors,
getValues,
} = useForm<FormData>({
defaultValues: normalizeData(
data,
@ -134,8 +140,8 @@ function Settings() {
</FormField>
</FormCard>
<FormCard
title="organization_details.jit.title"
description="organization_details.jit.description"
title="organization_details.membership_policies"
description="organization_details.membership_policies_description"
>
<FormField title="organization_details.jit.is_enabled_title">
<Controller
@ -160,42 +166,60 @@ function Settings() {
/>
</RadioGroup>
{field.value && (
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
className={styles.emailDomains}
values={value}
renderValue={(value) => value}
validateInput={(input) => {
if (!domainRegExp.test(input)) {
return t('organization_details.jit.invalid_domain');
}
<>
<p className={styles.description}>
{t('organization_details.jit.description')}
</p>
<Controller
name="jitEmailDomains"
control={control}
render={({ field: { onChange, value } }) => (
<MultiOptionInput
className={styles.emailDomains}
values={value}
renderValue={(value) => value}
validateInput={(input) => {
if (!domainRegExp.test(input)) {
return t('organization_details.jit.invalid_domain');
}
if (value.includes(input)) {
return t('organization_details.jit.domain_already_added');
}
if (value.includes(input)) {
return t('organization_details.jit.domain_already_added');
}
return { value: input };
}}
placeholder={t('organization_details.jit.email_domains_placeholder')}
error={errors.jitEmailDomains?.message}
onChange={onChange}
onError={(error) => {
setError('jitEmailDomains', { type: 'custom', message: error });
}}
onClearError={() => {
clearErrors('jitEmailDomains');
}}
/>
)}
/>
return { value: input };
}}
placeholder={t('organization_details.jit.email_domains_placeholder')}
error={errors.jitEmailDomains?.message}
onChange={onChange}
onError={(error) => {
setError('jitEmailDomains', { type: 'custom', message: error });
}}
onClearError={() => {
clearErrors('jitEmailDomains');
}}
/>
)}
/>
</>
)}
</div>
)}
/>
</FormField>
{isDevFeaturesEnabled && (
<FormField title="organization_details.mfa.title" tip={t('organization_details.mfa.tip')}>
<Switch
label={t('organization_details.mfa.description')}
{...register('isMfaRequired')}
/>
{getValues('isMfaRequired') && signInExperience?.mfa.factors.length === 0 && (
<InlineNotification severity="alert" className={styles.warning}>
{t('organization_details.mfa.no_mfa_warning')}
</InlineNotification>
)}
</FormField>
)}
</FormCard>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</DetailsForm>

View file

@ -130,6 +130,13 @@ const stubAccount = (ctx: KoaContextWithOIDC, overrideAccountId = accountId) =>
});
};
const createAccessDeniedError = (message: string, statusCode: number) => {
const error = new errors.AccessDenied(message);
// eslint-disable-next-line @silverhand/fp/no-mutation
error.statusCode = statusCode;
return error;
};
const createPreparedContext = () => {
const ctx = createOidcContext(validOidcContext);
stubRefreshToken(ctx);
@ -266,7 +273,9 @@ describe('organization token grant', () => {
const ctx = createPreparedContext();
const tenant = new MockTenant();
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(false);
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.AccessDenied);
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(
createAccessDeniedError('user is not a member of the organization', 403)
);
});
it('should throw if the user has not granted the requested organization', async () => {
@ -278,7 +287,24 @@ describe('organization token grant', () => {
isThirdParty: true,
});
Sinon.stub(tenant.queries.applications.userConsentOrganizations, 'exists').resolves(false);
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.AccessDenied);
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(
createAccessDeniedError('organization access is not granted to the application', 403)
);
});
it('should throw if the organization requires MFA but the user has not configured it', async () => {
const ctx = createPreparedContext();
const tenant = new MockTenant();
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
Sinon.stub(tenant.queries.applications.userConsentOrganizations, 'exists').resolves(true);
Sinon.stub(tenant.queries.organizations, 'getMfaData').resolves({
isMfaRequired: true,
hasMfaConfigured: false,
});
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(
createAccessDeniedError('organization requires MFA but user has no MFA configured', 403)
);
});
// The handler returns void so we cannot check the return value, and it's also not
@ -296,6 +322,10 @@ describe('organization token grant', () => {
{ tenantId: 'default', id: 'bar', name: 'bar', description: 'bar' },
{ tenantId: 'default', id: 'baz', name: 'baz', description: 'baz' },
]);
Sinon.stub(tenant.queries.organizations, 'getMfaData').resolves({
isMfaRequired: false,
hasMfaConfigured: false,
});
const entityStub = Sinon.stub(ctx.oidc, 'entity');
const noopStub = Sinon.stub().resolves();

View file

@ -246,6 +246,17 @@ export const buildHandler: (
error.statusCode = 403;
throw error;
}
// Check if the organization requires MFA and the user has MFA enabled
const { isMfaRequired, hasMfaConfigured } = await queries.organizations.getMfaData(
organizationId,
account.accountId
);
if (isMfaRequired && !hasMfaConfigured) {
const error = new AccessDenied('organization requires MFA but user has no MFA configured');
error.statusCode = 403;
throw error;
}
}
/* === End RFC 0001 === */

View file

@ -20,6 +20,7 @@ import {
OrganizationRoleResourceScopeRelations,
Scopes,
Resources,
Users,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
@ -295,4 +296,32 @@ export default class OrganizationQueries extends SchemaQueries<
constructor(pool: CommonQueryMethods) {
super(pool, Organizations);
}
/**
* Get the multi-factor authentication (MFA) data for the given organization and user.
*
* @returns Whether MFA is required for the organization and whether the user has configured MFA.
* @see {@link MfaData}
*/
async getMfaData(organizationId: string, userId: string) {
const { table, fields } = convertToIdentifiers(Organizations);
const users = convertToIdentifiers(Users);
type MfaData = {
/** Whether MFA is required for the organization. */
isMfaRequired: boolean;
/** Whether the user has configured MFA. */
hasMfaConfigured: boolean;
};
return this.pool.one<MfaData>(sql`
select
(select ${fields.isMfaRequired} from ${table} where ${fields.id} = ${organizationId}),
exists (
select 1 from ${users.table}
where ${users.fields.id} = ${userId}
and jsonb_array_length(${users.fields.mfaVerifications}) > 0
) as "hasMfaConfigured";
`);
}
}

View file

@ -26,8 +26,10 @@ const organization_details = {
custom_data_tip:
'Custom data is a JSON object that can be used to store additional data associated with the organization.',
invalid_json_object: 'Invalid JSON object.',
membership_policies: 'Membership policies',
membership_policies_description:
'Define how users can join this organization and what requirements they must meet for access.',
jit: {
title: 'Just-in-time (JIT) provisioning',
description:
'Automatically assign users into this organization when they sign up or are added through the Management API, provided their email addresses match the specified domains.',
is_enabled_title: 'Enable just-in-time provisioning',
@ -39,6 +41,14 @@ const organization_details = {
invalid_domain: 'Invalid domain',
domain_already_added: 'Domain already added',
},
mfa: {
title: 'Multi-factor authentication (MFA)',
tip: 'When MFA is required, users with no MFA configured will be rejected when trying to exchange an organization token. This setting does not affect user authentication.',
description:
'Require users to configure multi-factor authentication to access this organization.',
no_mfa_warning:
'No multi-factor authentication methods are enabled for your tenant. Users will not be able to access this organization until at least one multi-factor authentication method is enabled.',
},
};
export default Object.freeze(organization_details);

View file

@ -0,0 +1,18 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table organizations add column is_mfa_required boolean not null default false;
`);
},
down: async (pool) => {
await pool.query(sql`
alter table organizations drop column is_mfa_required;
`);
},
};
export default alteration;

View file

@ -12,6 +12,8 @@ create table organizations (
description varchar(256),
/** Additional data associated with the organization. */
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
/** Whether multi-factor authentication configuration is required for the members of the organization. */
is_mfa_required boolean not null default false,
/** When the organization was created. */
created_at timestamptz not null default(now()),
primary key (id)