From 79eb7708e21407e7423d2c742ef49278628a81ff Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Sun, 21 Feb 2021 15:02:44 -0800 Subject: [PATCH] add auth-fortinet tests This tests OpenConnect's ability to authenticate with a (fake) Fortinet server, using all of the options that OpenConnect currently supports (username+password, username+password+token, non-default realm). The fake Fortinet authentication server requires Python 3.6 and Flask. Signed-off-by: Daniel Lenski --- configure.ac | 13 +++ tests/Makefile.am | 6 +- tests/auth-fortinet | 64 +++++++++++++ tests/common.sh | 3 +- tests/fake-fortinet-server.py | 166 ++++++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 2 deletions(-) create mode 100755 tests/auth-fortinet create mode 100755 tests/fake-fortinet-server.py diff --git a/configure.ac b/configure.ac index 2b5ca0e1..9c83b2e4 100644 --- a/configure.ac +++ b/configure.ac @@ -1067,6 +1067,19 @@ AM_CONDITIONAL(BUILD_WWW, [test "${build_www}" = "yes"]) PKG_CHECK_MODULES([CWRAP], [uid_wrapper, socket_wrapper], have_cwrap=yes, have_cwrap=no) AM_CONDITIONAL(HAVE_CWRAP, test "x$have_cwrap" != xno) +have_python36_flask=no +if test -n "${ac_cv_path_PYTHON}"; then + AC_MSG_CHECKING([for Python 3.6+ with Flask module]) + python3 -c 'import sys; assert sys.version_info >= (3,6); import flask' 2>/dev/null + if test $? -ne 0 ; then + AC_MSG_RESULT(not found) + else + have_python36_flask=yes + AC_MSG_RESULT(found) + fi + AM_CONDITIONAL(HAVE_PYTHON36_FLASK, test "$have_python36_flask" = yes) +fi + if test "$enable_ppp_tests" = "yes"; then AC_PATH_PROGS(SOCAT, [socat], [], $PATH:/bin:/usr/bin) AC_PATH_PROGS(PPPD, [pppd], [], $PATH:/bin:/usr/bin:/sbin:/usr/sbin/) diff --git a/tests/Makefile.am b/tests/Makefile.am index 5493e6ca..50d49a72 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -46,7 +46,7 @@ EXTRA_DIST = certs/ca.pem certs/ca-key.pem certs/user-cert.pem $(USER_KEYS) $(US configs/user-cert.prm configs/server-cert.prm \ softhsm2.conf.in softhsm ns.sh configs/test-dtls-psk.config \ scripts/vpnc-script scripts/vpnc-script-detect-disconnect \ - suppressions.lsan + suppressions.lsan fake-fortinet-server.py dist_check_SCRIPTS = autocompletion @@ -61,6 +61,10 @@ endif if HAVE_CWRAP dist_check_SCRIPTS += auth-username-pass auth-certificate auth-nonascii cert-fingerprint id-test obsolete-server-crypto pfs +if HAVE_PYTHON36_FLASK +dist_check_SCRIPTS += auth-fortinet +endif + if TEST_PKCS11 dist_check_SCRIPTS += auth-pkcs11 diff --git a/tests/auth-fortinet b/tests/auth-fortinet new file mode 100755 index 00000000..60d97ad1 --- /dev/null +++ b/tests/auth-fortinet @@ -0,0 +1,64 @@ +#!/bin/sh +# +# Copyright © 2021 Daniel Lenski +# +# This file is part of openconnect. +# +# This is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see + +# This test uses LD_PRELOAD +PRELOAD=1 +srcdir=${srcdir:-.} +top_builddir=${top_builddir:-..} + +. `dirname $0`/common.sh + +FINGERPRINT="--servercert=d66b507ae074d03b02eafca40d35f87dd81049d3" +CERT=$certdir/server-cert.pem +KEY=$certdir/server-key.pem + +echo "Testing Fortinet auth against fake server ... " + +OCSERV=./fake-fortinet-server.py +launch_simple_sr_server $ADDRESS 443 $CERT $KEY > /dev/null 2>&1 +PID=$! +wait_server $PID 1 + +echo -n "Authenticating with username/password and DEFAULT realm... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=fortinet -q $ADDRESS:443 -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake Fortinet server" + +echo ok + +echo -n "Authenticating with username/password and NON-DEFAULT path... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=fortinet -q $ADDRESS:443/fakeRealm -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake Fortinet server" + +echo ok + +echo -n "Authenticating with username/password/token and DEFAULT path... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=fortinet -q $ADDRESS:443/?want_2fa=1 -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake Fortinet server" + +echo ok + +echo -n "Authenticating with username/password/token and NON-DEFAULT path... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=fortinet -q $ADDRESS:443/fakeRealm?want_2fa=1 -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --pfs --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake Fortinet server" + +echo ok + +cleanup + +exit 0 diff --git a/tests/common.sh b/tests/common.sh index f1aa1485..82207506 100644 --- a/tests/common.sh +++ b/tests/common.sh @@ -110,8 +110,9 @@ launch_simple_pppd() { } wait_server() { + test $# -ge 2 && DELAY="$2" || DELAY=5 trap "kill $1" 1 15 2 - sleep 5 + sleep "$DELAY" } cleanup() { diff --git a/tests/fake-fortinet-server.py b/tests/fake-fortinet-server.py new file mode 100755 index 00000000..9d51bfbd --- /dev/null +++ b/tests/fake-fortinet-server.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# +# Copyright © 2021 Daniel Lenski +# +# This file is part of openconnect. +# +# This is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see + +######################################## +# This program emulates the authentication-phase behavior of a Fortinet +# server enough to test OpenConnect's authentication behavior against it. +# Specifically, it emulates the following requests: +# +# GET /[$REALM] +# GET /remote/login[?realm=$REALM] +# POST /remote/logincheck (with username and credential fields) +# No 2FA) Completes the login +# With 2FA) Returns a 2FA challenge +# POST /remote/logincheck (with username, code, and challenge response fields) +# +# It does not actually validate the credentials in any way, but attempts to +# verify their consistency from one request to the next, by saving their +# values via a (cookie-based) session. +# +# In order to test with 2FA, the initial 'GET /' request should include +# the query string '?want_2fa=1'. +######################################## + +import sys +import ssl +import random +import base64 +from json import dumps +from functools import wraps +from flask import Flask, request, abort, redirect, url_for, make_response, session + +host, port, *cert_and_maybe_keyfile = sys.argv[1:] + +context = ssl.SSLContext() +context.load_cert_chain(*cert_and_maybe_keyfile) + +app = Flask(__name__) +app.config.update(SECRET_KEY=b'fake', DEBUG=True, HOST=host, PORT=int(port), SESSION_COOKIE_NAME='fake') + +######################################## + +def cookify(jsonable): + return base64.urlsafe_b64encode(dumps(jsonable).encode()) + +def require_SVPNCOOKIE(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + if not request.cookies.get('SVPNCOOKIE'): + session.clear() + return redirect(url_for('login')) + return fn(*args, **kwargs) + return wrapped + +def check_form_against_session(*fields): + def inner(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + for f in fields: + assert session.get(f) == request.form.get(f), \ + f'at step {session.get("step")}: form {f!r} {request.form.get(f)!r} != session {f!r} {session.get(f)!r}' + return fn(*args, **kwargs) + return wrapped + return inner + +######################################## + +# Respond to initial 'GET /' or 'GET /' with a redirect to '/remote/login?realm=' +# [Save want_2fa query parameter in the session for use later] +@app.route('/') +@app.route('/') +def realm(realm=None): + session.update(step='initial-GET', want_2fa='want_2fa' in request.args) + # print(session) + return redirect(url_for('login', realm=realm or None)) + + +# Respond to 'GET /remote/login?realm=' with a placeholder stub (since OpenConnect doesn't even try to parse the form) +# [Save realm in the session for verification of client state later] +@app.route('/remote/login') +def login(): + realm = request.args.get('realm') + session.update(step='GET-login-form', realm=realm or '') + return f'login page for realm {realm!r}' + + +# Respond to 'POST /remote/logincheck' +@app.route('/remote/logincheck', methods=['POST']) +def logincheck(): + want_2fa = session.get('want_2fa') + + if (want_2fa and 'code' in request.form): + return complete_2fa() + elif (want_2fa and 'credential' in request.form): + return send_2fa_challenge() + elif ('credential' in request.form): + return complete_non_2fa() + abort(405) + + +# 2FA completion: ensure that client has parroted back the same values +# for username, reqid, polid, grp, portal, magic +# [Save code in the session for potential use later] +@check_form_against_session('username', 'reqid', 'polid', 'grp', 'portal', 'magic') +def complete_2fa(): + session.update(step='complete-2FA', code=request.form.get('code')) + # print(session) + + resp = make_response('ret=1,redir=/remote/fortisslvpn_xml') + resp.set_cookie('SVPNCOOKIE', cookify(dict(session))) + return resp + + +# 2FA initial login: ensure that client has sent the right realm value, and +# reply with a token challenge containing all known fields. +# [Save username, credential, and challenge fields in the session for verification of client state later] +@check_form_against_session('realm') +def send_2fa_challenge(): + session.update(step='send-2FA-challenge', username=request.form.get('username'), credential=request.form.get('credential'), + reqid=str(random.randint(10_000_000, 99_000_000)), polid='1-1-'+str(random.randint(10_000_000, 99_000_000)), + magic='1-'+str(random.randint(10_000_000, 99_000_000)), portal=random.choice('ABCD'), grp=random.choice('EFGH')) + # print(session) + + return ('ret=2,reqid={reqid},polid={polid},grp={grp},portal={portal},magic={magic},' + 'tokeninfo=,chal_msg=Please enter your token code'.format(**session), + {'content-type': 'text/plain'}) + + +# Non-2FA login: ensure that client has sent the right realm value +@check_form_against_session('realm') +def complete_non_2fa(): + session.update(step='complete-non-2FA', username=request.form.get('username'), credential=request.form.get('credential')) + # print(session) + + resp = make_response('ret=1,redir=/remote/fortisslvpn_xml', {'content-type': 'text/plain'}) + resp.set_cookie('SVPNCOOKIE', cookify(dict(session))) + return resp + + +# Respond to 'GET /remote/logout' by clearing session and SVPNCOOKIE +@app.route('/remote/logout') +@require_SVPNCOOKIE +def logout(): + session.clear() + resp = make_response('successful logout') + resp.set_cookie('SVPNCOOKIE', '') + return resp + + +app.run(host=app.config['HOST'], port=app.config['PORT'], debug=app.config['DEBUG'], + ssl_context=context, use_debugger=False) -- 2.50.1