From 7177855453dfba57a6f98ba9c7e3059380211693 Mon Sep 17 00:00:00 2001
From: Dmitry Ivanov <ivadmi5@gmail.com>
Date: Fri, 30 Jun 2023 20:18:37 +0300
Subject: [PATCH] feat: Implement LDAP authentication

This authentication method doesn't store any secrets; instead,
we delegate the whole auth to a pre-configured LDAP server. In
the method's implementation, we connect to the LDAP server and
perform a BIND operation which checks user's credentials.

Usage example:

```lua
-- Set the default auth method to LDAP and create a new user.
-- NOTE that we still have to provide a dummy password; otherwise
-- box.schema.user.create will setup an empty auth data.
box.cfg({auth_type = 'ldap'})
box.schema.user.create('demo', { password = '' })

-- Configure LDAP server connection URL and DN format string.
os = require('os')
os.setenv('TT_LDAP_URL', 'ldap://localhost:1389')
os.setenv('TT_LDAP_DN_FMT', 'cn=$USER,ou=users,dc=example,dc=org')

-- Authenticate using the LDAP authentication method via net.box.
conn = require('net.box').connect(uri, {
    user = 'demo',
    password = 'password',
    auth_type = 'ldap',
})
```

NO_DOC=picodata internal patch
NO_CHANGELOG=picodata internal patch
NO_TEST=picodata internal patch
---
 .gitlab-ci.yml                      |   2 +
 CMakeLists.txt                      |  58 ++++++
 cmake/BuildLDAP.cmake               |  72 +++++++
 cmake/BuildSASL.cmake               |  68 +++++++
 cmake/FindLDAP.cmake                |  68 +++++++
 cmake/FindSASL.cmake                |  40 ++++
 src/CMakeLists.txt                  |   2 +
 src/box/CMakeLists.txt              |   2 +
 src/box/auth_ldap.c                 | 306 ++++++++++++++++++++++++++++
 src/box/auth_ldap.h                 |  23 +++
 src/box/authentication.c            |   3 +
 test/box-luatest/ldap_auth_test.lua | 133 ++++++++++++
 12 files changed, 777 insertions(+)
 create mode 100644 cmake/BuildLDAP.cmake
 create mode 100644 cmake/BuildSASL.cmake
 create mode 100644 cmake/FindLDAP.cmake
 create mode 100644 cmake/FindSASL.cmake
 create mode 100644 src/box/auth_ldap.c
 create mode 100644 src/box/auth_ldap.h
 create mode 100644 test/box-luatest/ldap_auth_test.lua

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a21dab4175..2e0d3e1e21 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -32,6 +32,7 @@ test-ubuntu:
     tags: [docker]
     image: docker-public.binary.picodata.io/tarantool-testing:latest
     script:
+      - export PATH="$PATH:$PWD" # for GLAuth (CMake will download it)
       - make -f .test.mk test-release
 
 test_mac-m1:
@@ -40,6 +41,7 @@ test_mac-m1:
     script:
       - ulimit -n 10240
       - sudo chown -R $(id -u) /private/tmp/t
+      - export PATH="$PATH:$PWD" # for GLAuth (CMake will download it)
       - make -f .test.mk build
 
 checkpatch:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2f41693c51..50c167ee9a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -91,6 +91,13 @@ add_compile_flags("C;CXX" ${PREFIX_MAP_FLAGS})
 set(DEPENDENCY_CFLAGS "${DEPENDENCY_CFLAGS} ${PREFIX_MAP_FLAGS}")
 set(DEPENDENCY_CXXFLAGS "${DEPENDENCY_CXXFLAGS} ${PREFIX_MAP_FLAGS}")
 
+# We need this for bundled SASL & LDAP libs (in fact, for any autotools-based project)
+if(APPLE)
+    set(DEPENDENCY_CFLAGS "${DEPENDENCY_CFLAGS} ${CMAKE_C_SYSROOT_FLAG} ${CMAKE_OSX_SYSROOT}")
+    set(DEPENDENCY_CPPFLAGS "${DEPENDENCY_CPPFLAGS} ${CMAKE_C_SYSROOT_FLAG} ${CMAKE_OSX_SYSROOT}")
+    set(DEPENDENCY_CXXFLAGS "${DEPENDENCY_CXXFLAGS} ${CMAKE_C_SYSROOT_FLAG} ${CMAKE_OSX_SYSROOT}")
+endif()
+
 set(CMAKE_REQUIRED_DEFINITIONS "-D_GNU_SOURCE")
 
 check_symbol_exists(MAP_ANON sys/mman.h HAVE_MAP_ANON)
@@ -470,8 +477,10 @@ if(ENABLE_BUNDLED_OPENSSL)
     set(OPENSSL_USE_STATIC_LIBS ON)
     include(BuildOpenSSL)
     add_dependencies(build_bundled_libs bundled-openssl)
+    set(LDAP_OPENSSL_DEPS bundled-openssl)
 else()
     find_package(OpenSSL)
+    set(LDAP_OPENSSL_DEPS OpenSSL::SSL OpenSSL::Crypto)
 endif()
 if (OPENSSL_FOUND)
     message(STATUS "OpenSSL ${OPENSSL_VERSION} found")
@@ -491,6 +500,55 @@ if(OPENSSL_USE_STATIC_LIBS)
     endif()
 endif()
 
+#
+# OpenLDAP
+#
+option(ENABLE_BUNDLED_LDAP "Enable building of the bundled libldap" ON)
+if(ENABLE_BUNDLED_LDAP OR BUILD_STATIC)
+    include(BuildSASL)
+    sasl_build()
+    add_dependencies(build_bundled_libs bundled-sasl)
+
+    include(BuildLDAP)
+    ldap_build()
+    add_dependencies(build_bundled_libs bundled-ldap)
+else()
+    set(SASL_FIND_REQUIRED ON)
+    find_package(SASL)
+
+    set(LDAP_FIND_REQUIRED ON)
+    find_package(LDAP)
+endif()
+
+# Download GLAuth, a LDAP server used in tests
+message(STATUS "Downloading GLAuth for tests")
+if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
+    if(${CMAKE_HOST_SYSTEM_PROCESSOR} STREQUAL "x86_64")
+        set(GLAUTH_URL https://github.com/glauth/glauth/releases/download/v2.2.0/glauth-linux-amd64)
+        set(GLAUTH_HASH 823b4403b87e0294ca921651694b57ca)
+    elseif(${CMAKE_HOST_SYSTEM_PROCESSOR} STREQUAL "aarch64")
+        set(GLAUTH_URL https://github.com/glauth/glauth/releases/download/v2.2.0/glauth-linux-arm64)
+        set(GLAUTH_HASH c3e749a34f54e82597e7b4cd7efeae42)
+    else()
+        message(WARNING "Failed to download GLAuth: unsupported platform ${CMAKE_HOST_SYSTEM_PROCESSOR}")
+    endif()
+elseif(${CMAKE_SYSTEM_NAME} STREQUAL "Darwin")
+    if(${CMAKE_HOST_SYSTEM_PROCESSOR} STREQUAL "x86_64")
+        set(GLAUTH_URL https://github.com/glauth/glauth/releases/download/v2.2.0/glauth-darwin-amd64)
+        set(GLAUTH_HASH 19b3a8d83d6b258508df626cd1ff3f46)
+    elseif(${CMAKE_HOST_SYSTEM_PROCESSOR} STREQUAL "arm64")
+        set(GLAUTH_URL https://github.com/glauth/glauth/releases/download/v2.2.0/glauth-darwin-arm64)
+        set(GLAUTH_HASH 45b9f2ff7a27ca1cc2b53910f0c89c67)
+    else()
+        message(WARNING "Failed to download GLAuth: unsupported platform ${CMAKE_HOST_SYSTEM_PROCESSOR}")
+    endif()
+else()
+    message(WARNING "Failed to download GLAuth: unsupported OS")
+endif()
+set(GLAUTH_BIN ${CMAKE_BINARY_DIR}/glauth)
+file(DOWNLOAD ${GLAUTH_URL} ${GLAUTH_BIN} EXPECTED_MD5 ${GLAUTH_HASH} SHOW_PROGRESS)
+execute_process(COMMAND chmod 770 ${GLAUTH_BIN})
+
 #
 # Curl
 #
diff --git a/cmake/BuildLDAP.cmake b/cmake/BuildLDAP.cmake
new file mode 100644
index 0000000000..e64ad6dbd8
--- /dev/null
+++ b/cmake/BuildLDAP.cmake
@@ -0,0 +1,72 @@
+#
+# A macro to build libldap
+macro(ldap_build)
+    message(STATUS "Choosing bundled LDAP")
+
+    # https://git.openldap.org/openldap/openldap
+    set(LDAP_VERSION 2.6.4)
+    set(LDAP_HASH fee2b0dca212b41c87976d0414f30f12)
+    set(LDAP_URL https://www.openldap.org/software/download/OpenLDAP/openldap-release/openldap-${LDAP_VERSION}.tgz)
+
+    # Reusing approach from BuildLibCURL.cmake
+    get_filename_component(OPENSSL_INSTALL_DIR ${OPENSSL_INCLUDE_DIR} DIRECTORY)
+
+    include(ExternalProject)
+    ExternalProject_Add(bundled-ldap
+        DEPENDS ${LDAP_OPENSSL_DEPS} bundled-sasl
+        URL ${LDAP_URL}
+        URL_MD5 ${LDAP_HASH}
+        PATCH_COMMAND
+            # OpenLDAP builds everything (including MAN pages) unconditionally,
+            # thus we have to patch its sources so as not to install soelim (Groff).
+            sed -i.old "/SUBDIRS/s/clients servers tests doc//" Makefile.in
+        CONFIGURE_COMMAND <SOURCE_DIR>/configure
+            "CC=${CMAKE_C_COMPILER}"
+            "CFLAGS=${DEPENDENCY_CFLAGS}"
+            "CPPFLAGS=${DEPENDENCY_CPPFLAGS} -I${OPENSSL_INCLUDE_DIR} -I${SASL_INCLUDE_DIR}"
+            "LDFLAGS=-L${OPENSSL_INSTALL_DIR}/lib -L${SASL_INSTALL_DIR}/lib"
+            "LIBS=-lssl -lcrypto -ldl -lpthread"
+            --prefix=<INSTALL_DIR>
+
+            --with-cyrus-sasl
+            --with-tls=openssl
+
+            --enable-static
+            --disable-shared
+
+            --enable-local
+            --enable-ipv6
+
+            --disable-debug
+            --disable-slapd
+    )
+
+    unset(OPENSSL_INSTALL_DIR)
+
+    ExternalProject_Get_Property(bundled-ldap install_dir)
+    set(LDAP_INSTALL_DIR ${install_dir})
+
+    # Unfortunately, we can't use find_library here,
+    # since the package hasn't been built yet.
+    # We set the same vars as in FindLDAP.cmake.
+    set(LDAP_FOUND TRUE)
+    set(LDAP_INCLUDE_DIR ${LDAP_INSTALL_DIR}/include)
+    set(LDAP_LIBRARIES
+        ${LDAP_INSTALL_DIR}/lib/libldap.a
+        ${LDAP_INSTALL_DIR}/lib/liblber.a
+    )
+
+    # On OS X we may also need to link libresolv.dylib.
+    # However, note that newer OS X releases don't have that file;
+    # instead, it's provided via a built-in dynamic linker cache.
+    if(APPLE)
+        find_library(RESOLV_LIBRARIES NAMES resolv PATHS
+            /usr/lib
+            /usr/local/opt/openldap/lib
+            /usr/local/lib
+            NO_CMAKE_SYSTEM_PATH)
+        if(NOT "${RESOLV_LIBRARIES}" STREQUAL "RESOLV_LIBRARIES-NOTFOUND")
+            set(LDAP_LIBRARIES ${LDAP_LIBRARIES} ${RESOLV_LIBRARIES})
+        endif()
+    endif()
+endmacro()
diff --git a/cmake/BuildSASL.cmake b/cmake/BuildSASL.cmake
new file mode 100644
index 0000000000..6154b165e1
--- /dev/null
+++ b/cmake/BuildSASL.cmake
@@ -0,0 +1,68 @@
+#
+# A macro to build libsasl2
+macro(sasl_build)
+    message(STATUS "Choosing bundled SASL")
+
+    # https://github.com/cyrusimap/cyrus-sasl
+    # Tarball is already pre-configured (autoconf) as opposed to git sources.
+    set(SASL_VERSION 2.1.28)
+    set(SASL_HASH 6f228a692516f5318a64505b46966cfa)
+    set(SASL_URL https://github.com/cyrusimap/cyrus-sasl/releases/download/cyrus-sasl-${SASL_VERSION}/cyrus-sasl-${SASL_VERSION}.tar.gz)
+
+    # Reusing approach from BuildLibCURL.cmake
+    get_filename_component(OPENSSL_INSTALL_DIR ${OPENSSL_INCLUDE_DIR} DIRECTORY)
+
+    include(ExternalProject)
+    ExternalProject_Add(bundled-sasl
+        DEPENDS ${LDAP_OPENSSL_DEPS}
+        URL ${SASL_URL}
+        URL_MD5 ${SASL_HASH}
+        CONFIGURE_COMMAND <SOURCE_DIR>/configure
+            "CC=${CMAKE_C_COMPILER}"
+            "CFLAGS=${DEPENDENCY_CFLAGS}"
+            "CPPFLAGS=${DEPENDENCY_CPPFLAGS} -I${OPENSSL_INCLUDE_DIR}"
+            "LDFLAGS=-L${OPENSSL_INSTALL_DIR}/lib"
+            "LIBS=-lssl -lcrypto -ldl -lpthread"
+            --prefix=<INSTALL_DIR>
+
+            --with-openssl=${OPENSSL_INSTALL_DIR}
+            --with-dblib=none
+
+            --enable-static
+            --disable-shared
+
+            # Auth plugins listed below
+            --enable-anon
+            --enable-cram
+            --enable-digest
+            --enable-otp
+            --enable-plain
+            --enable-scram
+
+            --disable-gssapi
+            --disable-krb4
+
+            --disable-macos-framework
+            --disable-sample
+    )
+
+    unset(OPENSSL_INSTALL_DIR)
+
+    ExternalProject_Get_Property(bundled-sasl install_dir)
+    set(SASL_INSTALL_DIR ${install_dir})
+
+    # Unfortunately, we can't use find_library here,
+    # since the package hasn't been built yet.
+    # We set the same vars as in FindSASL.cmake.
+    set(SASL_FOUND TRUE)
+    set(SASL_INCLUDE_DIR ${SASL_INSTALL_DIR}/include)
+    set(SASL_LIBRARIES
+        ${SASL_INSTALL_DIR}/lib/libsasl2.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libanonymous.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libcrammd5.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libdigestmd5.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libotp.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libplain.a
+        ${SASL_INSTALL_DIR}/lib/sasl2/libscram.a
+    )
+endmacro()
diff --git a/cmake/FindLDAP.cmake b/cmake/FindLDAP.cmake
new file mode 100644
index 0000000000..792e5528b2
--- /dev/null
+++ b/cmake/FindLDAP.cmake
@@ -0,0 +1,68 @@
+# - Find LDAP header and library
+# The module defines the following variables:
+#
+#  LDAP_FOUND - true if libldap was found
+#  LDAP_INCLUDE_DIR - the directory of the LDAP headers
+#  LDAP_LIBRARIES - the libraries needed for linking
+
+# Support preference of static libs by adjusting CMAKE_FIND_LIBRARY_SUFFIXES
+if(BUILD_STATIC)
+    set(_openldap_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES})
+    set(CMAKE_FIND_LIBRARY_SUFFIXES .a)
+endif()
+
+if(APPLE)
+    find_path(LDAP_INCLUDE_DIR ldap.h PATHS
+        /usr/local/include
+        /usr/local/opt/openldap/include
+        /opt/local/include
+        NO_CMAKE_SYSTEM_PATH)
+    find_path(LDAP_LIBRARIES NAMES ldap PATHS
+        /usr/local/lib
+        /usr/local/opt/openldap/lib
+        /opt/local/lib
+        NO_CMAKE_SYSTEM_PATH)
+    find_path(LBER_LIBRARIES NAMES lber PATHS
+        /usr/local/lib
+        /usr/local/opt/openldap/lib
+        /opt/local/lib
+        NO_CMAKE_SYSTEM_PATH)
+
+    # On OS X we may also need to link libresolv.dylib.
+    # However, note that newer OS X releases don't have that file;
+    # instead, it's provided via a built-in dynamic linker cache.
+    if(_openldap_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES)
+        set(CMAKE_FIND_LIBRARY_SUFFIXES ${_openldap_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES})
+    endif()
+    find_library(RESOLV_LIBRARIES NAMES resolv PATHS
+        /usr/lib
+        /usr/local/opt/openldap/lib
+        /usr/local/lib
+        NO_CMAKE_SYSTEM_PATH)
+else()
+    find_path(LDAP_INCLUDE_DIR ldap.h)
+    find_library(LDAP_LIBRARIES NAMES ldap)
+    find_library(LBER_LIBRARIES NAMES lber)
+endif()
+
+if(LDAP_INCLUDE_DIR AND LDAP_LIBRARIES)
+    set(LDAP_FOUND TRUE)
+endif()
+
+if(LDAP_FOUND)
+    set(LDAP_LIBRARIES ${LDAP_LIBRARIES} ${LBER_LIBRARIES})
+    if(NOT "${RESOLV_LIBRARIES}" STREQUAL "RESOLV_LIBRARIES-NOTFOUND")
+        set(LDAP_LIBRARIES ${LDAP_LIBRARIES} ${RESOLV_LIBRARIES})
+    endif()
+    message(STATUS "Found LDAP includes: ${LDAP_INCLUDE_DIR}")
+    message(STATUS "Found LDAP libraries: ${LDAP_LIBRARIES}")
+else()
+    message(FATAL_ERROR "Could not find LDAP library")
+endif()
+
+mark_as_advanced(LDAP_INCLUDE_DIR LDAP_LIBRARIES LBER_LIBRARIES)
+
+# Restore the original find library ordering
+if(BUILD_STATIC)
+  set(CMAKE_FIND_LIBRARY_SUFFIXES ${_openldap_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES})
+endif()
diff --git a/cmake/FindSASL.cmake b/cmake/FindSASL.cmake
new file mode 100644
index 0000000000..f1176e6f6a
--- /dev/null
+++ b/cmake/FindSASL.cmake
@@ -0,0 +1,40 @@
+# - Find SASL header and library
+# The module defines the following variables:
+#
+#  SASL_FOUND - true if libldap was found
+#  SASL_INCLUDE_DIR - the directory of the SASL headers
+#  SASL_LIBRARIES - the libraries needed for linking
+
+# Support preference of static libs by adjusting CMAKE_FIND_LIBRARY_SUFFIXES
+if(BUILD_STATIC)
+    set(_cyrus_sasl_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES})
+    set(CMAKE_FIND_LIBRARY_SUFFIXES .a)
+endif()
+
+find_path(SASL_INCLUDE_DIR sasl/sasl.h)
+find_library(SASL_LIBRARIES NAMES sasl2)
+
+if(SASL_INCLUDE_DIR AND SASL_LIBRARIES)
+    set(SASL_FOUND TRUE)
+endif()
+
+if(SASL_FOUND)
+    message(STATUS "Found SASL includes: ${SASL_INCLUDE_DIR}")
+    message(STATUS "Found SASL libraries: ${SASL_LIBRARIES}")
+else()
+    message(FATAL_ERROR "Could not find SASL library")
+endif()
+
+if(BUILD_STATIC)
+    get_filename_component(sasl_lib_dir ${SASL_LIBRARIES} DIRECTORY)
+    file(GLOB_RECURSE SASL_PLUGINS "${sasl_lib_dir}/sasl2/*.a")
+    set(SASL_LIBRARIES ${SASL_LIBRARIES} ${SASL_PLUGINS})
+    unset(sasl_lib_dir)
+endif()
+
+mark_as_advanced(SASL_INCLUDE_DIR SASL_LIBRARIES)
+
+# Restore the original find library ordering
+if(BUILD_STATIC)
+  set(CMAKE_FIND_LIBRARY_SUFFIXES ${_cyrus_sasl_ORIG_CMAKE_FIND_LIBRARY_SUFFIXES})
+endif()
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 8556687817..9404c7f4a0 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -311,6 +311,8 @@ set (common_libraries
     ${LIBYAML_LIBRARIES}
     ${READLINE_LIBRARIES}
     ${ICONV_LIBRARIES}
+    ${LDAP_LIBRARIES}
+    ${SASL_LIBRARIES}
     ${OPENSSL_LIBRARIES}
 )
 
diff --git a/src/box/CMakeLists.txt b/src/box/CMakeLists.txt
index fb8b6df408..535d498d7b 100644
--- a/src/box/CMakeLists.txt
+++ b/src/box/CMakeLists.txt
@@ -85,6 +85,7 @@ add_custom_target(box_generate_lua_sources
     DEPENDS ${lua_sources})
 set_property(DIRECTORY PROPERTY ADDITIONAL_MAKE_CLEAN_FILES ${lua_sources})
 
+include_directories(${LDAP_INCLUDE_DIR})
 include_directories(${ZSTD_INCLUDE_DIRS})
 include_directories(${PROJECT_BINARY_DIR}/src/box/sql)
 include_directories(${PROJECT_BINARY_DIR}/src/box)
@@ -208,6 +209,7 @@ set(box_sources
     user.cc
     authentication.c
     auth_chap_sha1.c
+    auth_ldap.c
     auth_md5.c
     replication.cc
     recovery.cc
diff --git a/src/box/auth_ldap.c b/src/box/auth_ldap.c
new file mode 100644
index 0000000000..40bedced6b
--- /dev/null
+++ b/src/box/auth_ldap.c
@@ -0,0 +1,306 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright 2010-2023, Tarantool AUTHORS, please see AUTHORS file.
+ */
+#include "auth_ldap.h"
+
+#include <assert.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <ldap.h>
+
+#include "authentication.h"
+#include "coio_task.h"
+#include "diag.h"
+#include "errcode.h"
+#include "error.h"
+#include "fiber.h"
+#include "msgpuck.h"
+#include "small/region.h"
+#include "trivia/util.h"
+
+#define AUTH_LDAP_NAME "ldap"
+
+/** ldap authenticator implementation. */
+struct auth_ldap_authenticator {
+	/** Base class. */
+	struct authenticator base;
+};
+
+/** Try to format a proper LDAP Distinguished Name (DN). */
+static int
+format_dn(const char *fmt, const char *user, char *buf, size_t len)
+{
+	/** Magic should be replaced with the actual user */
+	const char *magic = "$USER";
+	char *suffix = strstr(fmt, magic);
+	if (suffix == NULL) {
+		say_error("TT_LDAP_DN_FMT doesn't contain $USER");
+		return -1;
+	}
+
+	size_t prefix_len = suffix - fmt;
+	suffix += strlen(magic);
+	size_t suffix_len = strlen(suffix),
+	       user_len = strlen(user);
+
+	if (strstr(suffix, magic) != NULL) {
+		say_error("TT_LDAP_DN_FMT contains more than one $USER");
+		return -1;
+	}
+
+	size_t needed = prefix_len + user_len + suffix_len + 1;
+	if (needed > len) {
+		say_error("TT_LDAP_DN_FMT is too long (max %lu)", len);
+		return -1;
+	}
+
+	memcpy(buf, fmt, prefix_len);
+	buf += prefix_len;
+	memcpy(buf, user, user_len);
+	buf += user_len;
+	memcpy(buf, suffix, suffix_len);
+	buf += suffix_len;
+	buf[0] = '\0';
+
+	return 0;
+}
+
+/** Perform synchronous LDAP BIND method call to authenticate as user */
+static ssize_t
+coio_ldap_check_password(va_list ap)
+{
+	const char *password = va_arg(ap, const char *);
+	uint32_t password_len = va_arg(ap, uint32_t);
+	const char *user = va_arg(ap, const char *);
+
+	/**
+	 * This should point to the LDAP authentication server.
+	 * Example: `ldap://localhost:1389`.
+	 */
+	const char *url = va_arg(ap, const char *);
+
+	/**
+	 * This should be a proper LDAP Distinguished Name (DN) fmt string.
+	 * Example: `cn=$USER,ou=users,dc=example,dc=org`.
+	 */
+	const char *dn_fmt = va_arg(ap, const char *);
+
+	int ret = -1;
+	LDAP *ldp = NULL;
+
+	char dn[512];
+	if (format_dn(dn_fmt, user, dn, sizeof(dn)) != 0)
+		goto cleanup;
+
+	/** Initialize the context, but don't connect just yet */
+	ret = ldap_initialize(&ldp, url);
+	if (ret != LDAP_SUCCESS) {
+		say_error("failed to initialize LDAP connection: %s",
+			  ldap_err2string(ret));
+		goto cleanup;
+	}
+
+	/** NB: older protocol versions may not be supported */
+	ret = ldap_set_option(ldp, LDAP_OPT_PROTOCOL_VERSION,
+			      &(int){LDAP_VERSION3});
+	if (ret != LDAP_SUCCESS) {
+		say_error("failed to set LDAP connection option: %s",
+			  ldap_err2string(ret));
+		goto cleanup;
+	}
+
+	say_info("connecting to LDAP server at '%s'", url);
+	ret = ldap_connect(ldp);
+	if (ret != LDAP_SUCCESS) {
+		say_error("failed to connect to LDAP server at '%s': %s",
+			  url, ldap_err2string(ret));
+		goto cleanup;
+	}
+
+	/** Check user's credentials by binding to the server on their behalf */
+	say_info("attempting LDAP BIND as '%s'", dn);
+	struct berval cred = {
+		.bv_val = (char *)password,
+		.bv_len = (ber_len_t)password_len,
+	};
+	/** See definition of ldap_simple_bind_s() */
+	ret = ldap_sasl_bind_s(ldp, dn, LDAP_SASL_SIMPLE,
+			       &cred, NULL, NULL, NULL);
+	if (ret != LDAP_SUCCESS) {
+		say_error("ldap authentication failed: %s",
+			  ldap_err2string(ret));
+		goto cleanup;
+	}
+
+cleanup:
+	if (ldp) {
+		/** This also reclaims the memory */
+		(void)ldap_unbind_ext_s(ldp, NULL, NULL);
+	}
+
+	return (ret == LDAP_SUCCESS) ? 0 : -1;
+}
+
+/** auth_method::auth_method_delete */
+static void
+auth_ldap_delete(struct auth_method *method)
+{
+	TRASH(method);
+	free(method);
+}
+
+/** auth_method::auth_data_prepare */
+static void
+auth_ldap_data_prepare(const struct auth_method *method,
+		       const char *password, int password_len,
+		       const char *user,
+		       const char **auth_data,
+		       const char **auth_data_end)
+{
+	(void)method;
+	(void)user;
+	(void)password;
+	(void)password_len;
+	struct region *region = &fiber()->gc;
+	size_t size = mp_sizeof_str(0);
+	char *p = xregion_alloc(region, size);
+	*auth_data = p;
+	*auth_data_end = p + size;
+	p = mp_encode_strl(p, 0);
+}
+
+/** auth_method::auth_request_prepare */
+static void
+auth_ldap_request_prepare(const struct auth_method *method,
+			  const char *password, int password_len,
+			  const char *user,
+			  const char *salt,
+			  const char **auth_request,
+			  const char **auth_request_end)
+{
+	(void)method;
+	(void)user;
+	(void)salt;
+	struct region *region = &fiber()->gc;
+	size_t size = mp_sizeof_str(password_len);
+	char *p = xregion_alloc(region, size);
+	*auth_request = p;
+	*auth_request_end = p + size;
+	p = mp_encode_strl(p, password_len);
+	memcpy(p, password, password_len);
+}
+
+/** auth_method::auth_request_check */
+static int
+auth_ldap_request_check(const struct auth_method *method,
+			const char *auth_request,
+			const char *auth_request_end)
+{
+	(void)method;
+	uint32_t password_len;
+	(void)password_len;
+	if (mp_typeof(*auth_request) == MP_STR) {
+		password_len = mp_decode_strl(&auth_request);
+	} else if (mp_typeof(*auth_request) == MP_BIN) {
+		password_len = mp_decode_binl(&auth_request);
+	} else {
+		diag_set(ClientError, ER_INVALID_AUTH_REQUEST,
+			 AUTH_LDAP_NAME, "password must be string");
+		return -1;
+	}
+	assert(auth_request + password_len == auth_request_end);
+	(void)auth_request_end;
+	return 0;
+}
+
+/** auth_method::authenticator_new */
+static struct authenticator *
+auth_ldap_authenticator_new(const struct auth_method *method,
+			    const char *auth_data,
+			    const char *auth_data_end)
+{
+	/** NB: we don't use stored data anyway */
+	(void)auth_data;
+	(void)auth_data_end;
+
+	struct auth_ldap_authenticator *auth = xmalloc(sizeof(*auth));
+	auth->base.method = method;
+	return (struct authenticator *)auth;
+}
+
+/** auth_method::authenticator_delete */
+static void
+auth_ldap_authenticator_delete(struct authenticator *auth_)
+{
+	struct auth_ldap_authenticator *auth =
+		(struct auth_ldap_authenticator *)auth_;
+	TRASH(auth);
+	free(auth);
+}
+
+/** auth_method::authenticator_check_request */
+static bool
+auth_ldap_authenticate_request(const struct authenticator *auth,
+			       const char *user,
+			       const char *salt,
+			       const char *auth_request,
+			       const char *auth_request_end)
+{
+	(void)auth;
+	(void)salt;
+	uint32_t password_len;
+	const char *password;
+	if (mp_typeof(*auth_request) == MP_STR) {
+		password = mp_decode_str(&auth_request, &password_len);
+	} else if (mp_typeof(*auth_request) == MP_BIN) {
+		password = mp_decode_bin(&auth_request, &password_len);
+	} else {
+		unreachable();
+	}
+	assert(auth_request == auth_request_end);
+	(void)auth_request_end;
+
+	ssize_t ret = -1;
+
+	/** NB: we shouldn't call getenv() from coio since it's MT-Unsafe */
+	char url[512];
+	if (getenv_safe("TT_LDAP_URL", url, sizeof(url)) == NULL) {
+		say_error("LDAP server not configured, "
+			  "please set env variable TT_LDAP_URL");
+		goto fail;
+	}
+
+	char dn_fmt[512];
+	if (getenv_safe("TT_LDAP_DN_FMT", dn_fmt, sizeof(dn_fmt)) == NULL) {
+		say_error("LDAP DN format string not configured, "
+			  "please set env variable TT_LDAP_DN_FMT");
+		goto fail;
+	}
+
+	ret = coio_call(coio_ldap_check_password,
+			password, password_len,
+			user, url, dn_fmt);
+fail:
+	return ret == 0;
+}
+
+struct auth_method *
+auth_ldap_new(void)
+{
+	struct auth_method *method = xmalloc(sizeof(*method));
+	method->name = AUTH_LDAP_NAME;
+	method->flags = 0;
+	method->auth_method_delete = auth_ldap_delete;
+	method->auth_data_prepare = auth_ldap_data_prepare;
+	method->auth_request_prepare = auth_ldap_request_prepare;
+	method->auth_request_check = auth_ldap_request_check;
+	method->authenticator_new = auth_ldap_authenticator_new;
+	method->authenticator_delete = auth_ldap_authenticator_delete;
+	method->authenticate_request = auth_ldap_authenticate_request;
+	return method;
+}
diff --git a/src/box/auth_ldap.h b/src/box/auth_ldap.h
new file mode 100644
index 0000000000..551e4afe1c
--- /dev/null
+++ b/src/box/auth_ldap.h
@@ -0,0 +1,23 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright 2010-2023, Tarantool AUTHORS, please see AUTHORS file.
+ */
+#pragma once
+
+#if defined(__cplusplus)
+extern "C" {
+#endif /* defined(__cplusplus) */
+
+struct auth_method;
+
+/**
+ * Allocates and initializes 'ldap' authentication method.
+ * This function never fails.
+ */
+struct auth_method *
+auth_ldap_new(void);
+
+#if defined(__cplusplus)
+} /* extern "C" */
+#endif /* defined(__cplusplus) */
diff --git a/src/box/authentication.c b/src/box/authentication.c
index 9e2f020675..45bc3431ee 100644
--- a/src/box/authentication.c
+++ b/src/box/authentication.c
@@ -13,6 +13,7 @@
 
 #include "assoc.h"
 #include "auth_chap_sha1.h"
+#include "auth_ldap.h"
 #include "auth_md5.h"
 #include "base64.h"
 #include "diag.h"
@@ -175,6 +176,8 @@ auth_init(void)
 	auth_method_register(chap_sha1_method);
 	struct auth_method *md5_method = auth_md5_new();
 	auth_method_register(md5_method);
+	struct auth_method *ldap_method = auth_ldap_new();
+	auth_method_register(ldap_method);
 }
 
 void
diff --git a/test/box-luatest/ldap_auth_test.lua b/test/box-luatest/ldap_auth_test.lua
new file mode 100644
index 0000000000..f57c62f13b
--- /dev/null
+++ b/test/box-luatest/ldap_auth_test.lua
@@ -0,0 +1,133 @@
+local net = require('net.box')
+local server = require('luatest.server')
+local t = require('luatest')
+local urilib = require('uri')
+local fio = require('fio')
+local popen = require('popen')
+local socket = require('socket')
+local fiber = require('fiber')
+
+-- GLAuth (A lightweight LDAP server for development, home use, or CI)
+-- https://github.com/glauth/glauth/releases/download/v2.2.0/glauth-linux-amd64
+local glauth = nil
+local glauth_tmp = fio.tempdir()
+
+local g = t.group()
+
+g.before_all(function(cg)
+    -- NB: configure magic LDAP environment variables.
+    local env = {
+        TT_LDAP_URL = 'ldap://127.0.0.1:1389',
+        TT_LDAP_DN_FMT = 'cn=$USER,dc=example,dc=org'
+    }
+
+    cg.server = server:new({alias = 'master', env = env})
+    cg.server:start()
+    cg.server:exec(function()
+        box.cfg{auth_type = 'ldap'}
+        box.schema.user.create('mickey', {password = ''})
+        box.session.su('admin', box.schema.user.grant, 'mickey', 'super')
+    end)
+
+    local config_path = fio.pathjoin(glauth_tmp, 'ldap.cfg')
+    local config = fio.open(config_path, {'O_CREAT', 'O_RDWR'},
+                            tonumber('644', 8))
+    local password = -- sha256 of `dogood`
+        "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a"
+    config:write(string.format([=[
+        [ldap]
+          enabled = true
+          listen = "127.0.0.1:1389"
+
+        [ldaps]
+          enabled = false
+
+        [backend]
+          datastore = "config"
+          baseDN = "dc=example,dc=org"
+
+        [[users]]
+          name = "mickey"
+          uidnumber = 5001
+          primarygroup = 5501
+          passsha256 = "%s"
+            [[users.capabilities]]
+            action = "search"
+            object = "*"
+
+        [[groups]]
+          name = "cartoons"
+          gidnumber = 5501
+    ]=], password))
+    config:close()
+
+    -- I'd gladly use popen.new, but it doesn't resolve path...
+    glauth = popen.shell('glauth -c ' .. config_path)
+    t.assert_not_equals(glauth, nil)
+
+    -- Wait for GLAuth startup
+    local sock = nil
+    for _=1,40 do
+        sock = socket.tcp_connect('127.0.0.1', 1389)
+        if sock == nil and glauth.status.state == popen.state.ALIVE then
+            fiber.sleep(0.1)
+        else break end
+    end
+    t.assert_not_equals(sock, nil)
+    sock:close()
+end)
+
+g.after_all(function(cg)
+    cg.server:drop()
+    glauth:close()
+    fio.rmtree(glauth_tmp)
+end)
+
+g.test_net_box = function(cg)
+    local parsed_uri = urilib.parse(cg.server.net_box_uri)
+    parsed_uri.login = 'mickey'
+    parsed_uri.password = 'dogood'
+    parsed_uri.params = parsed_uri.params or {}
+    parsed_uri.params.auth_type = {'ldap'}
+
+    -- Good
+    local uri = urilib.format(parsed_uri, true)
+    local conn = net.connect(cg.server.net_box_uri, uri)
+    t.assert_equals(conn.error, nil)
+    t.assert_equals(conn.state, 'active')
+    conn:close()
+
+    -- Good
+    conn = net.connect(cg.server.net_box_uri, {
+        user = 'mickey',
+        password = 'dogood',
+        auth_type = 'ldap',
+    })
+    t.assert_equals(conn.error, nil)
+    t.assert_equals(conn.state, 'active')
+    conn:close()
+
+    -- Bad password
+    conn = net.connect(cg.server.net_box_uri, {
+        user = 'mickey',
+        password = 'wrong_password',
+        auth_type = 'ldap',
+    })
+    t.assert_equals(
+        conn.error,
+        "User not found or supplied credentials are invalid"
+    )
+    conn:close()
+
+    -- Bad auth type
+    conn = net.connect(cg.server.net_box_uri, {
+        user = 'mickey',
+        password = 'dogood',
+        auth_type = 'md5',
+    })
+    t.assert_equals(
+        conn.error,
+        "User not found or supplied credentials are invalid"
+    )
+    conn:close()
+end
-- 
GitLab