mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
Merge pull request #6003 from logto-io/gao-org-mfa
feat(core,console): organization mfa requirement
This commit is contained in:
commit
b286dd314a
13 changed files with 301 additions and 48 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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, 'getMfaStatus').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, 'getMfaStatus').resolves({
|
||||
isMfaRequired: false,
|
||||
hasMfaConfigured: false,
|
||||
});
|
||||
|
||||
const entityStub = Sinon.stub(ctx.oidc, 'entity');
|
||||
const noopStub = Sinon.stub().resolves();
|
||||
|
|
|
@ -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.getMfaStatus(
|
||||
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 === */
|
||||
|
||||
|
|
|
@ -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) status 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 getMfaStatus(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";
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
type OrganizationWithFeatured,
|
||||
type OrganizationScope,
|
||||
type OrganizationEmailDomain,
|
||||
type CreateOrganization,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
@ -17,10 +18,7 @@ type Query = {
|
|||
page_size?: number;
|
||||
};
|
||||
|
||||
export class OrganizationApi extends ApiFactory<
|
||||
Organization,
|
||||
{ name: string; description?: string }
|
||||
> {
|
||||
export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganization, 'id'>> {
|
||||
constructor() {
|
||||
super('organizations');
|
||||
}
|
||||
|
|
|
@ -152,6 +152,14 @@ export default class MockClient {
|
|||
return this.logto.getAccessTokenClaims(resource);
|
||||
}
|
||||
|
||||
public async getOrganizationTokenClaims(organizationId: string) {
|
||||
return this.logto.getOrganizationTokenClaims(organizationId);
|
||||
}
|
||||
|
||||
public async clearAccessToken() {
|
||||
return this.logto.clearAccessToken();
|
||||
}
|
||||
|
||||
public async getRefreshToken(): Promise<Nullable<string>> {
|
||||
return this.logto.getRefreshToken();
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
type Organization,
|
||||
type OrganizationRoleWithScopes,
|
||||
type OrganizationInvitationEntity,
|
||||
type JsonObject,
|
||||
type CreateOrganization,
|
||||
} from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
|
||||
|
@ -123,11 +123,7 @@ export class OrganizationApiTest extends OrganizationApi {
|
|||
return this.#organizations;
|
||||
}
|
||||
|
||||
override async create(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
customData?: JsonObject;
|
||||
}): Promise<Organization> {
|
||||
override async create(data: Omit<CreateOrganization, 'id'>): Promise<Organization> {
|
||||
const created = await super.create(data);
|
||||
this.organizations.push(created);
|
||||
return created;
|
||||
|
|
|
@ -152,7 +152,7 @@ describe('get access token', () => {
|
|||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('can sign in and get multiple Access Tokens by the same Refresh Token within refreshTokenReuseInterval', async () => {
|
||||
it('can sign in and get multiple access tokens by the same refresh token within `refreshTokenReuseInterval`', async () => {
|
||||
const client = new MockClient({ resources: [testApiResourceInfo.indicator] });
|
||||
|
||||
await client.initSession();
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
||||
import { InteractionEvent, MfaFactor } from '@logto/schemas';
|
||||
|
||||
import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js';
|
||||
import { putInteraction } from '#src/api/index.js';
|
||||
import MockClient from '#src/client/index.js';
|
||||
import { processSession } from '#src/helpers/client.js';
|
||||
import { createUserByAdmin } from '#src/helpers/index.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generatePassword, generateUsername, randomString } from '#src/utils.js';
|
||||
|
||||
describe('get access token for organization', () => {
|
||||
const username = generateUsername();
|
||||
const password = generatePassword();
|
||||
const scopeName = `read:${randomString()}`;
|
||||
const scopeName2 = `read:other:${randomString()}`;
|
||||
const client = new MockClient({
|
||||
scopes: [scopeName, scopeName2, UserScope.Organizations],
|
||||
});
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let testApiScopeId: string;
|
||||
let testApiScopeId2: string;
|
||||
let testUserId: string;
|
||||
let testOrganizationId: string;
|
||||
let testOrganizationId2: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
beforeAll(async () => {
|
||||
const user = await createUserByAdmin({ username, password });
|
||||
testUserId = user.id;
|
||||
|
||||
const organization = await organizationApi.create({ name: 'org1' });
|
||||
testOrganizationId = organization.id;
|
||||
await organizationApi.addUsers(testOrganizationId, [user.id]);
|
||||
|
||||
const scope = await organizationApi.scopeApi.create({ name: scopeName });
|
||||
testApiScopeId = scope.id;
|
||||
const scope2 = await organizationApi.scopeApi.create({ name: scopeName2 });
|
||||
testApiScopeId2 = scope2.id;
|
||||
|
||||
const role = await organizationApi.roleApi.create({ name: `role1:${randomString()}` });
|
||||
await organizationApi.roleApi.addScopes(role.id, [scope.id]);
|
||||
await organizationApi.addUserRoles(testOrganizationId, user.id, [role.id]);
|
||||
|
||||
const organization2 = await organizationApi.create({ name: 'org2' });
|
||||
testOrganizationId2 = organization2.id;
|
||||
await organizationApi.addUsers(testOrganizationId2, [user.id]);
|
||||
const role2 = await organizationApi.roleApi.create({ name: `role2:${randomString()}` });
|
||||
await organizationApi.roleApi.addScopes(role2.id, [scope2.id]);
|
||||
await organizationApi.addUserRoles(testOrganizationId2, user.id, [role2.id]);
|
||||
|
||||
await enableAllPasswordSignInMethods();
|
||||
|
||||
// Prepare client
|
||||
await client.initSession();
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: { username, password },
|
||||
});
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
});
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([organizationApi.cleanUp(), deleteUser(testUserId)]);
|
||||
});
|
||||
|
||||
it('should be able to get access token for organization with correct scopes', async () => {
|
||||
await expect(client.getOrganizationTokenClaims(testOrganizationId)).resolves.toMatchObject({
|
||||
aud: buildOrganizationUrn(testOrganizationId),
|
||||
scope: scopeName,
|
||||
});
|
||||
await expect(client.getOrganizationTokenClaims(testOrganizationId2)).resolves.toMatchObject({
|
||||
aud: buildOrganizationUrn(testOrganizationId2),
|
||||
scope: scopeName2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to dynamically get access token according to the status quo', async () => {
|
||||
const newOrganization = await organizationApi.create({ name: 'foo' });
|
||||
|
||||
await organizationApi.addUsers(newOrganization.id, [testUserId]);
|
||||
await expect(client.getOrganizationTokenClaims(newOrganization.id)).resolves.toMatchObject({
|
||||
aud: buildOrganizationUrn(newOrganization.id),
|
||||
});
|
||||
|
||||
await organizationApi.deleteUser(newOrganization.id, testUserId);
|
||||
await client.clearAccessToken();
|
||||
await expect(
|
||||
client.getOrganizationTokenClaims(newOrganization.id)
|
||||
).rejects.toMatchInlineSnapshot('[Error: Access denied.]');
|
||||
});
|
||||
|
||||
it('should throw when organization requires mfa but user has not configured', async () => {
|
||||
await organizationApi.update(testOrganizationId, { isMfaRequired: true });
|
||||
await client.clearAccessToken();
|
||||
|
||||
await expect(
|
||||
client.getOrganizationTokenClaims(testOrganizationId)
|
||||
).rejects.toMatchInlineSnapshot('[Error: Access denied.]');
|
||||
});
|
||||
|
||||
it('should be able to get access token for organization when user has mfa configured', async () => {
|
||||
await createUserMfaVerification(testUserId, MfaFactor.TOTP);
|
||||
await expect(client.getOrganizationTokenClaims(testOrganizationId)).resolves.toMatchObject({
|
||||
aud: buildOrganizationUrn(testOrganizationId),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue