Przeglądaj źródła

Merge pull request #10674 from growilabs/fix/handle-blank-configurations

fix: Handle blank configurations for SAML settings
mergify[bot] 3 miesięcy temu
rodzic
commit
a2cbc64b1d

+ 7 - 4
apps/app/playwright/utils/Login.ts

@@ -7,12 +7,15 @@ export const login = async (page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
 
-  const loginForm = await page.getByRole('form');
+  const loginForm = await page.getByTestId('login-form');
 
   if (loginForm != null) {
-    await page.getByLabel('Username or E-mail').fill('admin');
-    await page.getByLabel('Password').fill('adminadmin');
-    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+    await loginForm.getByPlaceholder('Username or E-mail').fill('admin');
+    await loginForm.getByPlaceholder('Password').fill('adminadmin');
+    await loginForm
+      .locator('[type=submit]')
+      .filter({ hasText: 'Login' })
+      .click();
   }
 
   await page.waitForURL('/admin');

+ 112 - 117
apps/app/src/client/components/CompleteUserRegistrationForm.tsx

@@ -93,132 +93,127 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   }
 
   return (
-    <>
-      <div
-        className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`}
-        id="nologin-dialog"
-      >
-        <div className="row mx-0">
-          <div className="col-12 px-4">
-            {errorCode != null &&
-              errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND && (
-                <p className="alert alert-danger">
-                  <span>Token not found</span>
-                </p>
-              )}
-
-            {errorCode != null &&
-              errorCode ===
-                UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE && (
-                <p className="alert alert-danger">
-                  <span>{t('message.incorrect_token_or_expired_url')}</span>
-                </p>
-              )}
-
-            {!isEmailAuthenticationEnabled && (
+    <div
+      className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`}
+    >
+      <div className="row mx-0">
+        <div className="col-12 px-4">
+          {errorCode != null &&
+            errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND && (
               <p className="alert alert-danger">
-                <span>{t('message.email_authentication_is_not_enabled')}</span>
+                <span>Token not found</span>
               </p>
             )}
 
-            <form onSubmit={handleSubmitRegistration} id="registration-form">
-              <input type="hidden" name="token" value={token} />
+          {errorCode != null &&
+            errorCode ===
+              UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE && (
+              <p className="alert alert-danger">
+                <span>{t('message.incorrect_token_or_expired_url')}</span>
+              </p>
+            )}
 
-              <div className="input-group">
-                <span className="p-2 text-white opacity-75">
-                  <span className="material-symbols-outlined">mail</span>
-                </span>
-                <input
-                  type="text"
-                  className="form-control rounded"
-                  placeholder={t('Email')}
-                  disabled
-                  value={email}
-                />
-              </div>
-
-              <div className="input-group" id="input-group-username">
-                <span className="p-2 text-white opacity-75">
-                  <span className="material-symbols-outlined">person</span>
-                </span>
-                <input
-                  type="text"
-                  className="form-control rounded"
-                  placeholder={t('User ID')}
-                  name="username"
-                  onChange={(e) => setUsername(e.target.value)}
-                  required
-                  disabled={forceDisableForm || disableForm}
-                />
-              </div>
-              {!usernameAvailable && (
-                <p className="form-text text-red">
-                  <span id="help-block-username">
-                    <span className="p-2 text-white opacity-75">
-                      <span className="material-symbols-outlined">block</span>
-                    </span>
-                    {t('installer.unavaliable_user_id')}
+          {!isEmailAuthenticationEnabled && (
+            <p className="alert alert-danger">
+              <span>{t('message.email_authentication_is_not_enabled')}</span>
+            </p>
+          )}
+
+          <form onSubmit={handleSubmitRegistration} id="registration-form">
+            <input type="hidden" name="token" value={token} />
+
+            <div className="input-group">
+              <span className="p-2 text-white opacity-75">
+                <span className="material-symbols-outlined">mail</span>
+              </span>
+              <input
+                type="text"
+                className="form-control rounded"
+                placeholder={t('Email')}
+                disabled
+                value={email}
+              />
+            </div>
+
+            <div className="input-group">
+              <span className="p-2 text-white opacity-75">
+                <span className="material-symbols-outlined">person</span>
+              </span>
+              <input
+                type="text"
+                className="form-control rounded"
+                placeholder={t('User ID')}
+                name="username"
+                onChange={(e) => setUsername(e.target.value)}
+                required
+                disabled={forceDisableForm || disableForm}
+              />
+            </div>
+            {!usernameAvailable && (
+              <p className="form-text text-red">
+                <span>
+                  <span className="p-2 text-white opacity-75">
+                    <span className="material-symbols-outlined">block</span>
                   </span>
-                </p>
-              )}
-
-              <div className="input-group">
-                <span className="p-2 text-white opacity-75">
-                  <span className="material-symbols-outlined">sell</span>
+                  {t('installer.unavaliable_user_id')}
                 </span>
-                <input
-                  type="text"
-                  className="form-control rounded"
-                  placeholder={t('Name')}
-                  name="name"
-                  value={name}
-                  onChange={(e) => setName(e.target.value)}
-                  required
-                  disabled={forceDisableForm || disableForm}
-                />
-              </div>
-
-              <div className="input-group">
-                <span className="p-2 text-white opacity-75">
-                  <span className="material-symbols-outlined">lock</span>
+              </p>
+            )}
+
+            <div className="input-group">
+              <span className="p-2 text-white opacity-75">
+                <span className="material-symbols-outlined">sell</span>
+              </span>
+              <input
+                type="text"
+                className="form-control rounded"
+                placeholder={t('Name')}
+                name="name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                required
+                disabled={forceDisableForm || disableForm}
+              />
+            </div>
+
+            <div className="input-group">
+              <span className="p-2 text-white opacity-75">
+                <span className="material-symbols-outlined">lock</span>
+              </span>
+              <input
+                type="password"
+                className="form-control rounded"
+                placeholder={t('Password')}
+                name="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                required
+                disabled={forceDisableForm || disableForm}
+              />
+            </div>
+
+            <div className="input-group justify-content-center mt-4">
+              <button
+                type="submit"
+                disabled={forceDisableForm || disableForm}
+                className="btn btn-secondary btn-register col-6 mx-auto d-flex"
+              >
+                <span>
+                  <span className="material-symbols-outlined">person_add</span>
                 </span>
-                <input
-                  type="password"
-                  className="form-control rounded"
-                  placeholder={t('Password')}
-                  name="password"
-                  value={password}
-                  onChange={(e) => setPassword(e.target.value)}
-                  required
-                  disabled={forceDisableForm || disableForm}
-                />
-              </div>
-
-              <div className="input-group justify-content-center mt-4">
-                <button
-                  type="submit"
-                  disabled={forceDisableForm || disableForm}
-                  className="btn btn-secondary btn-register col-6 mx-auto d-flex"
-                >
-                  <span>
-                    <span className="material-symbols-outlined">
-                      person_add
-                    </span>
-                  </span>
-                  <span className="flex-grow-1">{t('Create')}</span>
-                </button>
-              </div>
-
-              <div className="input-group mt-5 d-flex">
-                <a href="https://growi.org" className="link-growi-org">
-                  <span className="growi">GROWI</span>
-                  <span className="org">.org</span>
-                </a>
-              </div>
-            </form>
-          </div>
+                <span className="flex-grow-1">{t('Create')}</span>
+              </button>
+            </div>
+
+            <div className="input-group mt-5 d-flex">
+              <a href="https://growi.org" className="link-growi-org">
+                <span className="growi">GROWI</span>
+                <span className="org">.org</span>
+              </a>
+            </div>
+          </form>
         </div>
       </div>
-    </>
+    </div>
   );
 };

+ 1 - 1
apps/app/src/client/components/InstallerForm.tsx

@@ -126,7 +126,7 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
         )}
 
-        <form id="register-form" className="ps-1" onSubmit={submitHandler}>
+        <form className="ps-1" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
             <div className="input-group dropdown-with-icon">
               <span className="p-2 text-white opacity-75">

+ 3 - 3
apps/app/src/client/components/InvitedForm.tsx

@@ -90,9 +90,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   }
 
   return (
-    <div className="nologin-dialog px-3 pb-3 mx-auto" id="nologin-dialog">
+    <div className="nologin-dialog px-3 pb-3 mx-auto">
       {formNotification()}
-      <form onSubmit={handleSubmit(submitHandler)} id="invited-form">
+      <form onSubmit={handleSubmit(submitHandler)}>
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -109,7 +109,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
           />
         </div>
         {/* UserID Form */}
-        <div className="input-group" id="input-group-username">
+        <div className="input-group">
           <span className="input-group-text">
             <span className="material-symbols-outlined">person</span>
           </span>

+ 5 - 12
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -213,7 +213,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
         )}
 
-        <form onSubmit={handleLoginWithLocalSubmit} id="login-form">
+        <form onSubmit={handleLoginWithLocalSubmit} data-testid="login-form">
           <div className="input-group">
             <label
               className="text-white opacity-75 d-flex align-items-center"
@@ -415,13 +415,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </p>
         )}
 
-        <form
-          onSubmit={(e) => handleRegisterFormSubmit(e, registerAction)}
-          id="register-form"
-        >
+        <form onSubmit={(e) => handleRegisterFormSubmit(e, registerAction)}>
           {!isEmailAuthenticationEnabled && (
             <div>
-              <div className="input-group" id="input-group-username">
+              <div className="input-group">
                 <span className="text-white opacity-75 d-flex align-items-center">
                   <span className="material-symbols-outlined">person</span>
                 </span>
@@ -439,7 +436,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 />
               </div>
               <p className="form-text text-danger">
-                <span id="help-block-username"></span>
+                <span></span>
               </p>
               <div className="input-group">
                 <span className="text-white opacity-75 d-flex align-items-center">
@@ -582,11 +579,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   return (
     <div className={moduleClass}>
-      <div
-        className="nologin-dialog mx-auto rounded-4 rounded-top-0"
-        id="nologin-dialog"
-        data-testid="login-form"
-      >
+      <div className="nologin-dialog mx-auto rounded-4 rounded-top-0">
         <div className="row mx-0">
           <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip

+ 3 - 229
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -13,7 +13,6 @@ import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import ShareLink from '~/server/models/share-link';
 import { configManager } from '~/server/service/config-manager';
-import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 import {
   prepareDeleteConfigValuesForCalc,
@@ -21,6 +20,7 @@ import {
 } from '~/utils/page-delete-config';
 
 import { checkSetupStrategiesHasAdmin } from './checkSetupStrategiesHasAdmin';
+import { handleSamlUpdate, samlAuthValidator } from './saml';
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
@@ -114,41 +114,6 @@ const validator = {
       .if((value) => value != null)
       .isString(),
   ],
-  samlAuth: [
-    body('entryPoint')
-      .if((value) => value != null)
-      .isString(),
-    body('issuer')
-      .if((value) => value != null)
-      .isString(),
-    body('cert')
-      .if((value) => value != null)
-      .isString(),
-    body('attrMapId')
-      .if((value) => value != null)
-      .isString(),
-    body('attrMapUsername')
-      .if((value) => value != null)
-      .isString(),
-    body('attrMapMail')
-      .if((value) => value != null)
-      .isString(),
-    body('attrMapFirstName')
-      .if((value) => value != null)
-      .isString(),
-    body('attrMapLastName')
-      .if((value) => value != null)
-      .isString(),
-    body('isSameUsernameTreatedAsIdenticalUser')
-      .if((value) => value != null)
-      .isBoolean(),
-    body('isSameEmailTreatedAsIdenticalUser')
-      .if((value) => value != null)
-      .isBoolean(),
-    body('ABLCRule')
-      .if((value) => value != null)
-      .isString(),
-  ],
   oidcAuth: [
     body('oidcProviderName')
       .if((value) => value != null)
@@ -363,75 +328,6 @@ const validator = {
  *          ldapGroupDnProperty:
  *            type: string
  *            description: The property of user object to use in dn interpolation of Group Search Filter
- *      SamlAuthSetting:
- *        type: object
- *        properties:
- *          missingMandatoryConfigKeys:
- *            type: array
- *            description: array of missing mandatory config keys
- *            items:
- *              type: string
- *              description: missing mandatory config key
- *          useOnlyEnvVarsForSomeOptions:
- *            type: boolean
- *            description: use only env vars for some options
- *          samlEntryPoint:
- *            type: string
- *            description: entry point for saml
- *          samlIssuer:
- *            type: string
- *            description: issuer for saml
- *          samlEnvVarIssuer:
- *            type: string
- *            description: issuer for saml
- *          samlCert:
- *            type: string
- *            description: certificate for saml
- *          samlEnvVarCert:
- *            type: string
- *            description: certificate for saml
- *          samlAttrMapId:
- *            type: string
- *            description: attribute mapping id for saml
- *          samlAttrMapUserName:
- *            type: string
- *            description: attribute mapping user name for saml
- *          samlAttrMapMail:
- *            type: string
- *            description: attribute mapping mail for saml
- *          samlEnvVarAttrMapId:
- *            type: string
- *            description: attribute mapping id for saml
- *          samlEnvVarAttrMapUserName:
- *            type: string
- *            description: attribute mapping user name for saml
- *          samlEnvVarAttrMapMail:
- *            type: string
- *            description: attribute mapping mail for saml
- *          samlAttrMapFirstName:
- *            type: string
- *            description: attribute mapping first name for saml
- *          samlAttrMapLastName:
- *            type: string
- *            description: attribute mapping last name for saml
- *          samlEnvVarAttrMapFirstName:
- *            type: string
- *            description: attribute mapping first name for saml
- *          samlEnvVarAttrMapLastName:
- *            type: string
- *            description: attribute mapping last name for saml
- *          isSameUsernameTreatedAsIdenticalUser:
- *            type: boolean
- *            description: local account automatically linked the user name matched
- *          isSameEmailTreatedAsIdenticalUser:
- *            type: boolean
- *            description: local account automatically linked the email matched
- *          samlABLCRule:
- *            type: string
- *            description: ABLCRule for saml
- *          samlEnvVarABLCRule:
- *            type: string
- *            description: ABLCRule for saml
  *      OidcAuthSetting:
  *        type: object
  *        properties:
@@ -1566,131 +1462,9 @@ module.exports = (crowi) => {
     loginRequiredStrictly,
     adminRequired,
     addActivity,
-    validator.samlAuth,
+    samlAuthValidator,
     apiV3FormValidator,
-    async (req, res) => {
-      const { t } = await getTranslation({
-        lang: req.user.lang,
-        ns: ['translation', 'admin'],
-      });
-
-      //  For the value of each mandatory items,
-      //  check whether it from the environment variables is empty and form value to update it is empty
-      //  validate the syntax of a attribute - based login control rule
-      const invalidValues = [];
-      for (const configKey of crowi.passportService
-        .mandatoryConfigKeysForSaml) {
-        const key = configKey.replace('security:passport-saml:', '');
-        const formValue = req.body[key];
-        if (
-          configManager.getConfig(configKey, ConfigSource.env) == null &&
-          formValue == null
-        ) {
-          const formItemName = t(`security_settings.form_item_name.${key}`);
-          invalidValues.push(
-            t('input_validation.message.required', { param: formItemName }),
-          );
-        }
-      }
-      if (invalidValues.length !== 0) {
-        return res.apiv3Err(
-          t('input_validation.message.error_message'),
-          400,
-          invalidValues,
-        );
-      }
-
-      const rule = req.body.ABLCRule;
-      // Empty string disables attribute-based login control.
-      // So, when rule is empty string, validation is passed.
-      if (rule != null) {
-        try {
-          crowi.passportService.parseABLCRule(rule);
-        } catch (err) {
-          return res.apiv3Err(
-            t('input_validation.message.invalid_syntax', {
-              syntax: t('security_settings.form_item_name.ABLCRule'),
-            }),
-            400,
-          );
-        }
-      }
-
-      const requestParams = {
-        'security:passport-saml:entryPoint': req.body.entryPoint,
-        'security:passport-saml:issuer': req.body.issuer,
-        'security:passport-saml:cert': req.body.cert,
-        'security:passport-saml:attrMapId': req.body.attrMapId,
-        'security:passport-saml:attrMapUsername': req.body.attrMapUsername,
-        'security:passport-saml:attrMapMail': req.body.attrMapMail,
-        'security:passport-saml:attrMapFirstName': req.body.attrMapFirstName,
-        'security:passport-saml:attrMapLastName': req.body.attrMapLastName,
-        'security:passport-saml:isSameUsernameTreatedAsIdenticalUser':
-          req.body.isSameUsernameTreatedAsIdenticalUser,
-        'security:passport-saml:isSameEmailTreatedAsIdenticalUser':
-          req.body.isSameEmailTreatedAsIdenticalUser,
-        'security:passport-saml:ABLCRule': req.body.ABLCRule,
-      };
-
-      try {
-        await updateAndReloadStrategySettings('saml', requestParams);
-
-        const securitySettingParams = {
-          missingMandatoryConfigKeys:
-            await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-          samlEntryPoint: await configManager.getConfig(
-            'security:passport-saml:entryPoint',
-            ConfigSource.db,
-          ),
-          samlIssuer: await configManager.getConfig(
-            'security:passport-saml:issuer',
-            ConfigSource.db,
-          ),
-          samlCert: await configManager.getConfig(
-            'security:passport-saml:cert',
-            ConfigSource.db,
-          ),
-          samlAttrMapId: await configManager.getConfig(
-            'security:passport-saml:attrMapId',
-            ConfigSource.db,
-          ),
-          samlAttrMapUsername: await configManager.getConfig(
-            'security:passport-saml:attrMapUsername',
-            ConfigSource.db,
-          ),
-          samlAttrMapMail: await configManager.getConfig(
-            'security:passport-saml:attrMapMail',
-            ConfigSource.db,
-          ),
-          samlAttrMapFirstName: await configManager.getConfig(
-            'security:passport-saml:attrMapFirstName',
-            ConfigSource.db,
-          ),
-          samlAttrMapLastName: await configManager.getConfig(
-            'security:passport-saml:attrMapLastName',
-            ConfigSource.db,
-          ),
-          isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig(
-            'security:passport-saml:isSameUsernameTreatedAsIdenticalUser',
-          ),
-          isSameEmailTreatedAsIdenticalUser: await configManager.getConfig(
-            'security:passport-saml:isSameEmailTreatedAsIdenticalUser',
-          ),
-          samlABLCRule: await configManager.getConfig(
-            'security:passport-saml:ABLCRule',
-          ),
-        };
-        const parameters = {
-          action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE,
-        };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-        return res.apiv3({ securitySettingParams });
-      } catch (err) {
-        const msg = 'Error occurred in updating SAML setting';
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
-      }
-    },
+    handleSamlUpdate(crowi, activityEvent, updateAndReloadStrategySettings),
   );
 
   /**

+ 320 - 0
apps/app/src/server/routes/apiv3/security-settings/saml.ts

@@ -0,0 +1,320 @@
+import type { EventEmitter } from 'node:events';
+import type { NonBlankString } from '@growi/core';
+import {
+  ConfigSource,
+  toNonBlankStringOrUndefined,
+} from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager';
+import { getTranslation } from '~/server/service/i18next';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:security-setting:saml');
+
+// Type definitions
+interface SamlRequestBody {
+  entryPoint?: string | null;
+  issuer?: string | null;
+  cert?: string | null;
+  attrMapId?: string | null;
+  attrMapUsername?: string | null;
+  attrMapMail?: string | null;
+  attrMapFirstName?: string | null;
+  attrMapLastName?: string | null;
+  isSameUsernameTreatedAsIdenticalUser?: boolean;
+  isSameEmailTreatedAsIdenticalUser?: boolean;
+  ABLCRule?: string | null;
+}
+
+interface SamlSecuritySettingParams {
+  missingMandatoryConfigKeys: string[];
+  samlEntryPoint: NonBlankString | undefined;
+  samlIssuer: NonBlankString | undefined;
+  samlCert: NonBlankString | undefined;
+  samlAttrMapId: NonBlankString | undefined;
+  samlAttrMapUsername: NonBlankString | undefined;
+  samlAttrMapMail: NonBlankString | undefined;
+  samlAttrMapFirstName: NonBlankString | undefined;
+  samlAttrMapLastName: NonBlankString | undefined;
+  isSameUsernameTreatedAsIdenticalUser: boolean;
+  isSameEmailTreatedAsIdenticalUser: boolean;
+  samlABLCRule: NonBlankString | undefined;
+}
+
+type UpdateAndReloadStrategySettings = (
+  authId: string,
+  params: Record<string, unknown>,
+  opts?: { removeIfUndefined?: boolean },
+) => Promise<void>;
+
+// Validator
+export const samlAuthValidator = [
+  body('entryPoint')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('issuer')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('cert')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('attrMapId')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('attrMapUsername')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('attrMapMail')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('attrMapFirstName')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('attrMapLastName')
+    .if((value: unknown) => value != null)
+    .isString(),
+  body('isSameUsernameTreatedAsIdenticalUser')
+    .if((value: unknown) => value != null)
+    .isBoolean(),
+  body('isSameEmailTreatedAsIdenticalUser')
+    .if((value: unknown) => value != null)
+    .isBoolean(),
+  body('ABLCRule')
+    .if((value: unknown) => value != null)
+    .isString(),
+];
+
+/**
+ * @swagger
+ *
+ * components:
+ *   schemas:
+ *     SamlAuthSetting:
+ *       type: object
+ *       properties:
+ *         missingMandatoryConfigKeys:
+ *           type: array
+ *           description: array of missing mandatory config keys
+ *           items:
+ *             type: string
+ *             description: missing mandatory config key
+ *         useOnlyEnvVarsForSomeOptions:
+ *           type: boolean
+ *           description: use only env vars for some options
+ *         samlEntryPoint:
+ *           type: string
+ *           description: entry point for saml
+ *         samlIssuer:
+ *           type: string
+ *           description: issuer for saml
+ *         samlEnvVarIssuer:
+ *           type: string
+ *           description: issuer for saml
+ *         samlCert:
+ *           type: string
+ *           description: certificate for saml
+ *         samlEnvVarCert:
+ *           type: string
+ *           description: certificate for saml
+ *         samlAttrMapId:
+ *           type: string
+ *           description: attribute mapping id for saml
+ *         samlAttrMapUserName:
+ *           type: string
+ *           description: attribute mapping user name for saml
+ *         samlAttrMapMail:
+ *           type: string
+ *           description: attribute mapping mail for saml
+ *         samlEnvVarAttrMapId:
+ *           type: string
+ *           description: attribute mapping id for saml
+ *         samlEnvVarAttrMapUserName:
+ *           type: string
+ *           description: attribute mapping user name for saml
+ *         samlEnvVarAttrMapMail:
+ *           type: string
+ *           description: attribute mapping mail for saml
+ *         samlAttrMapFirstName:
+ *           type: string
+ *           description: attribute mapping first name for saml
+ *         samlAttrMapLastName:
+ *           type: string
+ *           description: attribute mapping last name for saml
+ *         samlEnvVarAttrMapFirstName:
+ *           type: string
+ *           description: attribute mapping first name for saml
+ *         samlEnvVarAttrMapLastName:
+ *           type: string
+ *           description: attribute mapping last name for saml
+ *         isSameUsernameTreatedAsIdenticalUser:
+ *           type: boolean
+ *           description: local account automatically linked the user name matched
+ *         isSameEmailTreatedAsIdenticalUser:
+ *           type: boolean
+ *           description: local account automatically linked the email matched
+ *         samlABLCRule:
+ *           type: string
+ *           description: ABLCRule for saml
+ *         samlEnvVarABLCRule:
+ *           type: string
+ *           description: ABLCRule for saml
+ */
+
+/**
+ * SAML authentication route handler
+ */
+export const handleSamlUpdate = (
+  crowi: Crowi,
+  activityEvent: EventEmitter,
+  updateAndReloadStrategySettings: UpdateAndReloadStrategySettings,
+) => {
+  return async (req: CrowiRequest, res: ApiV3Response) => {
+    const { t } = await getTranslation({
+      lang: req.user?.lang,
+      ns: ['translation', 'admin'],
+    });
+
+    const reqBody = req.body as SamlRequestBody;
+
+    //  For the value of each mandatory items,
+    //  check whether it from the environment variables is empty and form value to update it is empty
+    //  validate the syntax of a attribute - based login control rule
+    const invalidValues: string[] = [];
+    for (const configKey of crowi.passportService.mandatoryConfigKeysForSaml) {
+      const key = configKey.replace('security:passport-saml:', '');
+      const formValue = reqBody[key as keyof SamlRequestBody];
+      if (
+        configManager.getConfig(configKey, ConfigSource.env) == null &&
+        formValue == null
+      ) {
+        const formItemName = t(`security_settings.form_item_name.${key}`);
+        invalidValues.push(
+          t('input_validation.message.required', { param: formItemName }),
+        );
+      }
+    }
+    if (invalidValues.length !== 0) {
+      return res.apiv3Err(
+        t('input_validation.message.error_message'),
+        400,
+        invalidValues,
+      );
+    }
+
+    const rule = reqBody.ABLCRule;
+    // Empty string disables attribute-based login control.
+    // So, when rule is empty string, validation is passed.
+    if (rule != null) {
+      try {
+        crowi.passportService.parseABLCRule(rule);
+      } catch (_err) {
+        return res.apiv3Err(
+          t('input_validation.message.invalid_syntax', {
+            syntax: t('security_settings.form_item_name.ABLCRule'),
+          }),
+          400,
+        );
+      }
+    }
+
+    const requestParams: Record<string, unknown> = {
+      'security:passport-saml:entryPoint': toNonBlankStringOrUndefined(
+        reqBody.entryPoint,
+      ),
+      'security:passport-saml:issuer': toNonBlankStringOrUndefined(
+        reqBody.issuer,
+      ),
+      'security:passport-saml:cert': toNonBlankStringOrUndefined(reqBody.cert),
+      'security:passport-saml:attrMapId': toNonBlankStringOrUndefined(
+        reqBody.attrMapId,
+      ),
+      'security:passport-saml:attrMapUsername': toNonBlankStringOrUndefined(
+        reqBody.attrMapUsername,
+      ),
+      'security:passport-saml:attrMapMail': toNonBlankStringOrUndefined(
+        reqBody.attrMapMail,
+      ),
+      'security:passport-saml:attrMapFirstName': toNonBlankStringOrUndefined(
+        reqBody.attrMapFirstName,
+      ),
+      'security:passport-saml:attrMapLastName': toNonBlankStringOrUndefined(
+        reqBody.attrMapLastName,
+      ),
+      'security:passport-saml:isSameUsernameTreatedAsIdenticalUser':
+        reqBody.isSameUsernameTreatedAsIdenticalUser,
+      'security:passport-saml:isSameEmailTreatedAsIdenticalUser':
+        reqBody.isSameEmailTreatedAsIdenticalUser,
+      'security:passport-saml:ABLCRule': toNonBlankStringOrUndefined(
+        reqBody.ABLCRule,
+      ),
+    };
+
+    try {
+      await updateAndReloadStrategySettings('saml', requestParams, {
+        removeIfUndefined: true,
+      });
+
+      const securitySettingParams: SamlSecuritySettingParams = {
+        missingMandatoryConfigKeys:
+          await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
+        samlEntryPoint: await configManager.getConfig(
+          'security:passport-saml:entryPoint',
+          ConfigSource.db,
+        ),
+        samlIssuer: await configManager.getConfig(
+          'security:passport-saml:issuer',
+          ConfigSource.db,
+        ),
+        samlCert: await configManager.getConfig(
+          'security:passport-saml:cert',
+          ConfigSource.db,
+        ),
+        samlAttrMapId: await configManager.getConfig(
+          'security:passport-saml:attrMapId',
+          ConfigSource.db,
+        ),
+        samlAttrMapUsername: await configManager.getConfig(
+          'security:passport-saml:attrMapUsername',
+          ConfigSource.db,
+        ),
+        samlAttrMapMail: await configManager.getConfig(
+          'security:passport-saml:attrMapMail',
+          ConfigSource.db,
+        ),
+        samlAttrMapFirstName: await configManager.getConfig(
+          'security:passport-saml:attrMapFirstName',
+          ConfigSource.db,
+        ),
+        samlAttrMapLastName: await configManager.getConfig(
+          'security:passport-saml:attrMapLastName',
+          ConfigSource.db,
+        ),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig(
+          'security:passport-saml:isSameUsernameTreatedAsIdenticalUser',
+        ),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig(
+          'security:passport-saml:isSameEmailTreatedAsIdenticalUser',
+        ),
+        samlABLCRule: await configManager.getConfig(
+          'security:passport-saml:ABLCRule',
+        ),
+      };
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+      return res.apiv3({ securitySettingParams });
+    } catch (err) {
+      const msg = 'Error occurred in updating SAML setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
+    }
+  };
+};

+ 25 - 13
apps/app/src/server/service/config-manager/config-definition.ts

@@ -568,43 +568,55 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'SAML_ENABLED',
     defaultValue: false,
   }),
-  'security:passport-saml:callbackUrl': defineConfig<string | undefined>({
+  'security:passport-saml:callbackUrl': defineConfig<
+    NonBlankString | undefined
+  >({
     envVarName: 'SAML_CALLBACK_URI',
     defaultValue: undefined,
   }),
-  'security:passport-saml:attrMapId': defineConfig<string | undefined>({
+  'security:passport-saml:attrMapId': defineConfig<NonBlankString | undefined>({
     envVarName: 'SAML_ATTR_MAPPING_ID',
     defaultValue: undefined,
   }),
-  'security:passport-saml:attrMapUsername': defineConfig<string | undefined>({
+  'security:passport-saml:attrMapUsername': defineConfig<
+    NonBlankString | undefined
+  >({
     envVarName: 'SAML_ATTR_MAPPING_USERNAME',
     defaultValue: undefined,
   }),
-  'security:passport-saml:attrMapMail': defineConfig<string | undefined>({
+  'security:passport-saml:attrMapMail': defineConfig<
+    NonBlankString | undefined
+  >({
     envVarName: 'SAML_ATTR_MAPPING_MAIL',
     defaultValue: undefined,
   }),
-  'security:passport-saml:attrMapFirstName': defineConfig<string | undefined>({
+  'security:passport-saml:attrMapFirstName': defineConfig<
+    NonBlankString | undefined
+  >({
     envVarName: 'SAML_ATTR_MAPPING_FIRST_NAME',
     defaultValue: undefined,
   }),
-  'security:passport-saml:attrMapLastName': defineConfig<string | undefined>({
+  'security:passport-saml:attrMapLastName': defineConfig<
+    NonBlankString | undefined
+  >({
     envVarName: 'SAML_ATTR_MAPPING_LAST_NAME',
     defaultValue: undefined,
   }),
-  'security:passport-saml:ABLCRule': defineConfig<string | undefined>({
+  'security:passport-saml:ABLCRule': defineConfig<NonBlankString | undefined>({
     envVarName: 'SAML_ABLC_RULE',
     defaultValue: undefined,
   }),
-  'security:passport-saml:entryPoint': defineConfig<string | undefined>({
-    envVarName: 'SAML_ENTRY_POINT',
-    defaultValue: undefined,
-  }),
-  'security:passport-saml:issuer': defineConfig<string | undefined>({
+  'security:passport-saml:entryPoint': defineConfig<NonBlankString | undefined>(
+    {
+      envVarName: 'SAML_ENTRY_POINT',
+      defaultValue: undefined,
+    },
+  ),
+  'security:passport-saml:issuer': defineConfig<NonBlankString | undefined>({
     envVarName: 'SAML_ISSUER',
     defaultValue: undefined,
   }),
-  'security:passport-saml:cert': defineConfig<string | undefined>({
+  'security:passport-saml:cert': defineConfig<NonBlankString | undefined>({
     envVarName: 'SAML_CERT',
     defaultValue: undefined,
   }),