]> www.infradead.org Git - users/dwmw2/openconnect.git/commitdiff
add auth-fortinet tests
authorDaniel Lenski <dlenski@gmail.com>
Sun, 21 Feb 2021 23:02:44 +0000 (15:02 -0800)
committerDaniel Lenski <dlenski@gmail.com>
Mon, 29 Mar 2021 03:57:25 +0000 (20:57 -0700)
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 <dlenski@gmail.com>
configure.ac
tests/Makefile.am
tests/auth-fortinet [new file with mode: 0755]
tests/common.sh
tests/fake-fortinet-server.py [new file with mode: 0755]

index 2b5ca0e1b9813674ed3fe4634c8d70e839cbe986..9c83b2e411d461b95facf71bc72f3ead757e179b 100644 (file)
@@ -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/)
index 5493e6ca8e7baf06ddcf136899d30202b47aa97a..50d49a724a35165570334485d3858b53438c81ce 100644 (file)
@@ -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 (executable)
index 0000000..60d97ad
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>
+
+# 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
index f1aa14854ff221ac05c42a896e814cb4644380e4..822075060fbe411b665f6d5fa76a58fb8c4efca8 100644 (file)
@@ -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 (executable)
index 0000000..9d51bfb
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>
+
+########################################
+# 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 /<realm>' with a redirect to '/remote/login?realm=<realm>'
+# [Save want_2fa query parameter in the session for use later]
+@app.route('/')
+@app.route('/<realm>')
+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=<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)