diff --git a/Makefile.am b/Makefile.am index 6be2379..8649394 100644 --- a/Makefile.am +++ b/Makefile.am @@ -29,7 +29,13 @@ google_authenticator_SOURCES = \ pam_google_authenticator_la_SOURCES = \ src/pam_google_authenticator.c \ + src/pam_util.c \ $(CORE_SRC) +if ENABLE_AUTHY +pam_google_authenticator_la_SOURCES += src/pam_authy.c +else +pam_google_authenticator_la_SOURCES += src/pam_no_authy.c +endif pam_google_authenticator_la_LIBADD = -lpam pam_google_authenticator_la_CFLAGS = $(AM_CFLAGS) pam_google_authenticator_la_LDFLAGS = $(AM_LDFLAGS) $(MODULES_LDFLAGS) -export-symbols-regex "pam_sm_(setcred|open_session|authenticate)" diff --git a/README.authy.md b/README.authy.md new file mode 100644 index 0000000..d51546c --- /dev/null +++ b/README.authy.md @@ -0,0 +1,65 @@ +# Authy push authentication for Google Authenticator PAM module +This extension uses Authy (https://authy.com/) to authenticate +using push notifications. + +## Build, install, setup +See https://github.com/google/google-authenticator-libpam + +Authy extension requires the following extra dependencies: +- libjansson +- libcurl + +If running Debian-based distro, do: +``` +sudo apt install libjansson-dev libcurl4-gnutls-dev +``` + +## Extra PAM module options +### enable_authy +If set, Authy extension is enabled. PAM module sends push notification +to the Authy authenticator on user login. If authentication passes, +login is granted. On failure, the classic Google OTP is used. + +## Extra .google_authenticator fields +To setup Authy authentication, generate .google_authenticator file as described +in https://github.com/google/google-authenticator-libpam and add the following +extra fields. + +### AUTHY_ID +See https://support.authy.com/hc/en-us/articles/360016449054-Find-your-Authy-ID +how to find out your Authy ID. + +Example: +``` +" AUTHY_ID 123456789" +``` +### AUTHY_API_KEY +See: https://www.twilio.com/docs/authy/twilioauth-sdk/quickstart/obtain-authy-api-key + +Obtaining an Authy API Key: +1. Create a Twilio account: https://www.twilio.com/try-twilio +2. Create an Authy application in the Twilio Console. +3. Once you've created a new Authy application, copy the API Key for +Production available in the Settings page of your Authy application. + +Example: +``` +" AUTHY_API_KEY aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpP +``` + +### .google_authenticator example +``` +00000000000000000000000000 +" RESETTING_TIME_SKEW 52968321+21 52968328+21 52968508+20 +" RATE_LIMIT 3 30 1589055237 +" WINDOW_SIZE 17 +" TOTP_AUTH +" AUTHY_ID 000000000 +" AUTHY_API_KEY xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +11111111 +22222222 +33333333 +" +``` + +### TODO: add Authy fields generation in google-authenticator tool diff --git a/configure.ac b/configure.ac index f88f863..95d3753 100644 --- a/configure.ac +++ b/configure.ac @@ -71,7 +71,26 @@ AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[ AC_LANG_POP(C) +AC_CHECK_LIB([curl], [curl_global_init], [have_curl=yes]) +AC_CHECK_LIB([jansson], [json_loads], [have_jansson=yes]) +AC_ARG_ENABLE([authy], + [AC_HELP_STRING([--disable-authy], + [enable Authy push authentcation [default=auto]])], + [:], [enable_authy=check]) + +AS_IF([test "x${enable_authy}" = "xcheck"], [ + AS_IF([test "x${have_jansson}" = "xyes" && test "x${have_curl}" = "xyes"], + [enable_authy=yes], [enable_authy=no]) +]) + +AM_CONDITIONAL([ENABLE_AUTHY], [test x${enable_authy} = xyes]) +AS_IF([test "x${enable_authy}" = "xyes"], [ + AS_IF([test "x${have_jansson}" != "xyes"], [AC_MSG_ERROR([libjannson is missing])]) + AS_IF([test "x${have_curl}" != "xyes"], [AC_MSG_ERROR([libcurl is missing])]) + AC_DEFINE(ENABLE_AUTHY, [1], [Define to 1 to enable Authy extension.]) + LIBS="$LIBS -ljansson -lcurl" +]) AC_SEARCH_LIBS([dlopen], [dl]) @@ -81,6 +100,7 @@ AC_OUTPUT echo " $PACKAGE_NAME version $PACKAGE_VERSION + Authy..........: $enable_authy Prefix.........: $prefix Debug Build....: $debug C Compiler.....: $CC $CFLAGS $CPPFLAGS diff --git a/src/pam_authy.c b/src/pam_authy.c new file mode 100644 index 0000000..ceca6b6 --- /dev/null +++ b/src/pam_authy.c @@ -0,0 +1,266 @@ +/* + * Google authenticator extension for Authy push notifications + * + * Copyright 2020 Krzysztof Olejarczyk + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#define _GNU_SOURCE + +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "pam_util.h" +#include "pam_authy.h" + +typedef struct { + char *memory; + size_t size; +} mblock_t; + +static size_t ctrl_curl_receive(void *content, size_t size, size_t nmemb, + void *user_mem) +{ + size_t realsize = size * nmemb; + mblock_t *mem = (mblock_t *)user_mem; + + mem->memory = realloc(mem->memory, mem->size + realsize + 1); + if (mem->memory == NULL) { + return -ENOMEM; + } + + memcpy(&(mem->memory[mem->size]), content, realsize); + mem->size += realsize; + mem->memory[mem->size] = 0; + + return realsize; +} + +static authy_rc_t authy_check_approval(pam_handle_t *pamh, char *api_key, char *uuid) +{ + json_t *payload = NULL, *jt = NULL; + mblock_t buffer = {0}; + authy_rc_t rc; + + CURL *curl = curl_easy_init(); + if (!curl) { + log_message(LOG_ERR, pamh, "authy_err: curl init failed"); + rc = AUTHY_LIB_ERROR; + goto exit_err; + } + + char url[120]; + snprintf(url, sizeof(url), + "https://api.authy.com/onetouch/json/approval_requests/%s", + uuid); + + char xheader[64]; + snprintf(xheader, sizeof(xheader), "X-Authy-API-Key: %s", api_key); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, xheader); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, ctrl_curl_receive); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&buffer); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res; + res = curl_easy_perform(curl); + if (res != CURLE_OK) { + log_message(LOG_ERR, pamh, "authy_err: curl call failed: %d (%s)", + res, curl_easy_strerror(res)); + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + rc = AUTHY_OK; + + payload = json_loads(buffer.memory, JSON_DECODE_ANY, NULL); + jt = json_object_get(payload, "approval_request"); + if (!jt) { + log_message(LOG_ERR, pamh, "authy_err: 'approval_request' field missing"); + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + + char *str = (char *)json_string_value(json_object_get(jt, "status")); + if (!str) { + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + + if (!strcmp(str, "pending")) { + rc = AUTHY_PENDING; + } else if (!strcmp(str, "expired")) { + rc = AUTHY_EXPIRED; + } else if (!strcmp(str, "denied")) { + rc = AUTHY_DENIED; + } else if (!strcmp(str, "approved")) { + rc = AUTHY_APPROVED; + } + +exit_err: + free(buffer.memory); + free(jt); + free(payload); + + if (curl) + curl_easy_cleanup(curl); + + return rc; +} + +static authy_rc_t authy_post_approval(pam_handle_t *pamh, long authy_id, char *api_key, int timeout, char **uuid) +{ + json_t *payload = NULL, *jt = NULL; + mblock_t buffer = {0}; + authy_rc_t rc; + + CURL *curl = curl_easy_init(); + if (!curl) { + log_message(LOG_ERR, pamh, "authy_err: curl init failed"); + rc = AUTHY_LIB_ERROR; + goto exit_err; + } + + const char *username; + if (pam_get_user(pamh, &username, NULL) != PAM_SUCCESS || + !username || + !*username) { + log_message(LOG_ERR, pamh, "pam_get_user() failed to get a user name"); + rc = AUTHY_LIB_ERROR; + goto exit_err; + } + + char hostname[128] = {0}; + if (gethostname(hostname, sizeof(hostname)-1)) { + strcpy(hostname, "unix"); + } + + char url[80]; + snprintf(url, sizeof(url), + "https://api.authy.com/onetouch/json/users/%ld/approval_requests", + authy_id); + + char xheader[64]; + snprintf(xheader, sizeof(xheader), "X-Authy-API-Key: %s", api_key); + + char data[200]; + snprintf(data, sizeof(data), "message=Login authentication&" \ + "details=%s at %s&seconds_to_expire=%d", + username, hostname, timeout); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, xheader); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, strlen(data)); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, ctrl_curl_receive); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&buffer); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res; + res = curl_easy_perform(curl); + if (res != CURLE_OK) { + log_message(LOG_ERR, pamh, "authy_err: curl call failed: %d (%s)", + res, curl_easy_strerror(res)); + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + rc = AUTHY_OK; + + payload = json_loads(buffer.memory, JSON_DECODE_ANY, NULL); + jt = json_object_get(payload, "approval_request"); + if (!jt) { + log_message(LOG_ERR, pamh, "authy_err: 'approval_request' field missing"); + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + *uuid = (char *)json_string_value(json_object_get(jt, "uuid")); + if (!*uuid) { + log_message(LOG_ERR, pamh, "authy_err: 'uuid' field missing"); + rc = AUTHY_CONN_ERROR; + goto exit_err; + } + +exit_err: + free(buffer.memory); + free(jt); + free(payload); + + if (curl) + curl_easy_cleanup(curl); + + return rc; +} + +authy_rc_t authy_login(pam_handle_t *pamh, long authy_id, char *api_key, int timeout) +{ + authy_rc_t rc; + char *uuid = NULL; + char *err_str = NULL; + + log_message(LOG_INFO, pamh, "authy_dbg: Sending Authy authentication push request"); + rc = authy_post_approval(pamh, authy_id, api_key, 30, &uuid); + if (rc != AUTHY_OK) { + log_message(LOG_ERR, pamh, "authy_err: Push Authentication request failed"); + goto exit_err; + } + + log_message(LOG_INFO, pamh, "authy_dbg: Waiting for Authy authentication approval"); + struct timespec start_time, now; + clock_gettime(CLOCK_MONOTONIC, &start_time); + do { + rc = authy_check_approval(pamh, api_key, uuid); + switch (rc) { + case AUTHY_DENIED: + err_str = "denied"; + goto exit_err; + case AUTHY_EXPIRED: + err_str = "expired"; + goto exit_err; + case AUTHY_APPROVED: + log_message(LOG_INFO, pamh, "authy_dbg: Authentication approved"); + goto exit_err; + default: + break; + } + sleep(1); + clock_gettime(CLOCK_MONOTONIC, &now); + } while ((start_time.tv_sec + timeout + 5) > now.tv_sec); + rc = AUTHY_EXPIRED; + err_str = "expired (pam timeout)"; + +exit_err: + if (err_str) + log_message(LOG_ERR, pamh, "authy_err: Authentication %s", err_str); + + free(uuid); + + return rc; +} diff --git a/src/pam_authy.h b/src/pam_authy.h new file mode 100644 index 0000000..ead6824 --- /dev/null +++ b/src/pam_authy.h @@ -0,0 +1,38 @@ +/* + * Google authenticator extension for Authy push notifications + * + * Copyright 2020 Krzysztof Olejarczyk + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef AUTHY_H +#define AUTHY_H + +typedef enum { + AUTHY_OK = 0, + AUTHY_LIB_ERROR, + AUTHY_CONN_ERROR, + AUTHY_APPROVED, + AUTHY_DENIED, + AUTHY_PENDING, + AUTHY_EXPIRED, + AUTHY_NO_SUPPORT, +} authy_rc_t; + +authy_rc_t authy_login(pam_handle_t *pamh, long authy_id, char *api_key, int timeout); + +#endif /* AUTHY_H */ diff --git a/src/pam_google_authenticator.c b/src/pam_google_authenticator.c index 790fed8..5f752f6 100644 --- a/src/pam_google_authenticator.c +++ b/src/pam_google_authenticator.c @@ -52,10 +52,12 @@ #include "hmac.h" #include "sha1.h" #include "util.h" +#include "pam_util.h" +#include "pam_authy.h" -#define MODULE_NAME "pam_google_authenticator" #define SECRET "~/.google_authenticator" #define CODE_PROMPT "Verification code: " +#define AUTHY_PROMPT "Confirm login request" #define PWCODE_PROMPT "Password & verification code: " typedef struct Params { @@ -74,68 +76,13 @@ typedef struct Params { int allowed_perm; time_t grace_period; int allow_readonly; + int enable_authy; } Params; static char oom; static const char* nobody = "nobody"; -#if defined(DEMO) || defined(TESTING) -static char* error_msg = NULL; - -const char *get_error_msg(void) __attribute__((visibility("default"))); -const char *get_error_msg(void) { - if (!error_msg) { - return ""; - } - return error_msg; -} -#endif - -static void log_message(int priority, pam_handle_t *pamh, - const char *format, ...) { - char *service = NULL; - if (pamh) - pam_get_item(pamh, PAM_SERVICE, (void *)&service); - if (!service) - service = ""; - - char logname[80]; - snprintf(logname, sizeof(logname), "%s(" MODULE_NAME ")", service); - - va_list args; - va_start(args, format); -#if !defined(DEMO) && !defined(TESTING) - openlog(logname, LOG_CONS | LOG_PID, LOG_AUTHPRIV); - vsyslog(priority, format, args); - closelog(); -#else - if (!error_msg) { - error_msg = strdup(""); - } - { - char buf[1000]; - vsnprintf(buf, sizeof buf, format, args); - const int newlen = strlen(error_msg) + 1 + strlen(buf) + 1; - char* n = malloc(newlen); - if (n) { - snprintf(n, newlen, "%s%s%s", error_msg, strlen(error_msg)?"\n":"",buf); - free(error_msg); - error_msg = n; - } else { - fprintf(stderr, "Failed to malloc %d bytes for log data.\n", newlen); - } - } -#endif - - va_end(args); - - if (priority == LOG_EMERG) { - // Something really bad happened. There is no way we can proceed safely. - _exit(1); - } -} - static int converse(pam_handle_t *pamh, int nargs, PAM_CONST struct pam_message **message, struct pam_response **response) { @@ -910,6 +857,38 @@ static long get_hotp_counter(pam_handle_t *pamh, const char *buf) { return counter; } +static long get_cfg_value_long(pam_handle_t *pamh, const char *buf, char *parm) { + if (!buf) { + return -1; + } + const char *cfg_val_str = get_cfg_value(pamh, parm, buf); + if (cfg_val_str == &oom) { + // Out of memory. This is a fatal error + return -1; + } + + long cfg_val = -1; + if (cfg_val_str) { + cfg_val = strtoll(cfg_val_str, NULL, 10); + } + free((void *)cfg_val_str); + + return cfg_val; +} + +static const char *get_cfg_value_char(pam_handle_t *pamh, const char *buf, char *parm) { + if (!buf) { + return NULL; + } + const char *cfg_val_str = get_cfg_value(pamh, parm, buf); + if (cfg_val_str == &oom) { + // Out of memory. This is a fatal error + return NULL; + } + + return cfg_val_str; +} + static int rate_limit(pam_handle_t *pamh, const char *secret_filename, int *updated, char **buf) { const char *value = get_cfg_value(pamh, "RATE_LIMIT", *buf); @@ -1781,6 +1760,8 @@ static int parse_args(pam_handle_t *pamh, int argc, const char **argv, params->nullok = NULLOK; } else if (!strcmp(argv[i], "allow_readonly")) { params->allow_readonly = 1; + } else if (!strcmp(argv[i], "enable_authy")) { + params->enable_authy = 1; } else if (!strcmp(argv[i], "echo-verification-code") || !strcmp(argv[i], "echo_verification_code")) { params->echocode = PAM_PROMPT_ECHO_ON; @@ -1890,6 +1871,22 @@ static int google_authenticator(pam_handle_t *pamh, log_message(LOG_WARNING , pamh, "No secret configured for user %s, asking for code anyway.", username); } + if (params.enable_authy) { + authy_rc_t arc; + long authy_id = get_cfg_value_long(pamh, buf, "AUTHY_ID"); + long authy_timeout = get_cfg_value_long(pamh, buf, "AUTHY_TIMEOUT"); + if (authy_timeout < 0) { + authy_timeout = 30; + } + char *api_key = (char *)get_cfg_value_char(pamh, buf, "AUTHY_API_KEY"); + arc = authy_login(pamh, authy_id, api_key, authy_timeout); + log_message(LOG_INFO, pamh, "authy_login result: %d\n", arc); + if (arc == AUTHY_APPROVED) { + rc = PAM_SUCCESS; + goto out; + } + } + int must_advance_counter = 0; char *pw = NULL, *saved_pw = NULL; for (int mode = 0; mode < 4; ++mode) { diff --git a/src/pam_no_authy.c b/src/pam_no_authy.c new file mode 100644 index 0000000..3511da3 --- /dev/null +++ b/src/pam_no_authy.c @@ -0,0 +1,36 @@ +/* + * Google authenticator extension for Authy push notifications + * + * Copyright 2020 Krzysztof Olejarczyk + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#define _GNU_SOURCE + +#include "config.h" +#include +#include + +#include +#include + +#include "pam_authy.h" + +authy_rc_t authy_login(pam_handle_t *pamh, long authy_id, char *api_key, int timeout) +{ + return AUTHY_NO_SUPPORT; +} diff --git a/src/pam_util.c b/src/pam_util.c new file mode 100644 index 0000000..bee4a89 --- /dev/null +++ b/src/pam_util.c @@ -0,0 +1,72 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "pam_util.h" + +#if defined(DEMO) || defined(TESTING) +static char* error_msg = NULL; + +const char *get_error_msg(void) __attribute__((visibility("default"))); +const char *get_error_msg(void) +{ + if (!error_msg) { + return ""; + } + return error_msg; +} +#endif + +void log_message(int priority, pam_handle_t *pamh, + const char *format, ...) +{ + char *service = NULL; + if (pamh) + pam_get_item(pamh, PAM_SERVICE, (void *)&service); + if (!service) + service = ""; + + char logname[80]; + snprintf(logname, sizeof(logname), "%s(" MODULE_NAME ")", service); + + va_list args; + va_start(args, format); +#if !defined(DEMO) && !defined(TESTING) + openlog(logname, LOG_CONS | LOG_PID, LOG_AUTHPRIV); + vsyslog(priority, format, args); + closelog(); +#else + if (!error_msg) { + error_msg = strdup(""); + } + { + char buf[1000]; + vsnprintf(buf, sizeof buf, format, args); + const int newlen = strlen(error_msg) + 1 + strlen(buf) + 1; + char* n = malloc(newlen); + if (n) { + snprintf(n, newlen, "%s%s%s", error_msg, strlen(error_msg)?"\n":"",buf); + free(error_msg); + error_msg = n; + } else { + fprintf(stderr, "Failed to malloc %d bytes for log data.\n", newlen); + } + } +#endif + + va_end(args); + + if (priority == LOG_EMERG) { + // Something really bad happened. There is no way we can proceed safely. + _exit(1); + } +} + diff --git a/src/pam_util.h b/src/pam_util.h new file mode 100644 index 0000000..bcbea37 --- /dev/null +++ b/src/pam_util.h @@ -0,0 +1,9 @@ +#ifndef _PAM_UTIL_H_ +#define _PAM_UTIL_H_ + +#define MODULE_NAME "pam_google_authenticator" + +void log_message(int priority, pam_handle_t *pamh, + const char *format, ...); + +#endif /* _PAM_UTIL_H_ */