Use SQLite3 based authentication

The user data is now stored inside a SQLite3 database. By storing the data
inside a database, we remove the restriction that the previous hashtable
based implementation had.
This commit is contained in:
Markus Mäkelä
2017-01-28 12:58:24 +02:00
parent d4a06c61de
commit 041c0f1f2d
6 changed files with 378 additions and 27 deletions

View File

@ -25,3 +25,6 @@
#if SQLITE_VERSION_NUMBER < 3007014
#define sqlite3_close_v2 sqlite3_close
#endif
/** Default timeout is one minute */
#define MXS_SQLITE_BUSY_TIMEOUT 60000

View File

@ -20,11 +20,9 @@
#include <maxscale/protocol/mysql.h>
#include <maxscale/secrets.h>
#include <maxscale/mysql_utils.h>
#include <maxscale/sqlite3.h>
#include "../gssapi_auth.h"
/** Default timeout is one minute */
#define MXS_SQLITE_BUSY_TIMEOUT 60000
/**
* MySQL queries for retrieving the list of users
*/

View File

@ -1,4 +1,4 @@
add_library(MySQLAuth SHARED mysql_auth.c dbusers.c)
target_link_libraries(MySQLAuth maxscale-common MySQLCommon)
target_link_libraries(MySQLAuth maxscale-common MySQLCommon sqlite3)
set_target_properties(MySQLAuth PROPERTIES VERSION "1.0.0")
install_module(MySQLAuth core)

View File

@ -40,6 +40,7 @@
#include <stdio.h>
#include <ctype.h>
#include <mysql.h>
#include <netdb.h>
#include <maxscale/dcb.h>
#include <maxscale/service.h>
@ -121,6 +122,15 @@
MaxScale authentication will proceed without including database permissions. \
See earlier error messages for user '%s' for more information."
#define NEW_LOAD_DBUSERS_QUERY "SELECT u.user, u.host, d.db, u.select_priv, u.%s \
FROM mysql.user AS u LEFT JOIN mysql.db AS d \
ON (u.user = d.user AND u.host = d.host) %s \
UNION \
SELECT u.user, u.host, t.db, u.select_priv, u.%s \
FROM mysql.user AS u LEFT JOIN mysql.tables_priv AS t \
ON (u.user = t.user AND u.host = t.host) %s\
ORDER BY user"
static int add_databases(SERV_LISTENER *listener, MYSQL *con);
static int add_wildcard_users(USERS *users, char* name, char* host,
char* password, char* anydb, char* db, HASHTABLE* hash);
@ -215,6 +225,22 @@ static char* get_usercount_query(const char* server_version, bool include_root,
return buffer;
}
static char* get_new_users_query(const char *server_version, bool include_root)
{
const char* password = strstr(server_version, "5.7.") ? MYSQL57_PASSWORD : MYSQL_PASSWORD;
const char *with_root = include_root ? "user.user NOT IN ('root')" : "";
size_t n_bytes = snprintf(NULL, 0, NEW_LOAD_DBUSERS_QUERY, password, with_root, password, with_root);
char *rval = MXS_MALLOC(n_bytes + 1);
if (rval)
{
snprintf(rval, n_bytes + 1, NEW_LOAD_DBUSERS_QUERY, password, with_root, password, with_root);
}
return rval;
}
/**
* Check if the IP address of the user matches the one in the grant. This assumes
* that the grant has one or more single-character wildcards in it.
@ -1172,6 +1198,207 @@ cleanup:
return total_users;
}
static bool check_password(const char *output,
uint8_t *token, size_t token_len,
uint8_t *scramble, size_t scramble_len)
{
uint8_t stored_token[SHA_DIGEST_LENGTH] = {};
size_t stored_token_len = sizeof(stored_token);
if (*output)
{
/** Convert the hexadecimal string to binary */
gw_hex2bin(stored_token, output, strlen(output));
}
/**
* The client authentication token is made up of:
*
* XOR( SHA1(real_password), SHA1( CONCAT( scramble, <value of mysql.user.password> ) ) )
*
* Since we know the scramble and the value stored in mysql.user.password,
* we can extract the SHA1 of the real password by doing a XOR of the client
* authentication token with the SHA1 of the scramble concatenated with the
* value of mysql.user.password.
*
* Once we have the SHA1 of the original password, we can create the SHA1
* of this hash and compare the value with the one stored in the backend
* database. If the values match, the user has sent the right password.
*/
/** First, calculate the SHA1 of the scramble and the hash stored in the database */
uint8_t step1[SHA_DIGEST_LENGTH];
gw_sha1_2_str(scramble, scramble_len, stored_token, stored_token_len, step1);
/** Next, extract the SHA1 of the real password by XOR'ing it with
* the output of the previous calculation */
uint8_t step2[SHA_DIGEST_LENGTH];
gw_str_xor(step2, token, step1, token_len);
/** Finally, calculate the SHA1 of the hashed real password */
uint8_t final_step[SHA_DIGEST_LENGTH];
gw_sha1_str(step2, SHA_DIGEST_LENGTH, final_step);
/** If the two values match, the client has sent the correct password */
return memcmp(final_step, stored_token, stored_token_len) == 0;
}
/** Used to detect empty result sets */
struct user_query_result
{
bool ok;
char output[SHA_DIGEST_LENGTH * 2 + 1];
};
/** @brief Callback for sqlite3_exec() */
static int auth_cb(void *data, int columns, char** rows, char** row_names)
{
struct user_query_result *res = (struct user_query_result*)data;
strcpy(res->output, rows[0] ? rows[0] : "");
res->ok = true;
return 0;
}
/**
* @brief Verify the user has access to the database
*
* @param auth Authenticator session
* @param dcb Client DCB
* @param session MySQL session
* @param pw Client password
*
* @return True if the user has access to the database
*/
bool validate_mysql_user(sqlite3 *handle, DCB *dcb, MYSQL_session *session)
{
size_t len = sizeof(mysqlauth_validation_query) + strlen(session->user) * 2 +
strlen(session->db) * 2 + MYSQL_HOST_MAXLEN + session->auth_token_len * 4 + 1;
char sql[len + 1];
bool rval = false;
char *err;
/**
* Try authentication twice; first time with the current users, second
* time with fresh users
*/
for (int i = 0; i < 2 && !rval; i++)
{
sprintf(sql, mysqlauth_validation_query, session->user, dcb->remote,
session->db, session->db);
struct user_query_result res = {};
if (sqlite3_exec(handle, sql, auth_cb, &res, &err) != SQLITE_OK)
{
MXS_ERROR("Failed to execute auth query: %s", err);
sqlite3_free(err);
rval = false;
}
if (!res.ok)
{
/** Try authentication with the hostname */
char client_hostname[MYSQL_HOST_MAXLEN];
wildcard_domain_match(dcb->remote, client_hostname);
sprintf(sql, mysqlauth_validation_query, session->user, client_hostname,
session->db, session->db);
if (sqlite3_exec(handle, sql, auth_cb, &res, &err) != SQLITE_OK)
{
MXS_ERROR("Failed to execute auth query: %s", err);
sqlite3_free(err);
rval = false;
}
}
if (res.ok)
{
/** Found a matching row */
MySQLProtocol *proto = (MySQLProtocol*)dcb->protocol;
rval = check_password(res.output, session->auth_token, session->auth_token_len,
proto->scramble, sizeof(proto->scramble));
}
if (!rval && i == 0)
{
service_refresh_users(dcb->service);
}
}
return rval;
}
/**
* @brief Delete all users
*
* @param handle SQLite handle
*/
static void delete_mysql_users(sqlite3 *handle)
{
char *err;
if (sqlite3_exec(handle, delete_query, NULL, NULL, &err) != SQLITE_OK)
{
MXS_ERROR("Failed to delete old users: %s", err);
sqlite3_free(err);
}
}
/**
* @brief Add new MySQL user to the internal user database
*
* @param handle Database handle
* @param user Username
* @param host Host
* @param db Database
* @param anydb Global access to databases
*/
static void add_mysql_user(sqlite3 *handle, const char *user, const char *host,
const char *db, bool anydb, const char *pw)
{
size_t dblen = db && *db ? strlen(db) + 2 : sizeof(null_token); /** +2 for single quotes */
char dbstr[dblen + 1];
if (db && *db)
{
sprintf(dbstr, "'%s'", db);
}
else
{
strcpy(dbstr, null_token);
}
size_t pwlen = pw && *pw ? strlen(pw) + 2 : sizeof(null_token); /** +2 for single quotes */
char pwstr[pwlen + 1];
if (pw && *pw)
{
if (*pw == '*')
{
pw++;
}
sprintf(pwstr, "'%s'", pw);
}
else
{
strcpy(pwstr, null_token);
}
size_t len = sizeof(insert_sql_pattern) + strlen(user) + strlen(host) + dblen + pwlen + 1;
char insert_sql[len + 1];
sprintf(insert_sql, insert_sql_pattern, user, host, dbstr, anydb ? "1" : "0", pwstr);
char *err;
if (sqlite3_exec(handle, insert_sql, NULL, NULL, &err) != SQLITE_OK)
{
MXS_ERROR("Failed to insert user: %s", err);
sqlite3_free(err);
}
MXS_INFO("Added user: %s", insert_sql);
}
/**
* Load the user/passwd form mysql.user table into the service users' hashtable
* environment.
@ -1618,6 +1845,37 @@ get_users(SERV_LISTENER *listener, USERS *users)
MXS_FREE(users_data);
mysql_free_result(result);
/** Testing new users query */
char *query = get_new_users_query(server->server->server_string, service->enable_root);
if (query)
{
if (mysql_query(con, query) == 0)
{
MYSQL_AUTH *instance = (MYSQL_AUTH*)listener->auth_instance;
delete_mysql_users(instance->handle);
if ((result = mysql_store_result(con)))
{
while ((row = mysql_fetch_row(result)))
{
add_mysql_user(instance->handle, row[0], row[1], row[2],
row[3] && strcmp(row[3], "Y") == 0,
row[4]);
}
mysql_free_result(result);
}
}
else
{
MXS_ERROR("Failed to load users: %s", mysql_error(con));
}
MXS_FREE(query);
}
mysql_close(con);
return total_users;
@ -2753,3 +3011,53 @@ static void merge_netmask(char *host)
"Merge incomplete: %s", host);
}
}
/**
* @brief Check if an ip matches a wildcard hostname.
*
* One of the parameters should be an IP-address without wildcards, the other a
* hostname with wildcards. The hostname corresponding to the ip-address will be
* looked up and compared to the hostname with wildcard(s). Any error in the
* parameters or looking up the hostname will result in a false match.
*
* @param ip-address or a hostname with wildcard(s)
* @param ip-address or a hostname with wildcard(s)
* @return True if the host represented by the IP matches the wildcard string
*/
static bool wildcard_domain_match(const char *ip_address, char *client_hostname)
{
/* Looks like the parameters are valid. First, convert the client IP string
* to binary form. This is somewhat silly, since just a while ago we had the
* binary address but had to zero it. dbusers.c should be refactored to fix this.
*/
struct sockaddr_in bin_address;
bin_address.sin_family = AF_INET;
if (inet_pton(bin_address.sin_family, ip_address, &(bin_address.sin_addr)) != 1)
{
MXS_ERROR("Could not convert to binary ip-address: '%s'.", ip_address);
return false;
}
/* Try to lookup the domain name of the given IP-address. This is a slow
* i/o-operation, which will stall the entire thread. TODO: cache results
* if this feature is used often.
*/
MXS_DEBUG("Resolving '%s'", ip_address);
int lookup_result = getnameinfo((struct sockaddr*)&bin_address,
sizeof(struct sockaddr_in),
client_hostname, sizeof(client_hostname),
NULL, 0, // No need for the port
NI_NAMEREQD); // Text address only
if (lookup_result != 0)
{
MXS_ERROR("Client hostname lookup failed, getnameinfo() returned: '%s'.",
gai_strerror(lookup_result));
}
else
{
MXS_DEBUG("IP-lookup success, hostname is: '%s'", client_hostname);
}
return false;
}

View File

@ -41,6 +41,8 @@ static bool mysql_auth_is_client_ssl_capable(DCB *dcb);
static int mysql_auth_authenticate(DCB *dcb);
static void mysql_auth_free_client_data(DCB *dcb);
static int mysql_auth_load_users(SERV_LISTENER *port);
static void *mysql_auth_create(void *instance);
static void mysql_auth_destroy(void *data);
static int combined_auth_check(
DCB *dcb,
@ -72,12 +74,12 @@ MXS_MODULE* MXS_CREATE_MODULE()
static MXS_AUTHENTICATOR MyObject =
{
mysql_auth_init, /* Initialize the authenticator */
NULL, /* No create entry point */
mysql_auth_create, /* Create entry point */
mysql_auth_set_protocol_data, /* Extract data into structure */
mysql_auth_is_client_ssl_capable, /* Check if client supports SSL */
mysql_auth_authenticate, /* Authenticate user credentials */
mysql_auth_free_client_data, /* Free the client data held in DCB */
NULL, /* No destroy entry point */
mysql_auth_destroy, /* Destroy entry point */
mysql_auth_load_users, /* Load users from backend databases */
mysql_auth_reauthenticate /* Handle COM_CHANGE_USER */
};
@ -117,6 +119,24 @@ static void* mysql_auth_init(char **options)
instance->inject_service_user = true;
instance->skip_auth = false;
if (sqlite3_open_v2(MYSQLAUTH_DATABASE_NAME, &instance->handle, db_flags, NULL) != SQLITE_OK)
{
MXS_ERROR("Failed to open SQLite3 handle.");
MXS_FREE(instance);
return NULL;
}
char *err;
if (sqlite3_exec(instance->handle, create_sql, NULL, NULL, &err) != SQLITE_OK)
{
MXS_ERROR("Failed to create database: %s", err);
sqlite3_free(err);
sqlite3_close_v2(instance->handle);
MXS_FREE(instance);
return NULL;
}
for (int i = 0; options[i]; i++)
{
char *value = strchr(options[i], '=');
@ -165,6 +185,37 @@ static void* mysql_auth_init(char **options)
return instance;
}
static void* mysql_auth_create(void *instance)
{
mysql_auth_t *rval = MXS_MALLOC(sizeof(*rval));
if (rval)
{
if (sqlite3_open_v2(MYSQLAUTH_DATABASE_NAME, &rval->handle, db_flags, NULL) == SQLITE_OK)
{
sqlite3_busy_timeout(rval->handle, MXS_SQLITE_BUSY_TIMEOUT);
}
else
{
MXS_ERROR("Failed to open SQLite3 handle.");
MXS_FREE(rval);
rval = NULL;
}
}
return rval;
}
static void mysql_auth_destroy(void *data)
{
mysql_auth_t *auth = (mysql_auth_t*)data;
if (auth)
{
sqlite3_close_v2(auth->handle);
MXS_FREE(auth);
}
}
/**
* @brief Authenticates a MySQL user who is a client to MaxScale.
*
@ -183,7 +234,7 @@ mysql_auth_authenticate(DCB *dcb)
{
MySQLProtocol *protocol = DCB_PROTOCOL(dcb, MySQLProtocol);
MYSQL_session *client_data = (MYSQL_session *)dcb->data;
int auth_ret;
int auth_ret = MXS_AUTH_FAILED;
/**
* We record the SSL status before and after the authentication. This allows
@ -199,24 +250,16 @@ mysql_auth_authenticate(DCB *dcb)
{
auth_ret = (SSL_ERROR_CLIENT_NOT_SSL == ssl_ret) ? MXS_AUTH_FAILED_SSL : MXS_AUTH_FAILED;
}
else if (!health_after)
{
auth_ret = MXS_AUTH_SSL_INCOMPLETE;
}
else if (!health_before && health_after)
{
auth_ret = MXS_AUTH_SSL_INCOMPLETE;
poll_add_epollin_event_to_dcb(dcb, NULL);
}
else if (0 == strlen(client_data->user))
{
auth_ret = MXS_AUTH_FAILED;
}
else
else if (*client_data->user)
{
MXS_DEBUG("Receiving connection from '%s' to database '%s'.",
client_data->user, client_data->db);
@ -226,17 +269,15 @@ mysql_auth_authenticate(DCB *dcb)
MYSQL_AUTH *instance = (MYSQL_AUTH*)dcb->listener->auth_instance;
/* On failed authentication try to load user table from backend database */
/* Success for service_refresh_users returns 0 */
if (MXS_AUTH_SUCCEEDED != auth_ret && !instance->skip_auth &&
0 == service_refresh_users(dcb->service))
bool is_ok = validate_mysql_user(instance->handle, dcb, client_data);
if (!is_ok && !instance->skip_auth && service_refresh_users(dcb->service) == 0)
{
auth_ret = combined_auth_check(dcb, client_data->auth_token, client_data->auth_token_len, protocol,
client_data->user, client_data->client_sha1, client_data->db);
is_ok = validate_mysql_user(instance->handle, dcb, client_data);
}
/* on successful authentication, set user into dcb field */
if (MXS_AUTH_SUCCEEDED == auth_ret || instance->skip_auth)
if (is_ok || instance->skip_auth)
{
auth_ret = MXS_AUTH_SUCCEEDED;
dcb->user = MXS_STRDUP_A(client_data->user);

View File

@ -41,7 +41,7 @@ MXS_BEGIN_DECLS
static const char DBUSERS_DIR[] = "cache";
static const char DBUSERS_FILE[] = "dbusers";
#define MYSQLAUTH_DATABASE_NAME "file:mysqlauth.db"
#define MYSQLAUTH_DATABASE_NAME "file:mysqlauth.db?mode=memory&cache=shared"
/** The table name where we store the users */
#define MYSQLAUTH_TABLE_NAME "mysqlauth_users"
@ -52,10 +52,10 @@ static const char create_sql[] =
"(user varchar(255), host varchar(255), db varchar(255), anydb boolean, password text)";
/** The query that is executed when a user is authenticated */
static const char mysqlauth_auth_query[] =
"SELECT * FROM " MYSQLAUTH_TABLE_NAME
static const char mysqlauth_validation_query[] =
"SELECT password FROM " MYSQLAUTH_TABLE_NAME
" WHERE user = '%s' AND '%s' LIKE host AND (anydb = '1' OR '%s' = '' OR '%s' LIKE db)"
" AND ('%s' = '%s') LIMIT 1";
" LIMIT 1";
/** Delete query used to clean up the database before loading new users */
static const char delete_query[] = "DELETE FROM " MYSQLAUTH_TABLE_NAME;
@ -121,5 +121,6 @@ int gw_find_mysql_user_password_sha1(
const char *username,
uint8_t *gateway_password,
DCB *dcb);
bool validate_mysql_user(sqlite3 *handle, DCB *dcb, MYSQL_session *session);
MXS_END_DECLS