Skip to content

Commit d8ba645

Browse files
committed
add WKIS micro services
1 parent c330483 commit d8ba645

File tree

3 files changed

+296
-0
lines changed

3 files changed

+296
-0
lines changed
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
""" implement key/valuer store """
2+
import pickle
3+
import redis
4+
from satosa.state import _AESCipher
5+
6+
class LocalStore():
7+
""" Store context objects in Redis.
8+
Create a new key when a new value is set.
9+
Delete key/value after reading it
10+
"""
11+
def __init__(self, encryption_key: str, redishost: str):
12+
self.redis = redis.Redis(host=redishost, port=6379)
13+
self.aes_cipher = _AESCipher(encryption_key)
14+
15+
def set(self, context: object) -> int:
16+
context_serlzd = pickle.dumps(context, pickle.HIGHEST_PROTOCOL)
17+
context_enc = self.aes_cipher.encrypt(context_serlzd)
18+
key = self.redis.incr('REDIRURL_sequence', 1)
19+
self.redis.set(key, context_serlzd, 1800) # generous 30 min timeout to complete SSO transaction
20+
return key
21+
22+
def get(self, key: int) -> object:
23+
context_serlzd = self.redis.get(key)
24+
self.redis.expire(key, 600) # delay deletion in case request is repeated due to network issues
25+
return pickle.loads(context_serlzd)
26+
+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
ADFS/SAML-Support for role selection and profile completion after a SAML-Response
3+
was issued using a redirect-to-idp flow.
4+
* Store AuthnRequest for later replay
5+
* Handle redirect-to-idp and replay AuthnRequest after redirect-to-idp flow
6+
7+
Persist state: Storing the the full context of the AuthnRequest in SATOSA_STATE is not feasible due to cookie size limitations.
8+
Instead, it is stored in a local redis store, and the key is stored in SATOSA_STATE.
9+
10+
The Redis interface is using a basic implementation creating a connection pool and TCP sockets for each call, which is OK for the modest deployment.
11+
(Instantiating a global connection pool across gunicorn worker threads would impose some additional complexity.)
12+
The AuthnRequest is stored unencrypted with the assumption that a stolen request cannot do harm,
13+
because the final Response will only be delivered to the metadata-specified ACS endpoint.
14+
15+
16+
"""
17+
18+
import logging
19+
import sys
20+
from typing import Tuple
21+
import satosa
22+
from .base import RequestMicroService, ResponseMicroService
23+
from satosa.micro_services.local_store import LocalStore
24+
25+
MIN_PYTHON = (3, 6)
26+
if sys.version_info < MIN_PYTHON:
27+
sys.exit("Python %s.%s or later is required.\n" % MIN_PYTHON)
28+
29+
STATE_KEY = "REDIRURLCONTEXT"
30+
31+
32+
class RedirectUrlRequest(RequestMicroService):
33+
""" Store AuthnRequest in SATOSA STATE in case it is required later for the RedirectUrl flow """
34+
def __init__(self, config: dict, *args, **kwargs):
35+
super().__init__(*args, **kwargs)
36+
self.local_store = LocalStore(config['db_encryption_key'], redishost=config.get('redis_host', 'localhost'))
37+
logging.info('RedirectUrlRequest microservice active')
38+
39+
def process(self, context: satosa.context.Context, internal_request: satosa.internal.InternalData) \
40+
-> Tuple[satosa.context.Context, satosa.internal.InternalData]:
41+
key = self.local_store.set(context)
42+
context.state[STATE_KEY] = str(key)
43+
logging.debug(f"RedirectUrlRequest: store context (stub)")
44+
return super().process(context, internal_request)
45+
46+
47+
class RedirectUrlResponse(ResponseMicroService):
48+
"""
49+
Handle following events:
50+
* Processing a SAML Response:
51+
if the redirectUrl attribute is set in the response/attribute statement:
52+
Redirect to responder
53+
* Processing a RedirectUrlResponse:
54+
Retrieve previously saved AuthnRequest
55+
Replay AuthnRequest
56+
"""
57+
def __init__(self, config: dict, *args, **kwargs):
58+
super().__init__(*args, **kwargs)
59+
self.endpoint = 'redirecturl_response'
60+
self.self_entityid = config['self_entityid']
61+
self.redir_attr = config['redirect_attr_name']
62+
self.redir_entityid = config['redir_entityid']
63+
self.local_store = LocalStore(config['db_encryption_key'], redishost=config.get('redis_host', 'localhost'))
64+
logging.info('RedirectUrlResponse microservice active')
65+
66+
def _handle_redirecturl_response(
67+
self,
68+
context: satosa.context.Context,
69+
wsgi_app: callable(satosa.context.Context)) -> satosa.response.Response:
70+
logging.debug(f"RedirectUrl microservice: RedirectUrl processing complete")
71+
key = int(context.state[STATE_KEY])
72+
authnrequ_context = self.local_store.get(key)
73+
resp = wsgi_app.run(authnrequ_context)
74+
return resp
75+
76+
def process(self, context: satosa.context.Context,
77+
internal_response: satosa.internal.InternalData) -> satosa.response.Response:
78+
if self.redir_attr in internal_response.attributes:
79+
logging.debug(f"RedirectUrl microservice: Attribute {self.redir_attr} found, starting redirect")
80+
redirecturl = internal_response.attributes[self.redir_attr][0] + '?wtrealm=' + self.self_entityid
81+
return satosa.response.Redirect(redirecturl)
82+
else:
83+
logging.debug(f"RedirectUrl microservice: Attribute {self.redir_attr} not found")
84+
return super().process(context, internal_response)
85+
86+
def register_endpoints(self):
87+
return [("^{}$".format(self.endpoint), self._handle_redirecturl_response), ]
88+
89+
90+
if sys.version_info < (3, 6):
91+
raise Exception("Must be using Python 3.6 or later")
+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""
2+
Integrate the "simple consent" application into SATOSA
3+
4+
Logic:
5+
1. verify consent (API call)
6+
2. continue with response if true
7+
3. request consent (redirect to consent app)
8+
4. (consent service app will redirect to _handle_consent_response)
9+
5. verify consent (API call)
10+
6. delete attributes if no consent
11+
7. continue with response
12+
13+
"""
14+
import base64
15+
import hashlib
16+
import hmac
17+
import json
18+
import logging
19+
import pickle
20+
import sys
21+
import urllib.parse
22+
23+
import requests
24+
from requests.exceptions import ConnectionError
25+
26+
import satosa
27+
from satosa.internal import InternalData
28+
from satosa.logging_util import satosa_logging
29+
from satosa.micro_services.base import ResponseMicroService
30+
from satosa.response import Redirect
31+
32+
logger = logging.getLogger(__name__)
33+
34+
RESPONSE_STATE = "Saml2IDP"
35+
CONSENT_ID = "SimpleConsent"
36+
CONSENT_INT_DATA = 'simpleconsent.internaldata'
37+
38+
39+
class UnexpectedResponseError(Exception):
40+
pass
41+
42+
43+
class SimpleConsent(ResponseMicroService):
44+
def __init__(self, config: dict, *args, **kwargs):
45+
super().__init__(*args, **kwargs)
46+
self.consent_attrname_display = config['consent_attrname_display']
47+
self.consent_attr_not_displayed = config['consent_attr_not_displayed']
48+
self.consent_cookie_name = config['consent_cookie_name']
49+
self.consent_service_api_auth = config['consent_service_api_auth']
50+
self.endpoint = "simpleconsent_response"
51+
self.id_hash_alg = config['id_hash_alg']
52+
self.name = "simpleconsent"
53+
self.proxy_hmac_key = config['PROXY_HMAC_KEY'].encode('ascii')
54+
self.request_consent_url = config['request_consent_url']
55+
self.self_entityid = config['self_entityid']
56+
self.sp_entityid_names: dict = config['sp_entityid_names']
57+
self.verify_consent_url = config['verify_consent_url']
58+
logging.info('SimpleConsent microservice active')
59+
60+
def _end_consent_flow(self, context: satosa.context.Context,
61+
internal_response: satosa.internal.InternalData) -> satosa.response.Response:
62+
del context.state[CONSENT_ID]
63+
return super().process(context, internal_response)
64+
65+
def _handle_consent_response(
66+
self,
67+
context: satosa.context.Context,
68+
wsgi_app: callable(satosa.context.Context)) -> satosa.response.Response:
69+
70+
logging.debug(f"SimpleConsent microservice: resuming response processing after requesting consent")
71+
internal_resp_ser = base64.b64decode(context.state[CONSENT_INT_DATA].encode('ascii'))
72+
internal_response = pickle.loads(internal_resp_ser)
73+
consent_id = context.state[CONSENT_ID]
74+
75+
try:
76+
consent_given = self._verify_consent(internal_response.requester, consent_id)
77+
except ConnectionError:
78+
satosa_logging(logger, logging.ERROR,
79+
"Consent service is not reachable, no consent given.", context.state)
80+
internal_response.attributes = {}
81+
82+
if consent_given:
83+
satosa_logging(logger, logging.INFO, "Consent was given", context.state)
84+
else:
85+
satosa_logging(logger, logging.INFO, "Consent was NOT given, removing attributes", context.state)
86+
internal_response.attributes = {}
87+
88+
return self._end_consent_flow(context, internal_response)
89+
90+
def _get_consent_id(self, user_id: str, attr_set: dict) -> str:
91+
# include attributes in id_hash to ensure that consent is invalid if the attribute set changes
92+
attr_key_list = sorted(attr_set.keys())
93+
consent_id_json = json.dumps([user_id, attr_key_list])
94+
if self.id_hash_alg == 'md5':
95+
consent_id_hash = hashlib.md5(consent_id_json.encode('utf-8'))
96+
elif self.id_hash_alg == 'sha224':
97+
consent_id_hash = hashlib.sha224(consent_id_json.encode('utf-8'))
98+
else:
99+
raise Exception("Simpleconsent.config.id_hash_alg must be in ('md5', 'sha224')")
100+
return consent_id_hash.hexdigest()
101+
102+
def process(self, context: satosa.context.Context,
103+
internal_resp: satosa.internal.InternalData) -> satosa.response.Response:
104+
105+
response_state = context.state[RESPONSE_STATE]
106+
consent_id = self._get_consent_id(internal_resp.subject_id, internal_resp.attributes)
107+
context.state[CONSENT_ID] = consent_id
108+
logging.debug(f"SimpleConsent microservice: verify consent, id={consent_id}")
109+
try:
110+
# Check if consent is already given
111+
consent_given = self._verify_consent(internal_resp.requester, consent_id)
112+
except requests.exceptions.ConnectionError:
113+
satosa_logging(logger, logging.ERROR,
114+
f"Consent service is not reachable at {self.verify_consent_url}, no consent given.",
115+
context.state)
116+
# Send an internal_resp without any attributes
117+
internal_resp.attributes = {}
118+
return self._end_consent_flow(context, internal_resp)
119+
120+
if consent_given:
121+
satosa_logging(logger, logging.DEBUG, "SimpleConsent microservice: previous consent found", context.state)
122+
return self._end_consent_flow(context, internal_resp) # return attribute set unmodified
123+
else:
124+
logging.debug(f"SimpleConsent microservice: starting redirect to request consent")
125+
# save internal response
126+
internal_resp_ser = pickle.dumps(internal_resp)
127+
context.state[CONSENT_INT_DATA] = base64.b64encode(internal_resp_ser).decode('ascii')
128+
# create request object & redirect
129+
consent_requ_json = self._make_consent_request(response_state, consent_id, internal_resp.attributes)
130+
hmac_str = hmac.new(self.proxy_hmac_key, consent_requ_json.encode('utf-8'), hashlib.sha256).hexdigest()
131+
consent_requ_b64 = base64.urlsafe_b64encode(consent_requ_json.encode('ascii')).decode('ascii')
132+
redirecturl = f"{self.request_consent_url}/{urllib.parse.quote_plus(consent_requ_b64)}/{hmac_str}/"
133+
return satosa.response.Redirect(redirecturl)
134+
135+
return super().process(context, internal_resp)
136+
137+
def _make_consent_request(self, response_state: dict, consent_id: str, attr: list) -> dict:
138+
display_attr: set = set.difference(set(attr), set(self.consent_attr_not_displayed))
139+
for attr_name, attr_name_translated in self.consent_attrname_display.items():
140+
if attr_name in display_attr:
141+
display_attr.discard(attr_name)
142+
display_attr.add(attr_name_translated)
143+
displayname = attr['displayname'][0] if attr['displayname'] else ''
144+
entityid = response_state['resp_args']['sp_entity_id']
145+
sp_name = self.sp_entityid_names.get(entityid, entityid)
146+
uid = attr['mail'][0] if attr['mail'] else ''
147+
148+
consent_requ_dict = {
149+
"entityid": entityid,
150+
"consentid": consent_id,
151+
"displayname": displayname,
152+
"mail": uid,
153+
"sp": sp_name,
154+
"attr_list": sorted(list(display_attr)),
155+
}
156+
consent_requ_json = json.dumps(consent_requ_dict)
157+
return consent_requ_json
158+
159+
def register_endpoints(self) -> list:
160+
return [("^{}$".format(self.endpoint), self._handle_consent_response), ]
161+
162+
def _verify_consent(self, requester, consent_id: str) -> bool:
163+
requester_b64 = base64.urlsafe_b64encode(requester.encode('ascii')).decode('ascii')
164+
url = f"{self.verify_consent_url}/{requester_b64}/{consent_id}/"
165+
try:
166+
api_cred = (self.consent_service_api_auth['userid'],
167+
self.consent_service_api_auth['password'])
168+
response = requests.request(method='GET', url=url, auth=(api_cred))
169+
if response.status_code == 200:
170+
return json.loads(response.text)
171+
else:
172+
raise ConnectionError(f"GET {url} returned status code {response.status_code}")
173+
except requests.exceptions.ConnectionError as e:
174+
logger.debug(f"GET {url} {str(e)}")
175+
raise
176+
177+
178+
if sys.version_info < (3, 6):
179+
raise Exception("SimpleConsent microservice requires Python 3.6 or later")

0 commit comments

Comments
 (0)