/*
 * SPDX-License-Identifier: ISC
 * SPDX-URL: https://spdx.org/licenses/ISC.html
 *
 * Copyright (C) 2009 Atheme Project (http://atheme.org/)
 *
 * LDAP authentication.
 */

/* Supports the following options:
 *
 * url -- required ldap URL (e.g. ldap://host.domain.com/)
 *
 * then either:
 *
 *   dnformat  -- basedn to authenticate against.
 *                Use %s to specify where to insert the nick
 *
 * or:
 *
 *   base      -- basedn to begin the search for the matching dn of the user
 *   attribute -- the attribute to search against to find the nick
 *   binddn    -- distinguished name to bind to for searching (optional)
 *   bindauth  -- password for the distinguished name
 *                (optional, must specify if binddn given)
 */

#include <atheme.h>

#ifdef HAVE_LIBLDAP

#include <ldap.h>

static struct
{
	char *url;
	char *dnformat;
	char *attribute;
	char *base;
	char *binddn;
	char *bindauth;
	bool useDN;
} ldap_config;

static LDAP *ldap_conn;

static mowgli_list_t conf_ldap_table;

static void
ldap_config_ready(void *unused)
{
	int res;
	char *p;
	static time_t lastwarning;

	if (ldap_conn != NULL)
		ldap_unbind_ext_s(ldap_conn, NULL, NULL);
	ldap_conn = NULL;
	if (ldap_config.url == NULL)
	{
		slog(LG_ERROR, "ldap_config_ready(): ldap {} missing url definition");
		return;
	}
	if ((ldap_config.dnformat == NULL) && ((ldap_config.base == NULL) || (ldap_config.attribute == NULL)))
	{
		slog(LG_ERROR, "ldap_config_ready(): ldap {} block requires dnformat or base & attribute definition");
		return;
	}
	if (ldap_config.binddn != NULL && ldap_config.bindauth == NULL)
	{
		slog(LG_ERROR, "ldap_config_ready(): ldap{} block requires bindauth to be defined if binddn is defined");
		return;
	}

	if (ldap_config.dnformat != NULL)
	{
		ldap_config.useDN = true;
		p = strchr(ldap_config.dnformat, '%');
		if (p == NULL || p[1] != 's' || strchr(p + 1, '%'))
		{
			slog(LG_ERROR, "ldap_config_ready(): dnformat must contain exactly one %%s and no other %%");
			return;
		}
	}
	else
		ldap_config.useDN = false;

	ldap_set_option(NULL, LDAP_OPT_PROTOCOL_VERSION, &(const int)
			{
			3});
	res = ldap_initialize(&ldap_conn, ldap_config.url);
	if (res != LDAP_SUCCESS)
	{
		slog(LG_ERROR, "ldap_config_ready(): ldap_initialize(%s) failed: %s", ldap_config.url, ldap_err2string(res));
		if (CURRTIME > lastwarning + 300)
		{
			slog(LG_INFO, "LDAP:ERROR: \2%s\2", ldap_err2string(res));
			wallops("Problem with LDAP server: %s", ldap_err2string(res));
			lastwarning = CURRTIME;
		}
		return;
	}

	// short timeouts, because this blocks atheme as a whole
	ldap_set_option(ldap_conn, LDAP_OPT_TIMEOUT, &(const struct timeval){1, 0});
	ldap_set_option(ldap_conn, LDAP_OPT_NETWORK_TIMEOUT, &(const struct timeval){1, 0});
	ldap_set_option(ldap_conn, LDAP_OPT_DEREF, &(const int){false});
	ldap_set_option(ldap_conn, LDAP_OPT_REFERRALS, &(const int){false});
}

static bool
ldap_auth_user(struct myuser *mu, const char *password)
{
	int res;
	struct berval cred;
	LDAPMessage *message, *entry;

	if (ldap_conn == NULL)
	{
		ldap_config_ready(NULL);
	}
	if (ldap_conn == NULL)
	{
		slog(LG_INFO, "ldap_auth_user(): no connection");
		return false;
	}

	if (strchr(entity(mu)->name, ' '))
	{
		slog(LG_INFO, "ldap_auth_user(%s): bad name: found space", entity(mu)->name);
		return false;
	}
	if (strchr(entity(mu)->name, ','))
	{
		slog(LG_INFO, "ldap_auth_user(%s): bad name: found comma", entity(mu)->name);
		return false;
	}
	if (strchr(entity(mu)->name, '/'))
	{
		slog(LG_INFO, "ldap_auth_user(%s): bad name: found /", entity(mu)->name);
		return false;
	}

	if (ldap_config.useDN)
	{
		// Use DN to find exact match
		char dn[512];
		cred.bv_len = strlen(password);

		// sstrdup it to remove the const
		cred.bv_val = sstrdup(password);

		snprintf(dn, sizeof dn, ldap_config.dnformat, entity(mu)->name);
		res = ldap_sasl_bind_s(ldap_conn, dn, LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL);
		if (res == LDAP_SERVER_DOWN)
		{
			ldap_config_ready(NULL);
			res = ldap_sasl_bind_s(ldap_conn, dn, LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL);
		}
		if (res == LDAP_SUCCESS)
			return true;
		else if (res == LDAP_INVALID_CREDENTIALS)
		{
			slog(LG_INFO, "ldap_auth_user(%s): ldap auth bind failed: %s", entity(mu)->name, ldap_err2string(res));
			return false;
		}
		slog(LG_INFO, "ldap_auth_user(): ldap_bind_s failed: %s", ldap_err2string(res));
		return false;
	}
	else
	{
		// Use base + attribute search for Auth
		char what[512];
		char *binddn = NULL;

		cred.bv_len = 0;

		if (ldap_config.binddn != NULL && ldap_config.bindauth != NULL)
		{
			binddn = ldap_config.binddn;
			cred.bv_val = ldap_config.bindauth;
			cred.bv_len = strlen(ldap_config.bindauth);
		}

		res = ldap_sasl_bind_s(ldap_conn, binddn, LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL);
		if (res == LDAP_SERVER_DOWN)
		{
			ldap_config_ready(NULL);
			res = ldap_sasl_bind_s(ldap_conn, binddn, LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL);
		}
		if (res != LDAP_SUCCESS)
		{
			slog(LG_INFO, "ldap_auth_user(): ldap_bind_s failed: %s", ldap_err2string(res));
			return false;
		}

		sprintf(what, "%s=%s", ldap_config.attribute, entity(mu)->name);
		if ((res = ldap_search_ext_s(ldap_conn, ldap_config.base, LDAP_SCOPE_SUBTREE, what, NULL, 0, NULL, NULL, NULL, 0, &message)) != LDAP_SUCCESS)
		{
			slog(LG_INFO, "ldap_auth_user(%s): ldap search failed: %s", entity(mu)->name, ldap_err2string(res));
			return false;
		}

		cred.bv_len = strlen(password);

		// sstrdup it to remove the const
		cred.bv_val = sstrdup(password);

		for (entry = ldap_first_message(ldap_conn, message); entry && ldap_msgtype(entry) == LDAP_RES_SEARCH_ENTRY; entry = ldap_next_message(ldap_conn, entry))
		{

			res = ldap_sasl_bind_s(ldap_conn, ldap_get_dn(ldap_conn, entry), LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL);
			if (res == LDAP_SUCCESS)
			{
				ldap_msgfree(message);
				return true;
			}
		}

		ldap_msgfree(message);
	}

	slog(LG_INFO, "ldap_auth_user(%s): ldap auth bind failed: %s", entity(mu)->name, ldap_err2string(res));
	return false;
}

static void
mod_init(struct module ATHEME_VATTR_UNUSED *const restrict m)
{
	hook_add_config_ready(ldap_config_ready);

	add_subblock_top_conf("LDAP", &conf_ldap_table);
	add_dupstr_conf_item("URL", &conf_ldap_table, 0, &ldap_config.url, NULL);
	add_dupstr_conf_item("DNFORMAT", &conf_ldap_table, 0, &ldap_config.dnformat, NULL);
	add_dupstr_conf_item("BASE", &conf_ldap_table, 0, &ldap_config.base, NULL);
	add_dupstr_conf_item("ATTRIBUTE", &conf_ldap_table, 0, &ldap_config.attribute, NULL);
	add_dupstr_conf_item("BINDDN", &conf_ldap_table, 0, &ldap_config.binddn, NULL);
	add_dupstr_conf_item("BINDAUTH", &conf_ldap_table, 0, &ldap_config.bindauth, NULL);

	auth_user_custom = &ldap_auth_user;

	auth_module_loaded = true;
}

static void
mod_deinit(const enum module_unload_intent ATHEME_VATTR_UNUSED intent)
{
	auth_user_custom = NULL;

	auth_module_loaded = false;

	if (ldap_conn != NULL)
		ldap_unbind_ext_s(ldap_conn, NULL, NULL);

	hook_del_config_ready(ldap_config_ready);
	del_conf_item("URL", &conf_ldap_table);
	del_conf_item("DNFORMAT", &conf_ldap_table);
	del_conf_item("BASE", &conf_ldap_table);
	del_conf_item("ATTRIBUTE", &conf_ldap_table);
	del_conf_item("BINDDN", &conf_ldap_table);
	del_conf_item("BINDAUTH", &conf_ldap_table);
	del_top_conf("LDAP");
}

#else /* HAVE_LIBLDAP */

static void
mod_init(struct module *const restrict m)
{
	(void) slog(LG_ERROR, "Module %s requires LDAP support, refusing to load.", m->name);

	m->mflags |= MODFLAG_FAIL;
}

static void
mod_deinit(const enum module_unload_intent ATHEME_VATTR_UNUSED intent)
{
	// Nothing To Do
}

#endif /* !HAVE_LIBLDAP */

SIMPLE_DECLARE_MODULE_V1("auth/ldap", MODULE_UNLOAD_CAPABILITY_OK)
