/* Access control based on cryptographically secure cookie,
 * checked against MySQL database 
 * 
 * Copyright 2003 QWK.Net, LLC - http://www.qwk.net/
 * Author: Mark Jason Dominus (mjd@plover.com)
 *
 * 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 2 of the License, 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.
 */

#define ERRTAG "auth_cookie_mysql: "

#define unless(c) if(!(c))

#include "httpd.h"
#include "http_config.h"
#include "http_core.h"
#include "http_log.h"

#include "ap_md5.h"             /* For  */
#include <time.h>               /* for time */
#include <string.h>             /* for strncmp */
#include <mysql.h>              /* for MySQL */

/*
 * Declarations of handlers 
 */

module MODULE_VAR_EXPORT mod_auth_cookie_mysql_module;
static int authenticate_user (request_rec *);
static int check_user_access (request_rec *);
static void *create_auth_dir_config(pool *, char *);

/*
 * Definitions of data structures 
 */


/* per-directory configuration information structure */
typedef struct s_mysql_cookie_auth_config_struct {
  char *CookieName;
  unsigned long ExpireTime;
  char MatchIP;                 /* boolean */
  char *DBName;
  char *DBHost;
  char *DBTable;
  char *DBUser;
  char *DBPassword;
  char *DBUsernameField;
  char *DBPasswordField;
  char *FailureURL;
} mysql_cookie_config_rec;

/* Default settings for per-directory configuration */

static mysql_cookie_config_rec default_config_rec = 
{ NULL,                         /* CookieName: required */
  0UL,                          /* ExpireTime: 0 means never expire */
  0,                            /* MatchIP: check IP address in cookie? */
  NULL,                         /* DBName: required */
  "localhost",                  /* DBHost */
  "auth",                       /* DBTable */
  NULL,                         /* DBUser: no username needed to access DB */
  NULL,                         /* DBPassword: no password needed ... */
  "user",                       /* DBUserNameField */
  "password",                   /* DBPasswordField */
  NULL,                         /* FailureURL: required */
  };

/* This structure holds the broken-out fields that we expect to find
   stored in the authenticator cookie */
typedef struct st_authcookie {
  char *user;                   /* A: username */
  char *IP;                     /* B: their IP address */
  long creation;                /* C: credential creation time */
  char *hash;                   /* D: MD5 of A, B, C, and password from DB */
} authcookie;

/* Declarations of private utility functions */

static unsigned get_password(request_rec *, 
                             authcookie *, 
                             mysql_cookie_config_rec *,
                             char *, 
                             unsigned *
                             );

static unsigned good_password(request_rec *, 
                              authcookie *, 
                              mysql_cookie_config_rec *
                              );

static unsigned break_cookie(pool *, 
                             authcookie *, 
                             const char *
                             );

static char *extract_cookie(request_rec *, 
                            const char *
                            );


static int do_redirect(request_rec *);
static void to_hex(unsigned char *, unsigned char *, unsigned);

/*
 * Definitions of handlers
 */



static int
check_user_access (request_rec *r) 
{
  mysql_cookie_config_rec *conf =
    ap_get_module_config(r->per_dir_config, &mod_auth_cookie_mysql_module);
  authcookie info;
  int m = r->method_number;
  int method_restricted = 0;
  const array_header *reqs_arr = ap_requires(r);
  require_line *reqs;
  unsigned i;
  char *cookie;

  ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, 
                ERRTAG "check_user_access");

  unless ((cookie = extract_cookie(r, conf->CookieName))
          && break_cookie(r->pool, &info, cookie))
    return DECLINED;

  ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, 
                ERRTAG "got cookie; user is %s", info.user);
  
  if (reqs_arr == NULL) {
    ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, 
                ERRTAG "no requirements");
  
    return OK;
  }
  reqs = (require_line *) reqs_arr->elts;

  for (i=0; i < reqs_arr->nelts; i++) {
    const char *requirement, *word;
    unless (reqs[i].method_mask & (1 << m))
      continue;

    method_restricted = 1;

    requirement = reqs[i].requirement;
    ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, 
                ERRTAG "requirement #%u is %s", i, requirement);
    word = ap_getword_white(r->pool, &requirement);
    if (strcmp(word, "valid-user") == 0) return OK;
    else if (strcmp(word, "user") == 0) {
      while (*requirement) {
        word = ap_getword_conf(r->pool, &requirement);
        if (strcmp(info.user, word) == 0) return OK;
      }
    } else {
      ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r, 
                    ERRTAG "ignoring unsupported 'require' line: 'require %s'", word);
    }
  }

  ap_log_rerror(APLOG_MARK, APLOG_NOTICE|APLOG_NOERRNO, r, 
                ERRTAG "user '%s' forbidden", info.user);
  return do_redirect(r);
}


static int
authenticate_user (request_rec *r) 
{
  mysql_cookie_config_rec *conf =
    ap_get_module_config(r->per_dir_config, &mod_auth_cookie_mysql_module);
  char *cookie;
  authcookie info;

  /* These fields are required.  Perhaps issue a failure message
   * if they are missing? */
  unless (conf->DBName) {
    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r, 
                  ERRTAG "No MySQL_Cookie_Auth_DBName specified");
    return DECLINED;
  }
  unless (conf->CookieName) {
    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r, 
                  ERRTAG "No MySQL_Cookie_Auth_CookieName specified");
    return DECLINED;
  }
  unless (conf->FailureURL) {
    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r, 
                  ERRTAG "No MySQL_Cookie_Auth_FailureURL specified");
    return DECLINED;
  }

  /* Extract our cookie value into 'cookie', if present, and url-unescape it */
  unless (cookie = extract_cookie(r, conf->CookieName))
    return do_redirect(r);

  /* Break apart the four field of the cookie into the 'info' structure */
  unless (break_cookie(r->pool, &info, cookie)) 
    return do_redirect(r);

  /* Check to see if credential has expired */
  if (conf->ExpireTime > 0) {
    time_t now = time(NULL);
    if (info.creation + conf->ExpireTime < now) {
      ap_log_rerror(APLOG_MARK, APLOG_NOTICE|APLOG_NOERRNO, r, 
                    ERRTAG "cookie for user %s expired", info.user);
      return do_redirect(r);
    }
  }

  /* Check that IP address matches, if enabled */
  if (conf->MatchIP) 
    if (strncmp(info.IP, r->connection->remote_ip, 4) != 0) {
      ap_log_rerror(APLOG_MARK, APLOG_WARNING|APLOG_NOERRNO, r, 
                    ERRTAG "ip address for user %s didn't match", info.user);
      return do_redirect(r);
    }
  
  /* Check password */
  unless (good_password(r, &info, conf)) {
    ap_log_rerror(APLOG_MARK, APLOG_WARNING|APLOG_NOERRNO, r, 
                  ERRTAG "password mismatch for user '%s'", info.user);
    return do_redirect(r);
  }

  return OK;
}

static int
do_redirect(request_rec *r) 
{
  mysql_cookie_config_rec *conf =
    ap_get_module_config(r->per_dir_config, &mod_auth_cookie_mysql_module);
  char *redirect = 
    ap_psprintf(r->pool, 
                "%s?return=%s&cookie=%s",
                conf->FailureURL, 
                r->uri,
                conf->CookieName
                );

  unless (redirect) 
    return HTTP_INTERNAL_SERVER_ERROR;

  ap_table_setn(r->headers_out, "Location", redirect);
  return REDIRECT;
}

/* 
 * Definitions of private utility functions
 */

/* Look through 'Cookie' header for indicated cookie; extract it
 * and URL-unescape it. Return the cookie on success, NULL on failure. */
static char *
extract_cookie(request_rec *r, const char *cookie_name) {
  const char *raw_cookie, *raw_cookie_end;
  char *cookie;

  unless (raw_cookie = ap_table_get(r->headers_in, "Cookie"))
    return 0;
  unless (raw_cookie = strstr(raw_cookie, cookie_name))
    return 0;
  unless (raw_cookie = strchr(raw_cookie, '='))
    return 0;
  raw_cookie++;
  unless ((raw_cookie_end = strchr(raw_cookie, ';'))
          || (raw_cookie_end = strchr(raw_cookie, '\0')))
    return 0;
  unless (cookie = ap_pstrndup(r->pool, raw_cookie, 
                               raw_cookie_end-raw_cookie))
    return 0;
  unless (ap_unescape_url(cookie) == 0)
    return 0;
  return cookie;
}
  
/* Convert cookie string into authcookie structure.
 * 
 * Format of cookie:
 *    IIII XXXX HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH USERNAME 
 * IIII is 4-byte IP address
 * XXXX is 4-byte time_t creation time
 * H..H is 32-byte MD5 checksum
 * USERNAME is variable-length username
 *
 * Return true on success, false on failure.
 */

static unsigned
break_cookie(pool *p, authcookie *info, const char *cookie) {
  char *creation;
  unless (info->IP   = ap_pstrndup(p, cookie +  0,  4)) return 0;
  unless (creation   = ap_pstrndup(p, cookie +  4,  4)) return 0;
  unless (info->hash = ap_pstrndup(p, cookie +  8, 32)) return 0;
  unless (info->user =  ap_pstrdup(p, cookie + 40)) return 0;

  info->creation = *(time_t *)creation;
  return 1;
}

/*
 * Check to see if the credential is good.
 * Extract the password from the database, compute the appropriate
 * checksum, and make sure it matches the checksum in the credential.
 */
#define MAX_PASSWORD_SIZE 128
unsigned
good_password(request_rec *r, authcookie *info, mysql_cookie_config_rec *conf) 
{
  char password[MAX_PASSWORD_SIZE];
  unsigned int password_length = MAX_PASSWORD_SIZE;
  AP_MD5_CTX md5;
  char digest[MD5_DIGESTSIZE];
  char hexdigest[MD5_DIGESTSIZE*2+1];
  
  /* Do the database lookup */
  unless (get_password(r, info, conf, password, &password_length)) return 0;
  
  ap_MD5Init(&md5);
  
  ap_MD5Update(&md5, info->user, strlen(info->user));
  ap_MD5Update(&md5, info->IP, 4);
  ap_MD5Update(&md5, (char *)&info->creation, 4);
  ap_MD5Update(&md5, password, password_length);
  ap_MD5Final(digest, &md5);

  to_hex(hexdigest, digest, MD5_DIGESTSIZE);

  return (strcmp(hexdigest, info->hash) == 0);
}

/*
 * Take a binary string in s and turn it into a hex string in h.
 * Only do the first n bytes of s.  h should point to a buffer with 
 * at least 2*n+1 bytes.
 */
void to_hex(unsigned char *h, unsigned char *s, unsigned n) 
{
  char *hexdigit = "0123456789abcdef";
  while (n) {
    *h++ = hexdigit[*s >> 4];
    *h++ = hexdigit[*s & 0xf];
    s++; n--;
  }
  *h = '\0';
}

 
/* Copy password from database into *password, length into *pw_length
 * 
 * On input, pw_length is the size of the buffer pointed to by
 * *password; on successful return pw_length has been changed to the
 * actual length of the password.
 *
 * It is permissible to pass a NULL buffer for 'password'; in this case
 * the input value of pw_length is ignored, but on successful return
 * it still contains the actual length of the password.
 *
 * Returns true on success, false on failure.
 */
static unsigned
get_password(request_rec *r, authcookie *info, mysql_cookie_config_rec *conf,
             char *password, unsigned *pw_length
             ) {
  MYSQL dbh;
  MYSQL_RES *res;
  MYSQL_ROW row;
  unsigned long *lengths;
  unsigned rv = 0;              /* return value */
  pool *p= r->pool;
  char *query;

  ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, ERRTAG "get_password()...");

  mysql_init(&dbh);
  unless (mysql_real_connect(&dbh,
                             conf->DBHost, 
                             conf->DBUser, conf->DBPassword, 
                             conf->DBName,
                             0, NULL, 0)) {

    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r,
                  ERRTAG "couldn't connect to database: %s",
                  mysql_error(&dbh));
    return 0;
  }

  /* Assemble query */
  {
    char *esc_user;
    unsigned ulen = strlen(info->user);
    unless (esc_user = ap_palloc(p, ulen*2+1)) return 0;
    mysql_escape_string(esc_user, info->user, ulen);
    unless (query = ap_psprintf(p, 
                                "SELECT %s FROM %s WHERE %s = '%s'",
                                conf->DBPasswordField,
                                conf->DBTable,
                                conf->DBUsernameField,
                                esc_user)) return 0;
    ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r,
                  ERRTAG "  query: %s",
                  query);
  }

  /* Issue query */
  unless (mysql_query(&dbh, query) == 0) {
    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r,
                  ERRTAG "couldn't issue database query: %s",
                  mysql_error(&dbh));
    return 0;
  }

  unless (res = mysql_store_result(&dbh)) {
    ap_log_rerror(APLOG_MARK, APLOG_ERR|APLOG_NOERRNO, r,
                  ERRTAG "couldn't store query result: %s",
                  mysql_error(&dbh));
    return 0;
  }


  /* Make sure retrieved row exists and is unique */
  {
    my_ulonglong n_rows = mysql_num_rows(res);
    if (n_rows != 1) {
      ap_log_rerror(APLOG_MARK, APLOG_WARNING|APLOG_NOERRNO, r,
                    ERRTAG "%ld rows returned", n_rows);
      goto Out1;
    }
  }

  unless (row = mysql_fetch_row(res)) goto Out1;
  lengths = mysql_fetch_lengths(res);
  unless (row[0]) goto Out1;    /* password is NULL */
  if (password) {
    if (*lengths > *pw_length) goto Out1; /* not enough space in buffer */
    strncpy(password, row[0], *lengths);
  }
  *pw_length = *lengths;
  rv = 1;

  ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r,
                ERRTAG "password is '%s' with length '%u'",
                row[0], *pw_length);

 Out1:
  mysql_free_result(res);
  ap_log_rerror(APLOG_MARK, APLOG_INFO|APLOG_NOERRNO, r, 
                ERRTAG "returning %u from get_password()", rv);
  mysql_close(&dbh);
  return rv;
}


/* Declarations of routines to manipulate the module's configuration
 * info.  Note that these are returned, and passed in, as void *'s;
 * the server core keeps track of them, but it doesn't, and can't,
 * know their internal structure.
 */

static void *create_auth_dir_config(pool *p, char *d)
{
    mysql_cookie_config_rec *cr =
    (mysql_cookie_config_rec *) ap_pcalloc(p, sizeof(mysql_cookie_config_rec));

    *cr = default_config_rec;
    return ((void *) cr);
}

/* Declarations of routines to handle config-file commands */

/* 'value' is the actual value of the option. 
 * The offset is in cmd->info 
 * 'config' is the config structure to install into
 * I don't know what 'args' is for 
 */
static const char *set_conf_slot(cmd_parms *cmd, 
                                 mysql_cookie_config_rec *config, 
                                 char *value, char *args)
{
  unsigned offset = (unsigned) (cmd->info);
  *(char **)(((char *)(config))+offset) = value; /* Wow! */
  return NULL;
}

#define WHERE_ALLOWED (ACCESS_CONF|OR_AUTHCFG)

static const command_rec auth_commands[] = {

  {"MySQL_Cookie_Auth_CookieName", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, CookieName),
   WHERE_ALLOWED, TAKE1,
   "Name of cookie to set"},

  {"MySQL_Cookie_Auth_ExpireTime", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, ExpireTime), WHERE_ALLOWED, TAKE1,
   "Lifetime of set cookies in seconds (0 means cookies live forever)"},

  {"MySQL_Cookie_Auth_MatchIP",    set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, MatchIP), WHERE_ALLOWED, FLAG,
   "Check IP address in cookie?"},

  {"MySQL_Cookie_Auth_DBName", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBName), WHERE_ALLOWED, TAKE1,
   "Name of database to search"},

  {"MySQL_Cookie_Auth_DBHost", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBHost), WHERE_ALLOWED, TAKE1,
   "Host on which DB server resides"},

  {"MySQL_Cookie_Auth_DBTable", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBTable), WHERE_ALLOWED, TAKE1,
   "Table to consult"},

  {"MySQL_Cookie_Auth_DBUser", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBUser), WHERE_ALLOWED, TAKE1,
   "Username for DB access"},

  {"MySQL_Cookie_Auth_DBPassword", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBPassword), WHERE_ALLOWED, TAKE1,
   "Password for DB access"},

  {"MySQL_Cookie_Auth_DBUsernameField", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBUsernameField), WHERE_ALLOWED, TAKE1,
   "Name of field to consult for username"},

  {"MySQL_Cookie_Auth_DBPasswordField", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, DBPasswordField), WHERE_ALLOWED, TAKE1,
   "Name of field to consult for cookie"},

  {"MySQL_Cookie_Auth_FailureURL", set_conf_slot, 
   (void *) XtOffsetOf(mysql_cookie_config_rec, FailureURL), WHERE_ALLOWED, TAKE1,
   "URL of authentication script"},

  { NULL }
};


module MODULE_VAR_EXPORT mod_auth_cookie_mysql_module =
{
    STANDARD_MODULE_STUFF,
    NULL,                       /* initializer */
    create_auth_dir_config,     /* dir config creater */
    NULL,                       /* dir merger --- default is to override */
    NULL,                       /* server config */
    NULL,                       /* merge server config */
    auth_commands,              /* command table */
    NULL,                       /* handlers */
    NULL,                       /* filename translation */
    authenticate_user,          /* check cookie against database */
    check_user_access,          /* check auth */
    NULL,                       /* check access */
    NULL,                       /* type_checker */
    NULL,                       /* fixups */
    NULL,                       /* logger */
    NULL,                       /* header parser */
    NULL,                       /* child_init */
    NULL,                       /* child_exit */
    NULL                        /* post read-request */
};
