]> www.infradead.org Git - users/dwmw2/openconnect.git/commitdiff
add fake-juniper-server.py and tests/juniper-auth
authorDaniel Lenski <dlenski@gmail.com>
Wed, 31 Mar 2021 08:08:01 +0000 (01:08 -0700)
committerDaniel Lenski <dlenski@gmail.com>
Fri, 2 Apr 2021 08:59:53 +0000 (01:59 -0700)
Flask-based tests of Juniper authentication forms handling. Currently tested cases are:

- frmLogin with username/password
- frmLogin with username/password/authgroup
- frmLogin with username/password/token-as-2nd-password
- frmLogin with username/password ⇒ frmTotpToken
- frmLogin with username/password → frmDefender → frmConfirmation
- frmLogin with username/password → frmNextToken

Signed-off-by: Daniel Lenski <dlenski@gmail.com>
oncp.c
tests/Makefile.am
tests/fake-juniper-server.py [new file with mode: 0755]
tests/juniper-auth [new file with mode: 0755]

diff --git a/oncp.c b/oncp.c
index 1c5b8995171721c0df87a9b166e3dd308c789185..32798fa5aa5b4216459e3d58b370bdedf5e3e01f 100644 (file)
--- a/oncp.c
+++ b/oncp.c
@@ -535,7 +535,7 @@ int oncp_connect(struct openconnect_info *vpninfo)
                vpn_progress(vpninfo, PRG_ERR,
                             _("Unexpected %d result from server\n"),
                             ret);
-               ret = -EINVAL;
+               ret = (ret >= 400 && ret <= 499) ? -EPERM : -EINVAL;
                goto out;
        }
 
index 505aee098c2aacb9eaeff437948462e5faa3b11d..805285c00ae5da7e868a9519733806e95d495a7b 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 fake-fortinet-server.py fake-f5-server.py
+       suppressions.lsan fake-fortinet-server.py fake-f5-server.py fake-juniper-server.py
 
 dist_check_SCRIPTS = autocompletion
 
@@ -64,6 +64,7 @@ dist_check_SCRIPTS += auth-username-pass auth-certificate auth-nonascii cert-fin
 if HAVE_PYTHON36_FLASK
 dist_check_SCRIPTS += fortinet-auth-and-config
 dist_check_SCRIPTS += f5-auth-and-config
+dist_check_SCRIPTS += juniper-auth
 endif
 
 if TEST_PKCS11
diff --git a/tests/fake-juniper-server.py b/tests/fake-juniper-server.py
new file mode 100755 (executable)
index 0000000..4cbb056
--- /dev/null
@@ -0,0 +1,211 @@
+#!/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 Juniper
+# server enough to test OpenConnect's authentication behavior against it.
+########################################
+
+import sys
+import ssl
+import random
+import base64
+import time
+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_DSID(fn):
+    @wraps(fn)
+    def wrapped(*args, **kwargs):
+        if not request.cookies.get('DSID'):
+            session.clear()
+            return redirect(url_for('get_policy'))
+        return fn(*args, **kwargs)
+    return wrapped
+
+def check_form_against_session(*fields, use_query=False):
+    def inner(fn):
+        @wraps(fn)
+        def wrapped(*args, **kwargs):
+            source = request.args if use_query else request.form
+            source_name = 'args' if use_query else 'form'
+            for f in fields:
+                assert session.get(f) == source.get(f), \
+                    f'at step {session.get("step")}: {source_name} {f!r} {source.get(f)!r} != session {f!r} {session.get(f)!r}'
+            return fn(*args, **kwargs)
+        return wrapped
+    return inner
+
+########################################
+
+# Respond to initial 'GET /' with a redirect to '/dana-na/auth/url_default/welcome.cgi'
+# [Save in the session for use later:
+#   list of realms,
+#   session confirmation requirement,
+#   token/2FA form name (can be frmLogin, for 2-password-in-one-form option)]
+@app.route('/')
+def root():
+    realms = request.args.get('realms')
+    confirm = bool(request.args.get('confirm'))
+    token_form = request.args.get('token_form')
+    session.update(step='initial-GET', realms=realms and realms.split(','),
+                   confirm=confirm, token_form=token_form)
+    # print(session)
+    return redirect(url_for('frmLogin'))
+
+
+# frmLogin
+@app.route('/dana-na/auth/url_default/welcome.cgi')
+def frmLogin():
+    session.update(step='GET-frmLogin')
+    realms = session.get('realms')
+    token_form = session.get('token_form')
+    sel = token = ''
+    if realms:
+        sel = '<select name="realm">%s</select>' % ''.join(
+            '<option value="%d">%s</option>' % nv for nv in enumerate(realms))
+    if token_form == 'frmLogin':
+        token = '<input type="password" name="token_as_second_password"/>'
+
+    return '''
+<html><body><form name="frmLogin" method="post" action="%s">
+<input type="text" name="username"/>
+<input type="password" name="password"/>
+%s<input type="submit" value="Sign In" name="btnSubmit"/>
+%s</form></body></html>''' % (url_for('frmLogin_post'), token, sel)
+
+
+# frmLogin POST-response
+# This either yields a successful DSID cookie, or redirects to a confirmation page,
+# depending on session['confirm']
+@app.route('/dana-na/auth/url_default/login.cgi', methods=['POST'])
+def frmLogin_post():
+    realms = session.get('realms')
+    confirm = session.get('confirm')
+    token_form = session.get('token_form')
+    if realms:
+        assert 0 <= int(request.form.get('realm',-1)) < len(realms)
+    session.update(step='POST-login', username=request.form.get('username'),
+                   password=request.form.get('password'),
+                   realm=request.form.get('realm'))
+    # print(session)
+
+    need_confirm = need_token = False
+    got_confirm = got_token = None
+    if confirm:
+        need_confirm = request.form.get('btnContinue') is None
+        got_confirm = not need_confirm
+    if token_form:
+        need_token = request.form.get('btnAction') is None and request.form.get('totpactionEnter') is None and request.form.get('token_as_second_password') is None
+        got_token = not need_token
+    session.update(got_token=got_token, got_confirm=got_confirm)
+
+    if need_token and not got_confirm:
+        return redirect(url_for('frm2FA'))
+    elif need_confirm:
+        return redirect(url_for('frmConfirmation'))
+    else:
+        resp = redirect(url_for('webtop'))
+        resp.set_cookie('DSID', cookify(dict(session)))
+        return resp
+
+
+# 2FA forms (frmDefender, frmNextToken, or frmTotpToken)
+# This redirects back to frmLogin_POST
+@app.route('/dana-na/auth/url_default/token.cgi')
+def frm2FA():
+    token_form = session.get('token_form')
+    submit_button = ('totpactionEnter' if token_form == 'frmTotpToken' else 'btnAction')
+    session.update(step='GET-frmConfirm')
+    return '''
+<html><body><form name="%s" method="post" action="%s">
+Enter your 2FA token code.
+<input type="hidden" name="username" value="%s"/>
+<input type="password" name="token_code"/>
+<input type="hidden" name="realm" value="%s"/>
+<input type="submit" name="%s" value="Sign In"/>
+</form></body></html>''' % (
+    token_form,
+    url_for('frmLogin_post'), session.get('username'), session.get('realm'),
+    submit_button
+)
+
+
+# frmConfirmation
+# This redirects back to frmLogin_POST
+@app.route('/dana-na/auth/url_default/confirm.cgi')
+def frmConfirmation():
+    session.update(step='GET-frmConfirm')
+    return '''
+<html><body><form name="frmConfirmation" method="post" action="%s">
+Confirm your login for whatever reason.
+<input type="hidden" name="username" value="%s"/>
+<input type="hidden" name="password" value="%s"/>
+<input type="hidden" name="realm" value="%s"/>
+<input type="submit" name="btnContinue" value="Confirm Login"/>
+</form></body></html>''' % (
+    url_for('frmLogin_post'), session.get('username'), session.get('password'),
+    session.get('realm'))
+
+
+# stand-in for the "webtop" UI if logged in through a browser
+@app.route('/dana/home/starter0.cgi')
+def webtop():
+    session.update(step='POST-login-webtop')
+    # print(session)
+
+    return 'some junk HTML webtop'
+
+
+# respond to faux-CONNECT 'POST /dana/na?prot=1&svc=4' with 401
+@app.route('/dana/js', methods=['POST'])
+@require_DSID
+def tunnel_post():
+    session.update(step='POST-tunnel')
+    assert request.args.get('prot') == '1' and request.args.get('svc') == '4'
+    assert request.headers['Connection'].lower().strip() == 'close'
+    abort(401)
+
+
+# Respond to 'GET /dana-na/auth/logout.cgi' by clearing session and DSID
+@app.route('/dana-na/auth/logout.cgi')
+@require_DSID
+def logout():
+    session.clear()
+    resp = make_response('successful logout')
+    resp.set_cookie('DSID', '')
+    return resp
+
+
+app.run(host=app.config['HOST'], port=app.config['PORT'], debug=app.config['DEBUG'],
+        ssl_context=context, use_debugger=False)
diff --git a/tests/juniper-auth b/tests/juniper-auth
new file mode 100755 (executable)
index 0000000..7533972
--- /dev/null
@@ -0,0 +1,84 @@
+#!/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
+FAKE_TOKEN="--token-mode=totp --token-secret=ABCD"
+
+echo "Testing Juniper auth against fake server ... "
+
+OCSERV=./fake-juniper-server.py
+launch_simple_sr_server $ADDRESS 443 $CERT $KEY > /dev/null 2>&1
+PID=$!
+wait_server $PID 1
+
+echo -n "frmLogin with username/password"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443 -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password/authgroup"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443/?realms=xyz,abc,def --authgroup=abc -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password/token-as-2nd-password"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443/?token_form=frmLogin -u test $FAKE_TOKEN $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password → frmTotpToken"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443/?token_form=frmTotpToken -u test $FAKE_TOKEN $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password → frmDefender → frmConfirmation"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q "$ADDRESS:443/?token_form=frmDefender&confirm=1" -u test $FAKE_TOKEN $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password → frmNextToken"
+( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443/?token_form=frmNextToken -u test $FAKE_TOKEN $FINGERPRINT --cookieonly >/dev/null 2>&1) ||
+    fail $PID "Could not receive cookie from fake Juniper server"
+
+echo ok
+
+echo -n "frmLogin with username/password, then proceeding to tunnel stage... "
+echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=nc -q $ADDRESS:443 -u test $FINGERPRINT >/dev/null 2>&1
+test $? = 2 || # what OpenConnect returns when server rejects cookie upon tunnel connection, as the fake server does
+    fail $PID "Something went wrong in fake Juniper server (other than the expected rejection of cookie)"
+
+echo ok
+
+cleanup
+
+exit 0