diff --git a/Makefile.am b/Makefile.am index 3b59e0d9..a2b0584a 100644 --- a/Makefile.am +++ b/Makefile.am @@ -2,7 +2,8 @@ bin_PROGRAMS = openfortivpn openfortivpn_SOURCES = src/config.c src/config.h src/hdlc.c src/hdlc.h \ - src/http.c src/http.h src/io.c src/io.h src/ipv4.c \ + src/http.c src/http.h src/io.c src/io.h \ + src/http_server.c src/ipv4.c \ src/ipv4.h src/log.c src/log.h src/tunnel.c \ src/tunnel.h src/main.c src/ssl.h src/xml.c \ src/xml.h src/userinput.c src/userinput.h diff --git a/README.md b/README.md index 53b5fb4f..86d93707 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ Examples openfortivpn vpn-gateway:8443 --username= --password= --user-cert=cert.pem --user-key=key.pem ``` +* Connect using SAML login: + ```shell + openfortivpn vpn-gateway:8443 --saml-login + ``` + * Don't set IP routes and don't add VPN nameservers to `/etc/resolv.conf`: ```shell openfortivpn vpn-gateway:8443 -u foo --no-routes --no-dns --pppd-no-peerdns @@ -230,6 +235,12 @@ to authenticate and retrieve a session cookie. This cookie can be fed to openfortivpn using option `--cookie-on-stdin`. Obviously, such a solution requires a graphic session. +When started using `--saml-login` the program creates a web server that +accepts SAML login requests. To login using SAML you just have to open +`/remote/saml/start?redirect=1` and follow the login steps. +At the end of the login process the page will be redirected to +`http://127.0.0.1:8020/?id=` + Contributing ------------ diff --git a/doc/openfortivpn.1.in b/doc/openfortivpn.1.in index 62e2ad1d..fefc5f28 100644 --- a/doc/openfortivpn.1.in +++ b/doc/openfortivpn.1.in @@ -10,6 +10,7 @@ openfortivpn \- Client for PPP+TLS VPN tunnel services [\fB\-p\fR \fI\fR] [\fB\-\-cookie=\fI\fR] [\fB\-\-cookie\-on\-stdin\fR] +[\fB\-\-saml\-login[=\fI\fR]] [\fB\-\-pinentry=\fI\fR] [\fB\-\-otp=\fI\fR] [\fB\-\-otp\-prompt=\fI\fR] @@ -81,6 +82,14 @@ A valid cookie (SVPNCOOKIE) to use in place of username and password. \fB\-\-cookie\-on\-stdin\fR Read the cookie (SVPNCOOKIE) from standard input. .TP +\fB\-\-saml\-login[=\fI\fR] +Create a temporary web server to receive a local SAML redirect operation. +To login using SAML you just have to open +`/remote/saml/start?redirect=1' and follow the login steps. +At the end of the login process, the page will be redirected to +`http://127.0.0.1:8020/?id='. The actual URL to use for the login, +including the optional ream, is printed to the terminal when the web server it started. +.TP \fB\-\-pinentry=\fI\fR The pinentry program to use. Allows supplying the password in a secure manner. For example: pinentry-gnome3 on Linux, or pinentry-mac on macOS. diff --git a/etc/openfortivpn/config.template b/etc/openfortivpn/config.template index 0c7cd349..fa2a6a2c 100644 --- a/etc/openfortivpn/config.template +++ b/etc/openfortivpn/config.template @@ -4,3 +4,5 @@ host = vpn.example.org port = 443 username = vpnuser password = VPNpassw0rd + +# saml-login = 8020 diff --git a/src/config.c b/src/config.c index 9c6d3c5d..67ee46cc 100644 --- a/src/config.c +++ b/src/config.c @@ -45,6 +45,8 @@ const struct vpn_config invalid_cfg = { .password = {'\0'}, .password_set = 0, .cookie = NULL, + .saml_port = 0, + .saml_session_id = {'\0'}, .otp = {'\0'}, .otp_prompt = NULL, .otp_delay = -1, @@ -417,6 +419,14 @@ int load_config(struct vpn_config *cfg, const char *filename) cfg->user_cert = strdup(val); if (strncmp(cfg->user_cert, "pkcs11:", 7) == 0) cfg->use_engine = 1; + } else if (strcmp(key, "saml-login") == 0) { + long port = strtol(val, NULL, 0); + + if (port < 1 || port > 65535) { + log_error("Bad SAML listen port: \"%s\".\n", val); + goto err_free; + } + cfg->saml_port = (uint16_t)port; } else if (strcmp(key, "user-key") == 0) { free(cfg->user_key); cfg->user_key = strdup(val); @@ -534,6 +544,9 @@ void merge_config(struct vpn_config *dst, struct vpn_config *src) free(dst->cookie); dst->cookie = src->cookie; } + if(src->saml_port != 0) { + dst->saml_port = src->saml_port; + } if (src->pinentry) { free(dst->pinentry); dst->pinentry = src->pinentry; diff --git a/src/config.h b/src/config.h index eaf7f825..352caae2 100644 --- a/src/config.h +++ b/src/config.h @@ -81,6 +81,7 @@ struct x509_digest { * We believe we are on the safe side using this value. */ #define MAX_DOMAIN_LENGTH 256 +#define MAX_SAML_SESSION_ID_LENGTH 1024 struct vpn_config { char gateway_host[GATEWAY_HOST_SIZE + 1]; @@ -91,6 +92,8 @@ struct vpn_config { int password_set; char otp[OTP_SIZE + 1]; char *cookie; + int saml_port; + char saml_session_id[MAX_SAML_SESSION_ID_LENGTH]; char *otp_prompt; unsigned int otp_delay; int no_ftm_push; diff --git a/src/hdlc.c b/src/hdlc.c index f347d42d..65385a09 100644 --- a/src/hdlc.c +++ b/src/hdlc.c @@ -199,7 +199,7 @@ ssize_t hdlc_find_frame(const uint8_t *buffer, size_t bufsize, off_t *start) int s = -1, e = -1; // Look for frame start - for (int i = *start; i < bufsize; i++) { + for (size_t i = *start; i < bufsize; i++) { if (buffer[i] == 0x7e) { // Flag Sequence s = i + 1; break; @@ -213,7 +213,7 @@ ssize_t hdlc_find_frame(const uint8_t *buffer, size_t bufsize, off_t *start) s++; // Look for frame end - for (int i = s; i < bufsize; i++) { + for (size_t i = s; i < bufsize; i++) { if (buffer[i] == 0x7e) { // Flag Sequence e = i; break; diff --git a/src/http.c b/src/http.c index 82d33d6e..bf4052da 100644 --- a/src/http.c +++ b/src/http.c @@ -47,7 +47,7 @@ * @param[out] dest the buffer to write the URL-encoded string * @param[in] str the input string to be escaped */ -static void url_encode(char *dest, const char *str) +void url_encode(char *dest, const char *str) { while (*str != '\0') { if (isalnum(*str) || *str == '-' || *str == '_' || @@ -667,7 +667,15 @@ int auth_log_in(struct tunnel *tunnel) tunnel->cookie[0] = '\0'; - if (username[0] == '\0' && tunnel->config->password[0] == '\0') { + if (strlen(tunnel->config->saml_session_id) > 0) { + // SAML login + static const char *uri_pattern = "/remote/saml/auth_id?id=%s"; + int required_size = snprintf(NULL, 0, uri_pattern, tunnel->config->saml_session_id) + 1; + char *uri = alloca(required_size); + snprintf(uri, required_size, uri_pattern, tunnel->config->saml_session_id); + log_debug("Using SAML authentication URL %s\n", uri); + ret = http_request(tunnel, "GET", uri, "", &res, &response_size); + } else if (username[0] == '\0' && tunnel->config->password[0] == '\0') { ret = http_request(tunnel, "GET", "/remote/login", data, &res, &response_size); } else { diff --git a/src/http.h b/src/http.h index a4a07e1b..22f7b719 100644 --- a/src/http.h +++ b/src/http.h @@ -31,6 +31,16 @@ #define ERR_HTTP_PERMISSION -6 #define ERR_HTTP_NO_COOKIE -7 +/* + * URL-encodes a string for HTTP requests. + * + * The dest buffer size MUST be at least strlen(str) * 3 + 1. + * + * @param[out] dest the buffer to write the URL-encoded string + * @param[in] str the input string to be escaped + */ +void url_encode(char *dest, const char *str); + static inline const char *err_http_str(int code) { if (code > 0) diff --git a/src/http_server.c b/src/http_server.c new file mode 100644 index 00000000..fbadf909 --- /dev/null +++ b/src/http_server.c @@ -0,0 +1,234 @@ +#include +#include +#include +#include +#include +#include + +#include "config.h" +#include "log.h" +#include "tunnel.h" +#include "http.h" // for url_encode + +static void print_url(const struct vpn_config *cfg) { + char *encoded_realm = NULL; + char realm[] = "&realm="; + char *empty_string = "\0"; + + // Desired string is https://company.com:port/remote/saml/start?redirect=1(&realm=) + // with the realm being optional + static const char *uri_pattern = "https://%s:%d/remote/saml/start?redirect=1%s%s"; + + if (cfg->realm[0] != '\0') { + encoded_realm = alloca(strlen(cfg->realm) * 3 + 1); // url_encode required three times the size + url_encode(encoded_realm, cfg->realm); + } else { + encoded_realm = empty_string; + realm[0] = 0; // Make realm appear empty when printing as string + } + + int required_size = 1 + snprintf(NULL, 0, uri_pattern, cfg->gateway_host, cfg->gateway_port, realm, encoded_realm); + char *url = alloca(required_size); + snprintf(url, required_size, uri_pattern, cfg->gateway_host, cfg->gateway_port, realm, encoded_realm); + + log_info("Authenticate at '%s'\n", url); +} + +// Convenience function to send a response with a user readable status message and the +// request URL shown for debug purposes. +// The response is shown in the user's browser after being redirected from the Fortinet Server. +static void send_status_response(int socket, const char *userMessage) { + static const char *replyHeader = "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html\r\n" + "Content-Length: %lu\r\n" + "Connection: close\r\n" + "\r\n"; + + static const char *replyBody = "\r\n" + "\r\n" + "%s" // User readable message + "\r\n"; + + int replyBodySize = snprintf(NULL, 0, replyBody, userMessage) + 1; + char *replyBodyBuffer = alloca(replyBodySize); + snprintf(replyBodyBuffer, replyBodySize, replyBody, userMessage); + + int replyHeaderSize = snprintf(NULL, 0, replyHeader, replyBodySize) + 1; + char *replyHeaderBuffer = alloca(replyHeaderSize); + snprintf(replyHeaderBuffer, replyHeaderSize, replyHeader, strlen(replyBodyBuffer)); + + // Using two separate writes here to make the code not more complicated assembling + // the buffers. + ssize_t write_result = write(socket, replyHeaderBuffer, strlen(replyHeaderBuffer)); + write_result = write(socket, replyBodyBuffer, strlen(replyBodyBuffer)); + (void)write_result; +} + +static int process_request(int new_socket, char *id) { + log_info("Processing HTTP SAML request\n"); + + int flag = 1; + setsockopt(new_socket, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(int)); + + // Read the request + char request[1024]; + ssize_t read_result = read(new_socket, request, sizeof(request) - 1 /* Save one place for termination + in case the request is about to + fill the entire buffer. */ + ); + + // Check for '=id' in the response + // If the recevied request from the server is larger than the buffer, the result will not be null-terminated. + // Causing strlen to behave wrong. + if (read_result < 0) { + log_error("Bad request\n"); + send_status_response(new_socket, "Invalid redirect response from Fortinet server. VPN could not be established."); + return -1; + } + + // Safety Null-terminate + request[sizeof(request) - 1] = 0; + request[read_result] = 0; + + static const char *request_head = "GET /?id="; + + if (strncmp(request, request_head, strlen(request_head)) != 0) { + log_error("Bad request\n"); + send_status_response(new_socket, "Invalid redirect response from Fortinet server. VPN could not be established."); + return -1; + } + + // Extract the id + static const char *token_delimiter = " &\r\n"; + char *next_token = request + strlen(request_head); // strsep does modify the input argument and we don't want to loose our request pointer. + char *id_start = strsep(&next_token, token_delimiter); + + if (next_token == NULL) { + // In case not found, next_token was set to NULL + // This should be invalid because we expect \r\n at the end of the GET request line + log_error("Bad request format\n"); + send_status_response(new_socket, "Invalid formatting of Fortinet server redirect response. VPN could not be established."); + return -1; + } + + // strsep inserted a NULL at the location of the delimiter. + int id_length = strlen(id_start); + + if(id_length == 0 || id_length >= MAX_SAML_SESSION_ID_LENGTH) { + log_error("Bad request id\n"); + send_status_response(new_socket, "Invalid SAML session id received from Fortinet server. VPN could not be established."); + return -1; + } + + // It was checked above, that the length is smaller than MAX_SAML_SESSION_ID_LENGTH + strcpy(id, id_start); + + for (int i = 0; i < id_length; i++) { + if (isalnum(id[i]) || id[i] == '-') continue; + log_error("Invalid id format\n"); + send_status_response(new_socket, "Invalid SAML session id received from Fortinet server. VPN could not be established."); + return -1; + } + + send_status_response(new_socket, + "SAML session id received from Fortinet server. VPN will be established...
" + "You may close this browser tab now.
" + "\r\n"); + return 0; +} + +/** + * Run a http server to listen for SAML login requests + * + * @return 0 in case of success + * < 0 in case of error +*/ +int wait_for_http_request(struct vpn_config *config) { + int server_fd, new_socket; + struct sockaddr_in address; + int opt = 1; + int addrlen = sizeof(address); + long saml_port = config->saml_port; + + // Creating socket file descriptor + if ((server_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)) == 0) { + log_error("Failed to create socket\n"); + return -1; + } + + // Forcefully attaching socket to the port + if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { + close(server_fd); + log_error("Failed to set socket options\n"); + return -1; + } + + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(saml_port); + + // Forcefully attaching socket to the port + if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { + close(server_fd); + log_error("Failed to bind socket to port %d\n", saml_port); + return -1; + } + + if (listen(server_fd, 3) < 0) { + close(server_fd); + log_error("Failed to listen on socket\n"); + return -1; + } + + int max_tries = 5; + fd_set readfds; + struct timeval tv; + + log_info("Listening for SAML login on port %d\n", saml_port); + print_url(config); + + while(max_tries > 0) { + --max_tries; + FD_ZERO(&readfds); + FD_SET(server_fd, &readfds); + // Wait up to ten seconds + tv.tv_sec = 10; + tv.tv_usec = 0; + + int retval = select(server_fd + 1, &readfds, NULL, NULL, &tv); + + if (retval == -1) { + log_error("Failed to wait for connection: %s\n", strerror(errno)); + break; + } else if (retval > 0) { + log_debug("Incoming HTTP connection\n"); + if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) { + log_error("Failed to accept connection\n"); + continue; + } + } else { + log_debug("Timeout listening for incoming HTTP connection reached\n"); + continue; + } + + int result = process_request(new_socket, config->saml_session_id); + close(new_socket); + if(result == 0) + break; + + log_warn("Failed to process request\n"); + } + + close(server_fd); + + if (max_tries == 0 && strlen(config->saml_session_id) == 0) { + log_error("Finally failed to retrieve SAML authentication token\n"); + return -1; + } + + return 0; +} + diff --git a/src/http_server.h b/src/http_server.h new file mode 100644 index 00000000..d27afdb0 --- /dev/null +++ b/src/http_server.h @@ -0,0 +1,3 @@ +#include "config.h" + +int wait_for_http_request(struct vpn_config *config); diff --git a/src/ipv4.c b/src/ipv4.c index a86bcf24..f6e92e55 100644 --- a/src/ipv4.c +++ b/src/ipv4.c @@ -1052,7 +1052,7 @@ int ipv4_restore_routes(struct tunnel *tunnel) static inline char *replace_char(char *str, char find, char replace) { - for (int i = 0; i < strlen(str); i++) + for (size_t i = 0; i < strlen(str); i++) if (str[i] == find) str[i] = replace; return str; diff --git a/src/main.c b/src/main.c index c1fba334..2256ecfd 100644 --- a/src/main.c +++ b/src/main.c @@ -19,6 +19,7 @@ #include "tunnel.h" #include "userinput.h" #include "log.h" +#include "http_server.h" #include @@ -77,7 +78,7 @@ #define usage \ "Usage: openfortivpn [[:]] [-u ] [-p ]\n" \ -" [--cookie=] [--cookie-on-stdin]\n" \ +" [--cookie=] [--cookie-on-stdin] [--saml-login]\n" \ " [--otp=] [--otp-delay=] [--otp-prompt=]\n" \ " [--pinentry=] [--realm=]\n" \ " [--ifname=] [--set-routes=<0|1>]\n" \ @@ -117,6 +118,7 @@ PPPD_USAGE \ " -p , --password= VPN account password.\n" \ " --cookie= A valid session cookie (SVPNCOOKIE).\n" \ " --cookie-on-stdin Read the cookie (SVPNCOOKIE) from standard input.\n" \ +" --saml-login[=port] Run a http server to handle SAML login requests\n" \ " -o , --otp= One-Time-Password.\n" \ " --otp-prompt= Search for the OTP prompt starting with this string.\n" \ " --otp-delay= Wait seconds before sending the OTP.\n" \ @@ -225,6 +227,8 @@ int main(int argc, char *argv[]) .password = {'\0'}, .password_set = 0, .cookie = NULL, + .saml_port = 0, + .saml_session_id = {'\0'}, .otp = {'\0'}, .otp_prompt = NULL, .otp_delay = 0, @@ -286,6 +290,7 @@ int main(int argc, char *argv[]) {"password", required_argument, NULL, 'p'}, {"cookie", required_argument, NULL, 0}, {"cookie-on-stdin", no_argument, NULL, 0}, + {"saml-login", optional_argument, NULL, 0}, {"otp", required_argument, NULL, 'o'}, {"otp-prompt", required_argument, NULL, 0}, {"otp-delay", required_argument, NULL, 0}, @@ -604,6 +609,19 @@ int main(int argc, char *argv[]) free(cookie); break; } + if (strcmp(long_options[option_index].name, + "saml-login") == 0) { + long port = 8020; + if(optarg != NULL){ + port = strtol(optarg, NULL, 0); + } + if (port < 0 || port > 65535) { + log_warn("Invalid saml listen port: %s! Default port is 8020 \n", optarg); + break; + } + cli_cfg.saml_port = port; + break; + } goto user_error; case 'h': printf("%s%s%s%s%s%s%s", usage, summary, @@ -707,14 +725,14 @@ int main(int argc, char *argv[]) goto user_error; } // Check username - if (cfg.username[0] == '\0' && !cfg.cookie) + if (cfg.username[0] == '\0' && !cfg.cookie && !cfg.saml_port) // Need either username or cert if (cfg.user_cert == NULL) { log_error("Specify a username.\n"); goto user_error; } // If username but no password given, interactively ask user - if (!cfg.password_set && cfg.username[0] != '\0' && !cfg.cookie) { + if (!cfg.password_set && cfg.username[0] != '\0' && !cfg.cookie && !cfg.saml_port) { char hint[USERNAME_SIZE + 1 + REALM_SIZE + 1 + GATEWAY_HOST_SIZE + 10]; sprintf(hint, "%s_%s_%s_password", @@ -738,6 +756,21 @@ int main(int argc, char *argv[]) goto exit; } + if(cfg.saml_port != 0) { + // Wait for the SAML token from the HTTP GET request + if (wait_for_http_request(&cfg) != 0) { + log_error("Failed to retrieve SAML cookie from HTTP\n"); + ret = EXIT_FAILURE; + goto exit; + } + + if (strlen(cfg.saml_session_id) == 0) { + log_error("Failed to receive SAML session id\n"); + ret = EXIT_FAILURE; + goto exit; + } + } + do { if (run_tunnel(&cfg) != 0) ret = EXIT_FAILURE; diff --git a/src/tunnel.c b/src/tunnel.c index 71d8446d..a7316974 100644 --- a/src/tunnel.c +++ b/src/tunnel.c @@ -773,7 +773,7 @@ static int tcp_connect(struct tunnel *tunnel) const char *response = NULL; memset(&(request), 0, sizeof(request)); - for (int j = 0; response == NULL; j++) { + for (unsigned int j = 0; response == NULL; j++) { if (j >= ARRAY_SIZE(request) - 1) { log_error("Proxy response is unexpectedly large and cannot fit in the %lu-bytes buffer.\n", ARRAY_SIZE(request)); @@ -819,7 +819,7 @@ static int tcp_connect(struct tunnel *tunnel) }; const char *eol = NULL; - for (int i = 0; (i < ARRAY_SIZE(HTTP_EOL)) && + for (unsigned int i = 0; (i < ARRAY_SIZE(HTTP_EOL)) && (eol == NULL); i++) eol = strstr(response, HTTP_EOL[i]); response = eol; diff --git a/src/userinput.c b/src/userinput.c index 53a50255..75ed10c9 100644 --- a/src/userinput.c +++ b/src/userinput.c @@ -332,7 +332,7 @@ void read_password(const char *pinentry, const char *hint, { int masked = 0; struct termios oldt, newt; - int i; + size_t i; if (pinentry && *pinentry) { pinentry_read_password(pinentry, hint, prompt, pass, len);