Still prelim work
This commit is contained in:
1
script/__init__.py
Normal file
1
script/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__author__ = 'rolandh'
|
40
setup.py
Normal file
40
setup.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 Umea Universitet, Sweden
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
__author__ = 'rohe0002'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="oic",
|
||||||
|
version="0.3.0",
|
||||||
|
description="SAML2 test tool",
|
||||||
|
author = "Roland Hedberg",
|
||||||
|
author_email = "roland.hedberg@adm.umu.se",
|
||||||
|
license="Apache 2.0",
|
||||||
|
packages=["idp_test"],
|
||||||
|
package_dir = {"": "src"},
|
||||||
|
classifiers = ["Development Status :: 4 - Beta",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules"],
|
||||||
|
install_requires = ["pysaml2",
|
||||||
|
"mechanize",
|
||||||
|
"argparse",
|
||||||
|
"beautifulsoup4"],
|
||||||
|
|
||||||
|
zip_safe=False,
|
||||||
|
)
|
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__author__ = 'rolandh'
|
289
src/idp_test/__init__.py
Normal file
289
src/idp_test/__init__.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__author__ = 'rolandh'
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
def exception_trace(tag, exc, log=None):
|
||||||
|
message = traceback.format_exception(*sys.exc_info())
|
||||||
|
if log:
|
||||||
|
log.error("[%s] ExcList: %s" % (tag, "".join(message),))
|
||||||
|
log.error("[%s] Exception: %s" % (tag, exc))
|
||||||
|
else:
|
||||||
|
print >> sys.stderr, "[%s] ExcList: %s" % (tag, "".join(message),)
|
||||||
|
print >> sys.stderr, "[%s] Exception: %s" % (tag, exc)
|
||||||
|
|
||||||
|
class SAML2(object):
|
||||||
|
client_args = ["client_id", "redirect_uris", "password"]
|
||||||
|
|
||||||
|
def __init__(self, operations_mod, client_class, msgfactory):
|
||||||
|
self.operations_mod = operations_mod
|
||||||
|
self.client_class = client_class
|
||||||
|
self.client = None
|
||||||
|
#self.trace = Trace()
|
||||||
|
self.msgfactory = msgfactory
|
||||||
|
|
||||||
|
self._parser = argparse.ArgumentParser()
|
||||||
|
self._parser.add_argument('-d', dest='debug', action='store_true',
|
||||||
|
help="Print debug information")
|
||||||
|
self._parser.add_argument('-v', dest='verbose', action='store_true',
|
||||||
|
help="Print runtime information")
|
||||||
|
self._parser.add_argument('-C', dest="ca_certs",
|
||||||
|
help="CA certs to use to verify HTTPS server certificates, if HTTPS is used and no server CA certs are defined then no cert verification is done")
|
||||||
|
self._parser.add_argument('-J', dest="json_config_file",
|
||||||
|
help="Script configuration")
|
||||||
|
self._parser.add_argument("-l", dest="list", action="store_true",
|
||||||
|
help="List all the test flows as a JSON object")
|
||||||
|
self._parser.add_argument("-H", dest="host", default="example.com",
|
||||||
|
help="Which host the script is running on, used to construct the key export URL")
|
||||||
|
self._parser.add_argument("flow", nargs="?", help="Which test flow to run")
|
||||||
|
|
||||||
|
self.args = None
|
||||||
|
self.pinfo = None
|
||||||
|
self.sequences = []
|
||||||
|
self.function_args = {}
|
||||||
|
self.signing_key = None
|
||||||
|
self.encryption_key = None
|
||||||
|
self.test_log = []
|
||||||
|
self.environ = {}
|
||||||
|
self._pop = None
|
||||||
|
|
||||||
|
def parse_args(self):
|
||||||
|
self.json_config= self.json_config_file()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.features = self.json_config["features"]
|
||||||
|
except KeyError:
|
||||||
|
self.features = {}
|
||||||
|
|
||||||
|
self.pinfo = self.provider_info()
|
||||||
|
self.client_conf(self.client_args)
|
||||||
|
|
||||||
|
def json_config_file(self):
|
||||||
|
if self.args.json_config_file == "-":
|
||||||
|
return json.loads(sys.stdin.read())
|
||||||
|
else:
|
||||||
|
return json.loads(open(self.args.json_config_file).read())
|
||||||
|
|
||||||
|
def test_summation(self, id):
|
||||||
|
status = 0
|
||||||
|
for item in self.test_log:
|
||||||
|
if item["status"] > status:
|
||||||
|
status = item["status"]
|
||||||
|
|
||||||
|
if status == 0:
|
||||||
|
status = 1
|
||||||
|
|
||||||
|
sum = {
|
||||||
|
"id": id,
|
||||||
|
"status": status,
|
||||||
|
"tests": self.test_log
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == 5:
|
||||||
|
sum["url"] = self.test_log[-1]["url"]
|
||||||
|
sum["htmlbody"] = self.test_log[-1]["message"]
|
||||||
|
|
||||||
|
return sum
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.args = self._parser.parse_args()
|
||||||
|
|
||||||
|
if self.args.list:
|
||||||
|
return self.operations()
|
||||||
|
else:
|
||||||
|
if not self.args.flow:
|
||||||
|
raise Exception("Missing flow specification")
|
||||||
|
self.args.flow = self.args.flow.strip("'")
|
||||||
|
self.args.flow = self.args.flow.strip('"')
|
||||||
|
|
||||||
|
flow_spec = self.operations_mod.FLOWS[self.args.flow]
|
||||||
|
try:
|
||||||
|
block = flow_spec["block"]
|
||||||
|
except KeyError:
|
||||||
|
block = {}
|
||||||
|
|
||||||
|
self.parse_args()
|
||||||
|
_seq = self.make_sequence()
|
||||||
|
interact = self.get_interactions()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.do_features(interact, _seq, block)
|
||||||
|
except Exception,exc:
|
||||||
|
exception_trace("do_features", exc)
|
||||||
|
_output = {"status": 4,
|
||||||
|
"tests": [{"status": 4,
|
||||||
|
"message":"Couldn't run testflow: %s" % exc,
|
||||||
|
"id": "verify_features",
|
||||||
|
"name": "Make sure you don't do things you shouldn't"}]}
|
||||||
|
#print >> sys.stdout, json.dumps(_output)
|
||||||
|
return
|
||||||
|
|
||||||
|
tests = self.get_test()
|
||||||
|
self.client.state = "STATE0"
|
||||||
|
|
||||||
|
self.environ.update({"provider_info": self.pinfo,
|
||||||
|
"client": self.client})
|
||||||
|
|
||||||
|
try:
|
||||||
|
except_exception = flow_spec["except_exception"]
|
||||||
|
except KeyError:
|
||||||
|
except_exception = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.args.verbose:
|
||||||
|
print >> sys.stderr, "Set up done, running sequence"
|
||||||
|
testres, trace = run_sequence(self.client, _seq, self.trace,
|
||||||
|
interact, self.msgfactory,
|
||||||
|
self.environ, tests,
|
||||||
|
self.json_config["features"],
|
||||||
|
self.args.verbose, self.cconf,
|
||||||
|
except_exception)
|
||||||
|
self.test_log.extend(testres)
|
||||||
|
sum = self.test_summation(self.args.flow)
|
||||||
|
print >>sys.stdout, json.dumps(sum)
|
||||||
|
if sum["status"] > 1 or self.args.debug:
|
||||||
|
print >>sys.stderr, trace
|
||||||
|
except Exception, err:
|
||||||
|
#print >> sys.stderr, self.trace
|
||||||
|
print err
|
||||||
|
exception_trace("RUN", err)
|
||||||
|
|
||||||
|
#if self._pop is not None:
|
||||||
|
# self._pop.terminate()
|
||||||
|
if "keyprovider" in self.environ and self.environ["keyprovider"]:
|
||||||
|
# os.kill(self.environ["keyprovider"].pid, signal.SIGTERM)
|
||||||
|
self.environ["keyprovider"].terminate()
|
||||||
|
|
||||||
|
def operations(self):
|
||||||
|
lista = []
|
||||||
|
for key,val in self.operations_mod.FLOWS.items():
|
||||||
|
item = {"id": key,
|
||||||
|
"name": val["name"],}
|
||||||
|
try:
|
||||||
|
_desc = val["descr"]
|
||||||
|
if isinstance(_desc, basestring):
|
||||||
|
item["descr"] = _desc
|
||||||
|
else:
|
||||||
|
item["descr"] = "\n".join(_desc)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for key in ["depends", "endpoints"]:
|
||||||
|
try:
|
||||||
|
item[key] = val[key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lista.append(item)
|
||||||
|
|
||||||
|
print json.dumps(lista)
|
||||||
|
|
||||||
|
def provider_info(self):
|
||||||
|
# Should provide a Metadata class
|
||||||
|
res = {}
|
||||||
|
_jc = self.json_config["provider"]
|
||||||
|
|
||||||
|
# Backward compatible
|
||||||
|
if "endpoints" in _jc:
|
||||||
|
try:
|
||||||
|
for endp, url in _jc["endpoints"].items():
|
||||||
|
res[endp] = url
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for key in ProviderConfigurationResponse.c_param.keys():
|
||||||
|
try:
|
||||||
|
res[key] = _jc[key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def do_features(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def export(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def client_conf(self, cprop):
|
||||||
|
if self.args.ca_certs:
|
||||||
|
self.client = self.client_class(ca_certs=self.args.ca_certs)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.client = self.client_class(
|
||||||
|
ca_certs=self.json_config["ca_certs"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
self.client = self.client_class()
|
||||||
|
|
||||||
|
#self.client.http_request = self.client.http.crequest
|
||||||
|
|
||||||
|
# set the endpoints in the Client from the provider information
|
||||||
|
# If they are statically configured, if dynamic it happens elsewhere
|
||||||
|
for key, val in self.pinfo.items():
|
||||||
|
if key.endswith("_endpoint"):
|
||||||
|
setattr(self.client, key, val)
|
||||||
|
|
||||||
|
# Client configuration
|
||||||
|
self.cconf = self.json_config["client"]
|
||||||
|
# replace pattern with real value
|
||||||
|
_h = self.args.host
|
||||||
|
self.cconf["redirect_uris"] = [p % _h for p in self.cconf["redirect_uris"]]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.client_prefs = self.cconf["preferences"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# set necessary information in the Client
|
||||||
|
for prop in cprop:
|
||||||
|
try:
|
||||||
|
setattr(self.client, prop, self.cconf[prop])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def make_sequence(self):
|
||||||
|
# Whatever is specified on the command line takes precedences
|
||||||
|
if self.args.flow:
|
||||||
|
sequence = flow2sequence(self.operations_mod, self.args.flow)
|
||||||
|
elif self.json_config and "flow" in self.json_config:
|
||||||
|
sequence = flow2sequence(self.operations_mod,
|
||||||
|
self.json_config["flow"])
|
||||||
|
else:
|
||||||
|
sequence = None
|
||||||
|
|
||||||
|
return sequence
|
||||||
|
|
||||||
|
def get_interactions(self):
|
||||||
|
interactions = []
|
||||||
|
|
||||||
|
if self.json_config:
|
||||||
|
try:
|
||||||
|
interactions = self.json_config["interaction"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.args.interactions:
|
||||||
|
_int = self.args.interactions.replace("\'", '"')
|
||||||
|
if interactions:
|
||||||
|
interactions.update(json.loads(_int))
|
||||||
|
else:
|
||||||
|
interactions = json.loads(_int)
|
||||||
|
|
||||||
|
return interactions
|
||||||
|
|
||||||
|
def get_test(self):
|
||||||
|
if self.args.flow:
|
||||||
|
flow = self.operations_mod.FLOWS[self.args.flow]
|
||||||
|
elif self.json_config and "flow" in self.json_config:
|
||||||
|
flow = self.operations_mod.FLOWS[self.json_config["flow"]]
|
||||||
|
else:
|
||||||
|
flow = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return flow["tests"]
|
||||||
|
except KeyError:
|
||||||
|
return []
|
||||||
|
|
392
src/idp_test/base.py
Normal file
392
src/idp_test/base.py
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from check import ExpectedError
|
||||||
|
from check import factory
|
||||||
|
|
||||||
|
__author__ = 'rohe0002'
|
||||||
|
|
||||||
|
import time
|
||||||
|
import cookielib
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
class FatalError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Trace(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.trace = []
|
||||||
|
self.start = time.time()
|
||||||
|
|
||||||
|
def request(self, msg):
|
||||||
|
delta = time.time() - self.start
|
||||||
|
self.trace.append("%f --> %s" % (delta, msg))
|
||||||
|
|
||||||
|
def reply(self, msg):
|
||||||
|
delta = time.time() - self.start
|
||||||
|
self.trace.append("%f <-- %s" % (delta, msg))
|
||||||
|
|
||||||
|
def info(self, msg):
|
||||||
|
delta = time.time() - self.start
|
||||||
|
self.trace.append("%f %s" % (delta, msg))
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
delta = time.time() - self.start
|
||||||
|
self.trace.append("%f [ERROR] %s" % (delta, msg))
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
delta = time.time() - self.start
|
||||||
|
self.trace.append("%f [WARNING] %s" % (delta, msg))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "\n". join([t.encode("utf-8") for t in self.trace])
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.trace = []
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self.trace[item]
|
||||||
|
|
||||||
|
def next(self):
|
||||||
|
for line in self.trace:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
def flow2sequence(operations, item):
|
||||||
|
flow = operations.FLOWS[item]
|
||||||
|
return [operations.PHASES[phase] for phase in flow["sequence"]]
|
||||||
|
|
||||||
|
def endpoint(client, base):
|
||||||
|
for _endp in client._endpoints:
|
||||||
|
if getattr(client, _endp) == base:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_severity(stat):
|
||||||
|
if stat["status"] >= 4:
|
||||||
|
raise FatalError
|
||||||
|
|
||||||
|
|
||||||
|
def pick_interaction(interactions, _base="", content="", req=None):
|
||||||
|
unic = content
|
||||||
|
if content:
|
||||||
|
_bs = BeautifulSoup(content)
|
||||||
|
else:
|
||||||
|
_bs = None
|
||||||
|
|
||||||
|
for interaction in interactions:
|
||||||
|
_match = 0
|
||||||
|
for attr, val in interaction["matches"].items():
|
||||||
|
if attr == "url":
|
||||||
|
if val == _base:
|
||||||
|
_match += 1
|
||||||
|
elif attr == "title":
|
||||||
|
if _bs is None:
|
||||||
|
break
|
||||||
|
if _bs.title is None:
|
||||||
|
break
|
||||||
|
if val in _bs.title.contents:
|
||||||
|
_match += 1
|
||||||
|
elif attr == "content":
|
||||||
|
if unic and val in unic:
|
||||||
|
_match += 1
|
||||||
|
elif attr == "class":
|
||||||
|
if req and val == req:
|
||||||
|
_match += 1
|
||||||
|
|
||||||
|
if _match == len(interaction["matches"]):
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
raise KeyError("No interaction matched")
|
||||||
|
|
||||||
|
ORDER = ["url", "response", "content"]
|
||||||
|
|
||||||
|
def run_sequence(client, sequence, trace, interaction, msgfactory,
|
||||||
|
environ=None, tests=None, features=None, verbose=False,
|
||||||
|
cconf=None, except_exception=None):
|
||||||
|
item = []
|
||||||
|
response = None
|
||||||
|
content = None
|
||||||
|
url = ""
|
||||||
|
test_output = []
|
||||||
|
_keystore = client.keystore
|
||||||
|
features = features or {}
|
||||||
|
|
||||||
|
cjar = {"browser": cookielib.CookieJar(),
|
||||||
|
"rp": cookielib.CookieJar(),
|
||||||
|
"service": cookielib.CookieJar()}
|
||||||
|
|
||||||
|
environ["sequence"] = sequence
|
||||||
|
environ["cis"] = []
|
||||||
|
environ["trace"] = trace
|
||||||
|
environ["responses"] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for creq in sequence:
|
||||||
|
req = creq()
|
||||||
|
cfunc = getattr(client, "create_%s" % req.request)
|
||||||
|
if trace:
|
||||||
|
trace.info(70*"=")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_pretests = req.tests["pre"]
|
||||||
|
for test in _pretests:
|
||||||
|
chk = test()
|
||||||
|
stat = chk(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cfunc(**req.args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for test in req.tests["post"]:
|
||||||
|
if isinstance(test, tuple):
|
||||||
|
test, kwargs = test
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
|
chk = test(**kwargs)
|
||||||
|
stat = chk(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
if isinstance(chk, ExpectedError):
|
||||||
|
item.append(stat["temp"])
|
||||||
|
del stat["temp"]
|
||||||
|
url = None
|
||||||
|
break
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except FatalError:
|
||||||
|
raise
|
||||||
|
except Exception, err:
|
||||||
|
environ["exception"] = err
|
||||||
|
chk = factory("exception")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
raise FatalError()
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
done = True
|
||||||
|
elif url:
|
||||||
|
done = False
|
||||||
|
else:
|
||||||
|
done = True
|
||||||
|
|
||||||
|
while not done:
|
||||||
|
while response.status_code in [302, 301, 303]:
|
||||||
|
url = response.headers["location"]
|
||||||
|
|
||||||
|
trace.reply("REDIRECT TO: %s" % url)
|
||||||
|
# If back to me
|
||||||
|
for_me = False
|
||||||
|
for redirect_uri in client.redirect_uris:
|
||||||
|
if url.startswith(redirect_uri):
|
||||||
|
# Back at the RP
|
||||||
|
environ["client"].cookiejar = cjar["rp"]
|
||||||
|
for_me=True
|
||||||
|
|
||||||
|
if for_me:
|
||||||
|
done = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
part = do_request(client, url, "GET", trace=trace)
|
||||||
|
except Exception, err:
|
||||||
|
raise FatalError("%s" % err)
|
||||||
|
environ.update(dict(zip(ORDER, part)))
|
||||||
|
(url, response, content) = part
|
||||||
|
|
||||||
|
check = factory("check-http-response")()
|
||||||
|
stat = check(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
|
||||||
|
if done:
|
||||||
|
break
|
||||||
|
|
||||||
|
_base = url.split("?")[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
_spec = pick_interaction(interaction, _base, content)
|
||||||
|
except KeyError:
|
||||||
|
if creq.method == "POST":
|
||||||
|
break
|
||||||
|
elif not req.request in ["AuthorizationRequest",
|
||||||
|
"OpenIDRequest"]:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
_check = getattr(req, "interaction_check")
|
||||||
|
except AttributeError:
|
||||||
|
_check = None
|
||||||
|
|
||||||
|
if _check:
|
||||||
|
chk = factory("interaction-check")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
raise FatalError()
|
||||||
|
else:
|
||||||
|
chk = factory("interaction-needed")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
raise FatalError()
|
||||||
|
|
||||||
|
if len(_spec) > 2:
|
||||||
|
trace.info(">> %s <<" % _spec["page-type"])
|
||||||
|
if _spec["page-type"] == "login":
|
||||||
|
environ["login"] = content
|
||||||
|
|
||||||
|
_op = Operation(_spec["control"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
part = _op(environ, trace, url, response, content, features)
|
||||||
|
environ.update(dict(zip(ORDER, part)))
|
||||||
|
(url, response, content) = part
|
||||||
|
|
||||||
|
check = factory("check-http-response")()
|
||||||
|
stat = check(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
except FatalError:
|
||||||
|
raise
|
||||||
|
except Exception, err:
|
||||||
|
environ["exception"] = err
|
||||||
|
chk = factory("exception")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
raise FatalError
|
||||||
|
|
||||||
|
# if done:
|
||||||
|
# break
|
||||||
|
|
||||||
|
info = None
|
||||||
|
qresp = None
|
||||||
|
resp_type = resp.type
|
||||||
|
if response:
|
||||||
|
try:
|
||||||
|
ctype = response.headers["content-type"]
|
||||||
|
if ctype == "application/jwt":
|
||||||
|
resp_type = "jwt"
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
pass
|
||||||
|
elif not url:
|
||||||
|
if isinstance(content, Message):
|
||||||
|
qresp = content
|
||||||
|
elif response.status_code == 200:
|
||||||
|
info = content
|
||||||
|
elif resp.where == "url" or response.status_code == 302:
|
||||||
|
try:
|
||||||
|
info = response.headers["location"]
|
||||||
|
resp_type = "urlencoded"
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
_check = getattr(req, "interaction_check", None)
|
||||||
|
except AttributeError:
|
||||||
|
_check = None
|
||||||
|
|
||||||
|
if _check:
|
||||||
|
chk = factory("interaction-check")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
raise FatalError()
|
||||||
|
else:
|
||||||
|
chk = factory("missing-redirect")()
|
||||||
|
stat = chk(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
else:
|
||||||
|
check = factory("check_content_type_header")()
|
||||||
|
stat = check(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
info = content
|
||||||
|
|
||||||
|
if info and resp.response:
|
||||||
|
if isinstance(resp.response, basestring):
|
||||||
|
response = msgfactory(resp.response)
|
||||||
|
else:
|
||||||
|
response = resp.response
|
||||||
|
|
||||||
|
chk = factory("response-parse")()
|
||||||
|
environ["response_type"] = response.__name__
|
||||||
|
environ["responses"].append((response, info))
|
||||||
|
try:
|
||||||
|
qresp = client.parse_response(response, info, resp_type,
|
||||||
|
client.state,
|
||||||
|
keystore=_keystore,
|
||||||
|
client_id=client.client_id,
|
||||||
|
scope="openid")
|
||||||
|
if trace and qresp:
|
||||||
|
trace.info("[%s]: %s" % (qresp.type(),
|
||||||
|
qresp.to_dict()))
|
||||||
|
item.append(qresp)
|
||||||
|
environ["response_message"] = qresp
|
||||||
|
err = None
|
||||||
|
except Exception, err:
|
||||||
|
environ["exception"] = "%s" % err
|
||||||
|
qresp = None
|
||||||
|
if err and except_exception:
|
||||||
|
if isinstance(err, except_exception):
|
||||||
|
trace.info("Got expected exception: %s [%s]" % (err,
|
||||||
|
err.__class__.__name__))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
stat = chk(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
|
||||||
|
if qresp:
|
||||||
|
try:
|
||||||
|
for test in resp.tests["post"]:
|
||||||
|
if isinstance(test, tuple):
|
||||||
|
test, kwargs = test
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
|
chk = test(**kwargs)
|
||||||
|
stat = chk(environ, test_output)
|
||||||
|
check_severity(stat)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
resp(environ, qresp)
|
||||||
|
|
||||||
|
if tests is not None:
|
||||||
|
environ["item"] = item
|
||||||
|
for test, args in tests:
|
||||||
|
if isinstance(test, basestring):
|
||||||
|
chk = factory(test)(**args)
|
||||||
|
else:
|
||||||
|
chk = test(**args)
|
||||||
|
try:
|
||||||
|
check_severity(chk(environ, test_output))
|
||||||
|
except Exception, err:
|
||||||
|
raise FatalError("%s" % err)
|
||||||
|
|
||||||
|
except FatalError:
|
||||||
|
pass
|
||||||
|
except Exception, err:
|
||||||
|
environ["exception"] = err
|
||||||
|
chk = factory("exception")()
|
||||||
|
chk(environ, test_output)
|
||||||
|
|
||||||
|
return test_output, "%s" % trace
|
||||||
|
|
||||||
|
|
||||||
|
def run_sequences(client, sequences, trace, interaction,
|
||||||
|
verbose=False):
|
||||||
|
for sequence, endpoints, fid in sequences:
|
||||||
|
# clear cookie cache
|
||||||
|
client.grant.clear()
|
||||||
|
try:
|
||||||
|
client.http.cookiejar.clear()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
err = run_sequence(client, sequence, trace, interaction, verbose)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
print "%s - FAIL" % fid
|
||||||
|
print
|
||||||
|
if not verbose:
|
||||||
|
print trace
|
||||||
|
else:
|
||||||
|
print "%s - OK" % fid
|
||||||
|
|
||||||
|
trace.clear()
|
118
src/idp_test/check.py
Normal file
118
src/idp_test/check.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__author__ = 'rolandh'
|
||||||
|
|
||||||
|
INFORMATION = 0
|
||||||
|
OK = 1
|
||||||
|
WARNING = 2
|
||||||
|
ERROR = 3
|
||||||
|
CRITICAL = 4
|
||||||
|
INTERACTION = 5
|
||||||
|
|
||||||
|
STATUSCODE = ["INFORMATION", "OK", "WARNING", "ERROR", "CRITICAL",
|
||||||
|
"INTERACTION"]
|
||||||
|
|
||||||
|
class Check():
|
||||||
|
""" General test
|
||||||
|
"""
|
||||||
|
id = "check"
|
||||||
|
msg = "OK"
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self._status = OK
|
||||||
|
self._message = ""
|
||||||
|
self.content = None
|
||||||
|
self.url = ""
|
||||||
|
self._kwargs = kwargs
|
||||||
|
|
||||||
|
def _func(self, environ):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def __call__(self, environ=None, output=None):
|
||||||
|
_stat = self.response(**self._func(environ))
|
||||||
|
output.append(_stat)
|
||||||
|
return _stat
|
||||||
|
|
||||||
|
def response(self, **kwargs):
|
||||||
|
try:
|
||||||
|
name = " ".join([s.strip() for s in self.__doc__.strip().split("\n")])
|
||||||
|
except AttributeError:
|
||||||
|
name = ""
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"id": self.id,
|
||||||
|
"status": self._status,
|
||||||
|
"name": name
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._message:
|
||||||
|
res["message"] = self._message
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
res.update(kwargs)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
class ExpectedError(Check):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CriticalError(Check):
|
||||||
|
status = CRITICAL
|
||||||
|
|
||||||
|
class Error(Check):
|
||||||
|
status = ERROR
|
||||||
|
|
||||||
|
class Other(CriticalError):
|
||||||
|
""" Other error """
|
||||||
|
msg = "Other error"
|
||||||
|
|
||||||
|
class CheckHTTPResponse(CriticalError):
|
||||||
|
"""
|
||||||
|
Checks that the HTTP response status is within the 200 or 300 range
|
||||||
|
"""
|
||||||
|
id = "check-http-response"
|
||||||
|
msg = "IdP error"
|
||||||
|
|
||||||
|
def _func(self, environ):
|
||||||
|
_response = environ["response"]
|
||||||
|
_content = environ["content"]
|
||||||
|
|
||||||
|
res = {}
|
||||||
|
if _response.status_code >= 400 :
|
||||||
|
self._status = self.status
|
||||||
|
self._message = self.msg
|
||||||
|
# if CONT_JSON in _response.headers["content-type"]:
|
||||||
|
# try:
|
||||||
|
# err = ErrorResponse().deserialize(_content, "json")
|
||||||
|
# self._message = err.to_json()
|
||||||
|
# except Exception:
|
||||||
|
# res["content"] = _content
|
||||||
|
# else:
|
||||||
|
# res["content"] = _content
|
||||||
|
res["url"] = environ["url"]
|
||||||
|
res["http_status"] = _response.status_code
|
||||||
|
else:
|
||||||
|
# might still be an error message
|
||||||
|
try:
|
||||||
|
# err = ErrorResponse().deserialize(_content, "json")
|
||||||
|
# err.verify()
|
||||||
|
# self._message = err.to_json()
|
||||||
|
self._status = self.status
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
res["url"] = environ["url"]
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def factory(id):
|
||||||
|
for name, obj in inspect.getmembers(sys.modules[__name__]):
|
||||||
|
if inspect.isclass(obj):
|
||||||
|
try:
|
||||||
|
if obj.id == id:
|
||||||
|
return obj
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
95
src/idp_test/operations.py
Normal file
95
src/idp_test/operations.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from check import CheckHTTPResponse
|
||||||
|
|
||||||
|
__author__ = 'rolandh'
|
||||||
|
|
||||||
|
class Request():
|
||||||
|
request = ""
|
||||||
|
method = ""
|
||||||
|
lax = False
|
||||||
|
_request_args= {}
|
||||||
|
kw_args = {}
|
||||||
|
tests = {"post": [CheckHTTPResponse], "pre":[]}
|
||||||
|
|
||||||
|
def __init__(self, cconf=None):
|
||||||
|
self.cconf = cconf
|
||||||
|
self.request_args = self._request_args.copy()
|
||||||
|
|
||||||
|
#noinspection PyUnusedLocal
|
||||||
|
def __call__(self, environ, trace, location, response, content, features):
|
||||||
|
_client = environ["client"]
|
||||||
|
try:
|
||||||
|
kwargs = self.kw_args.copy()
|
||||||
|
except KeyError:
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
func = getattr(_client, "do_%s" % self.request)
|
||||||
|
|
||||||
|
ht_add = None
|
||||||
|
|
||||||
|
if "authn_method" in kwargs:
|
||||||
|
h_arg = _client.init_authentication_method(cis, **kwargs)
|
||||||
|
else:
|
||||||
|
h_arg = None
|
||||||
|
|
||||||
|
url, body, ht_args, cis = _client.uri_and_body(request, cis,
|
||||||
|
method=self.method,
|
||||||
|
request_args=_req)
|
||||||
|
|
||||||
|
environ["cis"].append(cis)
|
||||||
|
if h_arg:
|
||||||
|
ht_args.update(h_arg)
|
||||||
|
if ht_add:
|
||||||
|
ht_args.update({"headers": ht_add})
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
try:
|
||||||
|
oro = unpack(cis["request"])[1]
|
||||||
|
trace.request("OpenID Request Object: %s" % oro)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
trace.request("URL: %s" % url)
|
||||||
|
trace.request("BODY: %s" % body)
|
||||||
|
try:
|
||||||
|
trace.request("HEADERS: %s" % ht_args["headers"])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
response = _client.http_request(url, method=self.method, data=body,
|
||||||
|
**ht_args)
|
||||||
|
|
||||||
|
if trace:
|
||||||
|
trace.reply("RESPONSE: %s" % response)
|
||||||
|
trace.reply("CONTENT: %s" % response.text)
|
||||||
|
if response.status_code in [301, 302]:
|
||||||
|
trace.reply("LOCATION: %s" % response.headers["location"])
|
||||||
|
trace.reply("COOKIES: %s" % response.cookies)
|
||||||
|
# try:
|
||||||
|
# trace.reply("HeaderCookies: %s" % response.headers["set-cookie"])
|
||||||
|
# except KeyError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
return url, response, response.text
|
||||||
|
|
||||||
|
def update(self, dic):
|
||||||
|
_tmp = {"request": self.request_args.copy(), "kw": self.kw_args}
|
||||||
|
for key, val in self.rec_update(_tmp, dic).items():
|
||||||
|
setattr(self, "%s_args" % key, val)
|
||||||
|
|
||||||
|
def rec_update(self, dic0, dic1):
|
||||||
|
res = {}
|
||||||
|
for key, val in dic0.items():
|
||||||
|
if key not in dic1:
|
||||||
|
res[key] = val
|
||||||
|
else:
|
||||||
|
if isinstance(val, dict):
|
||||||
|
res[key] = self.rec_update(val, dic1[key])
|
||||||
|
else:
|
||||||
|
res[key] = dic1[key]
|
||||||
|
|
||||||
|
for key, val in dic1.items():
|
||||||
|
if key in dic0:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
res[key] = val
|
||||||
|
|
||||||
|
return res
|
226
src/idp_test/tmp.py
Normal file
226
src/idp_test/tmp.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
from oic.oauth2 import rndstr
|
||||||
|
from saml2 import config, NAMEID_FORMAT_EMAILADDRESS
|
||||||
|
from saml2 import samlp
|
||||||
|
from saml2 import BINDING_HTTP_POST
|
||||||
|
from saml2 import VERSION
|
||||||
|
|
||||||
|
from saml2.client import Saml2Client
|
||||||
|
from saml2.time_util import instant
|
||||||
|
|
||||||
|
__author__ = 'rolandh'
|
||||||
|
|
||||||
|
try:
|
||||||
|
from xmlsec_location import xmlsec_path
|
||||||
|
except ImportError:
|
||||||
|
xmlsec_path = '/opt/local/bin/xmlsec1'
|
||||||
|
|
||||||
|
cnf_dict = {
|
||||||
|
"entityid" : "urn:mace:example.com:saml:roland:sp",
|
||||||
|
"name" : "urn:mace:example.com:saml:roland:sp",
|
||||||
|
"description": "Test SP",
|
||||||
|
"service": {
|
||||||
|
"sp": {
|
||||||
|
"endpoints":{
|
||||||
|
"assertion_consumer_service": ["http://lingon.catalogix.se:8087/"],
|
||||||
|
},
|
||||||
|
"required_attributes": ["surName", "givenName", "mail"],
|
||||||
|
"optional_attributes": ["title"],
|
||||||
|
"idp": ["urn:mace:example.com:saml:roland:idp"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"key_file" : "test.key",
|
||||||
|
"cert_file" : "test.pem",
|
||||||
|
"ca_certs": "cacerts.txt",
|
||||||
|
"xmlsec_binary" : xmlsec_path,
|
||||||
|
"metadata": {
|
||||||
|
"local": ["idp.xml"],
|
||||||
|
},
|
||||||
|
"subject_data": "subject_data.db",
|
||||||
|
"accepted_time_diff": 60,
|
||||||
|
"attribute_map_dir" : "attributemaps",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
conf = config.SPConfig()
|
||||||
|
conf.load(cnf_dict)
|
||||||
|
client = Saml2Client(conf)
|
||||||
|
|
||||||
|
binding= BINDING_HTTP_POST
|
||||||
|
query_id = rndstr()
|
||||||
|
service_url = "https://example.com"
|
||||||
|
|
||||||
|
authn_request = {
|
||||||
|
#===== AuthRequest =====
|
||||||
|
"subject":{
|
||||||
|
"base_id":{
|
||||||
|
"name_qualifier":None,
|
||||||
|
"sp_name_qualifier":None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"name_id":{
|
||||||
|
"name_qualifier":None,
|
||||||
|
"sp_name_qualifier":None,
|
||||||
|
"format":None,
|
||||||
|
"sp_provided_id": None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"encrypted_id":{
|
||||||
|
"encrypted_data":None,
|
||||||
|
"encrypted_key":None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"subject_confirmation":[{
|
||||||
|
"base_id":{
|
||||||
|
"name_qualifier":None,
|
||||||
|
"sp_name_qualifier":None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"name_id":{
|
||||||
|
"name_qualifier":None,
|
||||||
|
"sp_name_qualifier":None,
|
||||||
|
"format":None,
|
||||||
|
"sp_provided_id": None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"encrypted_id":{
|
||||||
|
"encrypted_data":None,
|
||||||
|
"encrypted_key":None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"subject_confirmation_data":{
|
||||||
|
"not_before":None,
|
||||||
|
"not_on_or_after":None,
|
||||||
|
"recipient":None,
|
||||||
|
"in_response_to":None,
|
||||||
|
"address":None,
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
}],
|
||||||
|
"text":None,
|
||||||
|
"extension_elements":None,
|
||||||
|
"extension_attributes":None,
|
||||||
|
},
|
||||||
|
#NameIDPolicy
|
||||||
|
"name_id_policy":{
|
||||||
|
"format":NAMEID_FORMAT_EMAILADDRESS,
|
||||||
|
# NAMEID_FORMAT_EMAILADDRESS = (
|
||||||
|
# "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress")
|
||||||
|
# NAMEID_FORMAT_UNSPECIFIED = (
|
||||||
|
# "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified")
|
||||||
|
# NAMEID_FORMAT_ENCRYPTED = (
|
||||||
|
# "urn:oasis:names:tc:SAML:2.0:nameid-format:encrypted")
|
||||||
|
# NAMEID_FORMAT_PERSISTENT = (
|
||||||
|
# "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
|
||||||
|
# NAMEID_FORMAT_TRANSIENT = (
|
||||||
|
# "urn:oasis:names:tc:SAML:2.0:nameid-format:transient")
|
||||||
|
# NAMEID_FORMAT_ENTITY = (
|
||||||
|
# "urn:oasis:names:tc:SAML:2.0:nameid-format:entity")
|
||||||
|
|
||||||
|
"sp_name_qualifier":None,
|
||||||
|
"allow_create":None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
#saml.Conditions
|
||||||
|
"conditions":{
|
||||||
|
#Condition
|
||||||
|
"condition":[{}],
|
||||||
|
#AudienceRestriction
|
||||||
|
"audience_restriction":[{}],
|
||||||
|
#OneTimeUse
|
||||||
|
"one_time_use":[{}],
|
||||||
|
#ProxyRestriction
|
||||||
|
"proxy_restriction":[{}],
|
||||||
|
#not_before=None,
|
||||||
|
#not_on_or_after=None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
#RequestedAuthnContext
|
||||||
|
"requested_authn_context":{
|
||||||
|
#saml.AuthnContextClassRef
|
||||||
|
"authn_context_class_ref":None,
|
||||||
|
#saml.AuthnContextDeclRef
|
||||||
|
"authn_context_decl_ref":None,
|
||||||
|
#AuthnContextComparisonType_
|
||||||
|
"comparison":None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
#Scoping
|
||||||
|
"scoping":{
|
||||||
|
#IDPList
|
||||||
|
"idp_list":{
|
||||||
|
#IDPEntry
|
||||||
|
"idp_entry":{
|
||||||
|
"provider_id":None,
|
||||||
|
"name":None,
|
||||||
|
"loc":None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
#GetComplete
|
||||||
|
"get_complete":{},
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
#RequesterID
|
||||||
|
"requester_id":{},
|
||||||
|
#proxy_count=None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
},
|
||||||
|
"force_authn":None,
|
||||||
|
"is_passive":None,
|
||||||
|
"protocol_binding":None,
|
||||||
|
"assertion_consumer_service_index":None,
|
||||||
|
"assertion_consumer_service_url":None,
|
||||||
|
"attribute_consuming_service_index":None,
|
||||||
|
"provider_name":None,
|
||||||
|
#saml.Issuer
|
||||||
|
"issuer":{},
|
||||||
|
#ds.Signature
|
||||||
|
"signature":{},
|
||||||
|
#Extensions
|
||||||
|
"extensions":{},
|
||||||
|
"id":None,
|
||||||
|
"version":None,
|
||||||
|
"issue_instant":None,
|
||||||
|
"destination":None,
|
||||||
|
"consent":None,
|
||||||
|
#text=None,
|
||||||
|
#extension_elements=None,
|
||||||
|
#extension_attributes=None,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
request = samlp.AuthnRequest(
|
||||||
|
id= query_id,
|
||||||
|
version= VERSION,
|
||||||
|
issue_instant= instant(),
|
||||||
|
assertion_consumer_service_url= service_url,
|
||||||
|
protocol_binding= binding
|
||||||
|
)
|
Reference in New Issue
Block a user