From: Daniel Lenski Date: Wed, 31 Mar 2021 08:08:01 +0000 (-0700) Subject: add fake-juniper-server.py and tests/juniper-auth X-Git-Tag: v8.20~311^2~7 X-Git-Url: https://www.infradead.org/git/?a=commitdiff_plain;h=c1dd982412568988b0ec67ea40d8973f2b0d2eec;p=users%2Fdwmw2%2Fopenconnect.git add fake-juniper-server.py and tests/juniper-auth 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 --- diff --git a/oncp.c b/oncp.c index 1c5b8995..32798fa5 100644 --- 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; } diff --git a/tests/Makefile.am b/tests/Makefile.am index 505aee09..805285c0 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 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 index 00000000..4cbb0562 --- /dev/null +++ b/tests/fake-juniper-server.py @@ -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 + +######################################## +# 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 = '' % ''.join( + '' % nv for nv in enumerate(realms)) + if token_form == 'frmLogin': + token = '' + + return ''' +
+ + +%s +%s
''' % (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 ''' +
+Enter your 2FA token code. + + + + +
''' % ( + 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 ''' +
+Confirm your login for whatever reason. + + + + +
''' % ( + 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 index 00000000..75339721 --- /dev/null +++ b/tests/juniper-auth @@ -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 + +# 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