]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4985 from BookStackApp/ldap_ca_cert_control
authorDan Brown <redacted>
Thu, 2 May 2024 22:16:16 +0000 (23:16 +0100)
committerGitHub <redacted>
Thu, 2 May 2024 22:16:16 +0000 (23:16 +0100)
LDAP CA TLS Cert Option, PR Review and continuation

1  2 
.env.example.complete
app/Access/LdapService.php
app/Config/services.php
tests/Auth/LdapTest.php

diff --combined .env.example.complete
index e3b2185cc29bf99265039184efc9b058bfef669e,cfc968192760dd075d79f05e3f07f478c6efb80a..1a4f421f0244a87dc0190292d9cc82ccaa7c9e28
@@@ -215,10 -215,11 +215,11 @@@ LDAP_SERVER=fals
  LDAP_BASE_DN=false
  LDAP_DN=false
  LDAP_PASS=false
 -LDAP_USER_FILTER=false
 +LDAP_USER_FILTER="(&(uid={user}))"
  LDAP_VERSION=false
  LDAP_START_TLS=false
  LDAP_TLS_INSECURE=false
+ LDAP_TLS_CA_CERT=false
  LDAP_ID_ATTRIBUTE=uid
  LDAP_EMAIL_ATTRIBUTE=mail
  LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@@ -267,7 -268,6 +268,7 @@@ OIDC_ISSUER_DISCOVER=fals
  OIDC_PUBLIC_KEY=null
  OIDC_AUTH_ENDPOINT=null
  OIDC_TOKEN_ENDPOINT=null
 +OIDC_USERINFO_ENDPOINT=null
  OIDC_ADDITIONAL_SCOPES=null
  OIDC_DUMP_USER_DETAILS=false
  OIDC_USER_TO_GROUPS=false
@@@ -325,14 -325,6 +326,14 @@@ FILE_UPLOAD_SIZE_LIMIT=5
  # Can be 'a4' or 'letter'.
  EXPORT_PAGE_SIZE=a4
  
 +# Export PDF Command
 +# Set a command which can be used to convert a HTML file into a PDF file.
 +# When false this will not be used.
 +# String values represent the command to be called for conversion.
 +# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
 +# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
 +EXPORT_PDF_COMMAND=false
 +
  # Set path to wkhtmltopdf binary for PDF generation.
  # Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
  # When false, BookStack will attempt to find a wkhtmltopdf in the application
index 67f3d6f54c6ed70c652516feb0512796a34fc94f,e0d7f2615b3ef248d6163f3ae2a0df10406a8cbd..e822b09a67cc6cc13d58ce9f754a88b48f13ef92
@@@ -209,6 -209,12 +209,12 @@@ class LdapServic
              $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
          }
  
+         // Configure any user-provided CA cert files for LDAP.
+         // This option works globally and must be set before a connection is created.
+         if ($this->config['tls_ca_cert']) {
+             $this->configureTlsCaCerts($this->config['tls_ca_cert']);
+         }
          $ldapHost = $this->parseServerString($this->config['server']);
          $ldapConnection = $this->ldap->connect($ldapHost);
  
  
          // Start and verify TLS if it's enabled
          if ($this->config['start_tls']) {
-             $started = $this->ldap->startTls($ldapConnection);
+             try {
+                 $started = $this->ldap->startTls($ldapConnection);
+             } catch (\Exception $exception) {
+                 $error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
+                 ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
+                 Log::info("LDAP STARTTLS failure: {$error} {$detail}");
+                 throw new LdapException('Could not start TLS connection. Further details in the application log.');
+             }
              if (!$started) {
                  throw new LdapException('Could not start TLS connection');
              }
          return $this->ldapConnection;
      }
  
+     /**
+      * Configure TLS CA certs globally for ldap use.
+      * This will detect if the given path is a directory or file, and set the relevant
+      * LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
+      *
+      * Note: When using a folder, certificates are expected to be correctly named by hash
+      * which can be done via the c_rehash utility.
+      *
+      * @throws LdapException
+      */
+     protected function configureTlsCaCerts(string $caCertPath): void
+     {
+         $errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
+         $path = realpath($caCertPath);
+         if ($path === false) {
+             throw new LdapException($errMessage);
+         }
+         if (is_dir($path)) {
+             $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
+         } else if (is_file($path)) {
+             $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
+         } else {
+             throw new LdapException($errMessage);
+         }
+     }
      /**
       * Parse an LDAP server string and return the host suitable for a connection.
       * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
  
      /**
       * Build a filter string by injecting common variables.
 +     * Both "${var}" and "{var}" style placeholders are supported.
 +     * Dollar based are old format but supported for compatibility.
       */
      protected function buildFilter(string $filterString, array $attrs): string
      {
          $newAttrs = [];
          foreach ($attrs as $key => $attrText) {
 -            $newKey = '${' . $key . '}';
 -            $newAttrs[$newKey] = $this->ldap->escape($attrText);
 +            $escapedText = $this->ldap->escape($attrText);
 +            $oldVarKey = '${' . $key . '}';
 +            $newVarKey = '{' . $key . '}';
 +            $newAttrs[$oldVarKey] = $escapedText;
 +            $newAttrs[$newVarKey] = $escapedText;
          }
  
          return strtr($filterString, $newAttrs);
diff --combined app/Config/services.php
index da07de13e9cbb61ce6c259de71979f4dcf9bf0a0,f93ca44f6e768f2a7ed85de2aaaa0ad39a3f4439..d73458231506cb56cc16ec81d4329709b14dbf95
@@@ -123,7 -123,7 +123,7 @@@ return 
          'dn'                     => env('LDAP_DN', false),
          'pass'                   => env('LDAP_PASS', false),
          'base_dn'                => env('LDAP_BASE_DN', false),
 -        'user_filter'            => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
 +        'user_filter'            => env('LDAP_USER_FILTER', '(&(uid={user}))'),
          'version'                => env('LDAP_VERSION', false),
          'id_attribute'           => env('LDAP_ID_ATTRIBUTE', 'uid'),
          'email_attribute'        => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
          'group_attribute'        => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
          'remove_from_groups'     => env('LDAP_REMOVE_FROM_GROUPS', false),
          'tls_insecure'           => env('LDAP_TLS_INSECURE', false),
+         'tls_ca_cert'            => env('LDAP_TLS_CA_CERT', false),
          'start_tls'              => env('LDAP_START_TLS', false),
          'thumbnail_attribute'    => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
      ],
diff --combined tests/Auth/LdapTest.php
index cb5fc5a8725f953efa686f1e572ed14638fb1a21,717167729f61d4991b347546329cb3a1758f930f..3f80f00f41e4abf454b2121abde1005aa3adef90
@@@ -4,6 -4,7 +4,7 @@@ namespace Tests\Auth
  
  use BookStack\Access\Ldap;
  use BookStack\Access\LdapService;
+ use BookStack\Exceptions\LdapException;
  use BookStack\Users\Models\Role;
  use BookStack\Users\Models\User;
  use Illuminate\Testing\TestResponse;
@@@ -32,9 -33,10 +33,10 @@@ class LdapTest extends TestCas
              'services.ldap.id_attribute'           => 'uid',
              'services.ldap.user_to_groups'         => false,
              'services.ldap.version'                => '3',
 -            'services.ldap.user_filter'            => '(&(uid=${user}))',
 +            'services.ldap.user_filter'            => '(&(uid={user}))',
              'services.ldap.follow_referrals'       => false,
              'services.ldap.tls_insecure'           => false,
+             'services.ldap.tls_ca_cert'            => false,
              'services.ldap.thumbnail_attribute'    => null,
          ]);
          $this->mockLdap = $this->mock(Ldap::class);
          $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
      }
  
 +    public function test_user_filter_default_placeholder_format()
 +    {
 +        config()->set('services.ldap.user_filter', '(&(uid={user}))');
 +        $this->mockUser->name = 'barryldapuser';
 +        $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
 +
 +        $this->commonLdapMocks(1, 1, 1, 1, 1);
 +        $this->mockLdap->shouldReceive('searchAndGetEntries')
 +            ->once()
 +            ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
 +            ->andReturn(['count' => 0, 0 => []]);
 +
 +        $resp = $this->mockUserLogin();
 +        $resp->assertRedirect('/login');
 +    }
 +
 +    public function test_user_filter_old_placeholder_format()
 +    {
 +        config()->set('services.ldap.user_filter', '(&(username=${user}))');
 +        $this->mockUser->name = 'barryldapuser';
 +        $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
 +
 +        $this->commonLdapMocks(1, 1, 1, 1, 1);
 +        $this->mockLdap->shouldReceive('searchAndGetEntries')
 +            ->once()
 +            ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
 +            ->andReturn(['count' => 0, 0 => []]);
 +
 +        $resp = $this->mockUserLogin();
 +        $resp->assertRedirect('/login');
 +    }
 +
      public function test_initial_incorrect_credentials()
      {
          $this->commonLdapMocks(1, 1, 1, 0, 1);
@@@ -799,4 -769,34 +801,34 @@@ EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8w
          $this->assertNotNull($user->avatar);
          $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
      }
+     public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
+     {
+         $path = 'non_found_' . time();
+         config()->set(['services.ldap.tls_ca_cert' => $path]);
+         $this->commonLdapMocks(0, 0, 0, 0, 0);
+         $this->assertThrows(function () {
+             $this->withoutExceptionHandling()->mockUserLogin();
+         }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
+     }
+     public function test_tls_ca_cert_option_used_if_set_to_a_folder()
+     {
+         $path = $this->files->testFilePath('');
+         config()->set(['services.ldap.tls_ca_cert' => $path]);
+         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
+         $this->runFailedAuthLogin();
+     }
+     public function test_tls_ca_cert_option_used_if_set_to_a_file()
+     {
+         $path = $this->files->testFilePath('test-file.txt');
+         config()->set(['services.ldap.tls_ca_cert' => $path]);
+         $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
+         $this->runFailedAuthLogin();
+     }
  }