/*
 * Auth prompt: SASL authentication for clients that don't support SASL
 * (C) Copyright 2018 Bram Matthys ("Syzop") and the UnrealIRCd team
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 1, or (at your option)
 * any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

#include "unrealircd.h"

ModuleHeader MOD_HEADER
= {
	"authprompt",
	"1.0",
	"SASL authentication for clients that don't support SASL",
	"UnrealIRCd Team",
	"unrealircd-6",
};

/** Configuration settings */
struct {
	int enabled;
	MultiLine *message;
	MultiLine *fail_message;
	MultiLine *unconfirmed_message;
} cfg;

/** User struct */
typedef struct APUser APUser;
struct APUser {
	char *authmsg;
	char *reason;
};

/* Global variables */
ModDataInfo *authprompt_md = NULL;

/* Forward declarations */
static void free_config(void);
static void init_config(void);
static void config_postdefaults(void);
int authprompt_config_test(ConfigFile *, ConfigEntry *, int, int *);
int authprompt_config_run(ConfigFile *, ConfigEntry *, int);
int authprompt_sasl_continuation(Client *client, const char *buf);
int authprompt_sasl_result(Client *client, int success);
int authprompt_take_action(Client *client, BanActionValue action, const char *reason, long duration);
int authprompt_find_tkline_match(Client *client, TKL *tk);
int authprompt_pre_local_handshake_timeout(Client *client, const char **comment);
int authprompt_pre_connect(Client *client);
CMD_FUNC(cmd_auth);
void authprompt_md_free(ModData *md);

/* Some macros */
#define SetAPUser(x, y) do { moddata_local_client(x, authprompt_md).ptr = y; } while(0)
#define SEUSER(x)       ((APUser *)moddata_local_client(x, authprompt_md).ptr)
#define AGENT_SID(agent_p)      (agent_p->user != NULL ? agent_p->user->server : agent_p->name)

MOD_TEST()
{
	HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, authprompt_config_test);
	return MOD_SUCCESS;
}

MOD_INIT()
{
	ModDataInfo mreq;

	MARK_AS_OFFICIAL_MODULE(modinfo);

	memset(&mreq, 0, sizeof(mreq));
	mreq.name = "authprompt";
	mreq.type = MODDATATYPE_LOCAL_CLIENT;
	mreq.free = authprompt_md_free;
	authprompt_md = ModDataAdd(modinfo->handle, mreq);
	if (!authprompt_md)
	{
		config_error("could not register authprompt moddata");
		return MOD_FAILED;
	}

	init_config();
	HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, authprompt_config_run);
	HookAdd(modinfo->handle, HOOKTYPE_SASL_CONTINUATION, 0, authprompt_sasl_continuation);
	HookAdd(modinfo->handle, HOOKTYPE_SASL_RESULT, 0, authprompt_sasl_result);
	HookAdd(modinfo->handle, HOOKTYPE_TAKE_ACTION, 0, authprompt_take_action);
	HookAdd(modinfo->handle, HOOKTYPE_FIND_TKLINE_MATCH, 0, authprompt_find_tkline_match);
	HookAdd(modinfo->handle, HOOKTYPE_PRE_LOCAL_HANDSHAKE_TIMEOUT, 0, authprompt_pre_local_handshake_timeout);
	/* For HOOKTYPE_PRE_LOCAL_CONNECT we want a low priority, so we are called last.
	 * This gives hooks like the one from the blacklist module (pending softban)
	 * a chance to be handled first.
	 */
	HookAdd(modinfo->handle, HOOKTYPE_PRE_LOCAL_CONNECT, -1000000, authprompt_pre_connect);
	CommandAdd(modinfo->handle, "AUTH", cmd_auth, 1, CMD_UNREGISTERED);
	return MOD_SUCCESS;
}

MOD_LOAD()
{
	config_postdefaults();
	return MOD_SUCCESS;
}

MOD_UNLOAD()
{
	free_config();
	return MOD_SUCCESS;
}

static void init_config(void)
{
	/* This sets some default values */
	memset(&cfg, 0, sizeof(cfg));
	cfg.enabled = 1;
}

static void config_postdefaults(void)
{
	if (!cfg.message)
	{
		addmultiline(&cfg.message, "The server requires clients from this IP address to authenticate with a registered nickname and password.");
		addmultiline(&cfg.message, "Please reconnect using SASL, or authenticate now by typing: /QUOTE AUTH nick:password");
	}
	if (!cfg.fail_message)
	{
		addmultiline(&cfg.fail_message, "Authentication failed.");
	}
	if (!cfg.unconfirmed_message)
	{
		addmultiline(&cfg.unconfirmed_message, "You are trying to use an unconfirmed services account.");
		addmultiline(&cfg.unconfirmed_message, "This services account can only be used after it has been activated/confirmed.");
	}
}

static void free_config(void)
{
	freemultiline(cfg.message);
	freemultiline(cfg.fail_message);
	freemultiline(cfg.unconfirmed_message);
	memset(&cfg, 0, sizeof(cfg)); /* needed! */
}

int authprompt_config_test(ConfigFile *cf, ConfigEntry *ce, int type, int *errs)
{
	int errors = 0;
	ConfigEntry *cep;

	if (type != CONFIG_SET)
		return 0;

	/* We are only interrested in set::authentication-prompt... */
	if (!ce || !ce->name || strcmp(ce->name, "authentication-prompt"))
		return 0;

	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!cep->value)
		{
			config_error("%s:%i: set::authentication-prompt::%s with no value",
				cep->file->filename, cep->line_number, cep->name);
			errors++;
		} else
		if (!strcmp(cep->name, "enabled"))
		{
		} else
		if (!strcmp(cep->name, "message"))
		{
		} else
		if (!strcmp(cep->name, "fail-message"))
		{
		} else
		if (!strcmp(cep->name, "unconfirmed-message"))
		{
		} else
		{
			config_error("%s:%i: unknown directive set::authentication-prompt::%s",
				cep->file->filename, cep->line_number, cep->name);
			errors++;
		}
	}
	*errs = errors;
	return errors ? -1 : 1;
}

int authprompt_config_run(ConfigFile *cf, ConfigEntry *ce, int type)
{
	ConfigEntry *cep;

	if (type != CONFIG_SET)
		return 0;

	/* We are only interrested in set::authentication-prompt... */
	if (!ce || !ce->name || strcmp(ce->name, "authentication-prompt"))
		return 0;

	for (cep = ce->items; cep; cep = cep->next)
	{
		if (!strcmp(cep->name, "enabled"))
		{
			cfg.enabled = config_checkval(cep->value, CFG_YESNO);
		} else
		if (!strcmp(cep->name, "message"))
		{
			addmultiline(&cfg.message, cep->value);
		} else
		if (!strcmp(cep->name, "fail-message"))
		{
			addmultiline(&cfg.fail_message, cep->value);
		} else
		if (!strcmp(cep->name, "unconfirmed-message"))
		{
			addmultiline(&cfg.unconfirmed_message, cep->value);
		}
	}
	return 1;
}

void authprompt_md_free(ModData *md)
{
	APUser *se = md->ptr;

	if (se)
	{
		safe_free(se->authmsg);
		safe_free(se->reason);
		safe_free(se);
		md->ptr = se = NULL;
	}
}

/** Parse an authentication request from the user (form: <user>:<pass>).
 * @param str      The input string with the request.
 * @param username Pointer to the username string.
 * @param password Pointer to the password string.
 * @retval 1 if the format is correct, 0 if not.
 * @note The returned 'username' and 'password' are valid until next call to parse_nickpass().
 */
int parse_nickpass(const char *str, char **username, char **password)
{
	static char buf[250];
	char *p;

	strlcpy(buf, str, sizeof(buf));

	p = strchr(buf, ':');
	if (!p)
		return 0;

	*p++ = '\0';
	*username = buf;
	*password = p;

	if (!*username[0] || !*password[0])
		return 0;

	return 1;
}

char *make_authbuf(const char *username, const char *password)
{
	char inbuf[256];
	static char outbuf[512];
	int size;

	size = strlen(username) + 1 + strlen(username) + 1 + strlen(password);
	if (size >= sizeof(inbuf)-1)
		return NULL; /* too long */

	/* Because size limits are already checked above, we can cut some corners here: */
	memset(inbuf, 0, sizeof(inbuf));
	strcpy(inbuf, username);
	strcpy(inbuf+strlen(username)+1, username);
	strcpy(inbuf+strlen(username)+1+strlen(username)+1, password);
	/* ^ normal people use stpcpy here ;) */

	if (b64_encode(inbuf, size, outbuf, sizeof(outbuf)) < 0)
		return NULL; /* base64 encoding error */

	return outbuf;
}

/** Send first SASL authentication request (AUTHENTICATE PLAIN).
 * Among other things, this is used to discover the agent
 * which will later be used for this session.
 */
void send_first_auth(Client *client)
{
	Client *sasl_server;
	char *addr = BadPtr(client->ip) ? "0" : client->ip;
	const char *certfp = moddata_client_get(client, "certfp");

	sasl_server = find_client(SASL_SERVER, NULL);
	if (!sasl_server)
	{
		/* Services down. */
		return;
	}

	/* Make them a user, needed for CHGHOST etc that we may receive */
	if (!client->user)
		make_user(client);
	
	if (Hooks[HOOKTYPE_SASL_AUTHENTICATE] && (find_client(SASL_SERVER, NULL) == &me))
	{
		/* We are the SASL server (some module handling auth) */
		RunHook(HOOKTYPE_SASL_AUTHENTICATE, client, 1, "PLAIN");
		RunHook(HOOKTYPE_SASL_AUTHENTICATE, client, 0, SEUSER(client)->authmsg);
	} else {
		sendto_one(sasl_server, NULL, ":%s SASL %s %s H %s %s",
			me.id, SASL_SERVER, client->id, addr, addr);

		if (certfp)
			sendto_one(sasl_server, NULL, ":%s SASL %s %s S %s %s",
				me.id, SASL_SERVER, client->id, "PLAIN", certfp);
		else
			sendto_one(sasl_server, NULL, ":%s SASL %s %s S %s",
				me.id, SASL_SERVER, client->id, "PLAIN");
	}
	/* The rest is sent from authprompt_sasl_continuation() */

	client->local->sasl_out++;
}

CMD_FUNC(cmd_auth)
{
	char *username = NULL;
	char *password = NULL;
	char *authbuf;

	if (!SEUSER(client))
	{
		if (HasCapability(client, "sasl"))
			sendnotice(client, "ERROR: Cannot use /AUTH when your client is doing SASL.");
		else
			sendnotice(client, "ERROR: /AUTH authentication request received before authentication prompt (too early!)");
		return;
	}

	if ((parc < 2) || BadPtr(parv[1]) || !parse_nickpass(parv[1], &username, &password))
	{
		sendnotice(client, "ERROR: Syntax is: /AUTH <nickname>:<password>");
		sendnotice(client, "Example: /AUTH mynick:secretpass");
		return;
	}

	if (!SASL_SERVER)
	{
		sendnotice(client, "ERROR: SASL is not configured on this server, or services are down.");
		// numeric instead? SERVICESDOWN?
		return;
	}

	/* Presumably if the user is really fast, this could happen.. */
	if (*client->local->sasl_agent || SEUSER(client)->authmsg)
	{
		sendnotice(client, "ERROR: Previous authentication request is still in progress. Please wait.");
		return;
	}

	authbuf = make_authbuf(username, password);
	if (!authbuf)
	{
		sendnotice(client, "ERROR: Internal error. Oversized username/password?");
		return;
	}

	safe_strdup(SEUSER(client)->authmsg, authbuf);

	send_first_auth(client);
}

void authprompt_tag_as_auth_required(Client *client, const char *reason)
{
	/* Allocate, and therefore indicate, that we are going to handle SASL for this user */
	if (!SEUSER(client))
		SetAPUser(client, safe_alloc(sizeof(APUser)));
	safe_strdup(SEUSER(client)->reason, reason);
}

void authprompt_send_auth_required_message(Client *client)
{
	/* Send the standard-reply ACCOUNT_REQUIRED_TO_CONNECT if the client supports receiving it */
	if (HasCapability(client, "standard-replies"))
	{
		const char *reason = SEUSER(client) && SEUSER(client)->reason ? SEUSER(client)->reason : NULL;
		if (reason)
			sendto_one(client, NULL, "FAIL * ACCOUNT_REQUIRED_TO_CONNECT :An account is required to connect: %s", reason);
		else
			sendto_one(client, NULL, "FAIL * ACCOUNT_REQUIRED_TO_CONNECT :An account is required to connect");
	}

	/* Display set::authentication-prompt::message */
	sendnotice_multiline(client, cfg.message);
}

/* Called upon "place a host ban on this user" (eg: spamfilter, blacklist, ..) */
int authprompt_take_action(Client *client, BanActionValue action, const char *reason, long duration)
{
	/* If it's a soft-xx action and the user is not logged in
	 * and the user is not yet online, then we will handle this user.
	 */
	if (IsSoftBanAction(action) && !IsLoggedIn(client) && !IsUser(client) && cfg.enabled)
	{
		/* And tag the user */
		authprompt_tag_as_auth_required(client, reason);
		authprompt_send_auth_required_message(client);
		return 1; /* pretend user is killed */
	}
	return 99; /* no action taken, proceed normally */
}

/** Called upon "check for KLINE/GLINE" */
int authprompt_find_tkline_match(Client *client, TKL *tkl)
{
	/* If it's a soft-xx action and the user is not logged in
	 * and the user is not yet online, then we will handle this user.
	 */
	if (cfg.enabled &&
		TKLIsServerBan(tkl) &&
	   (tkl->ptr.serverban->subtype & TKL_SUBTYPE_SOFT) &&
	   !IsLoggedIn(client) &&
	   !IsUser(client))
	{
		/* And tag the user */
		authprompt_tag_as_auth_required(client, tkl->ptr.serverban->reason);
		authprompt_send_auth_required_message(client);
		return 1; /* pretend user is killed */
	}
	return 99; /* no action taken, proceed normally */
}

int authprompt_pre_connect(Client *client)
{
	/* If the user is tagged as auth required and not logged in, then.. */
	if (SEUSER(client) && !IsLoggedIn(client) && cfg.enabled)
	{
		authprompt_send_auth_required_message(client);
		return HOOK_DENY; /* do not process register_user() */
	}

	return HOOK_CONTINUE; /* no action taken, proceed normally */
}

int authprompt_sasl_continuation(Client *client, const char *buf)
{
	/* If it's not for us (eg: user is doing real SASL) then return 0. */
	if (!SEUSER(client) || !SEUSER(client)->authmsg)
		return 0;

	if (!strcmp(buf, "+"))
	{
		Client *agent = find_client(client->local->sasl_agent, NULL);
		if (agent)
		{
			sendto_one(agent, NULL, ":%s SASL %s %s C %s",
				me.id, AGENT_SID(agent), client->id, SEUSER(client)->authmsg);
		}
		safe_free(SEUSER(client)->authmsg);
	}
	return 1; /* inhibit displaying of message */
}

int authprompt_sasl_result(Client *client, int success)
{
	/* If it's not for us (eg: user is doing real SASL) then return 0. */
	if (!SEUSER(client))
		return 0;

	if (!success)
	{
		sendnotice_multiline(client, cfg.fail_message);
		return 1;
	}

	if (client->user && !IsLoggedIn(client))
	{
		sendnotice_multiline(client, cfg.unconfirmed_message);
		return 1;
	}

	/* Authentication was a success */
	if (is_handshake_finished(client))
	{
		register_user(client);
		/* User MAY be killed now. But since we 'return 1' below, it's safe */
	}

	return 1; /* inhibit success/failure message */
}

/** Override the default "Registration timeout" quit reason */
int authprompt_pre_local_handshake_timeout(Client *client, const char **comment)
{
	if (SEUSER(client))
	{
		if (SEUSER(client)->reason)
			*comment = SEUSER(client)->reason;
		else
			*comment = "Account required to connect";
	}

	return HOOK_CONTINUE;
}
