Skip to content

Commit

Permalink
add WKIS micro services
Browse files Browse the repository at this point in the history
  • Loading branch information
rhoerbe committed Oct 16, 2019
1 parent c330483 commit d8ba645
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 0 deletions.
26 changes: 26 additions & 0 deletions src/satosa/micro_services/local_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
""" implement key/valuer store """
import pickle
import redis
from satosa.state import _AESCipher

class LocalStore():
""" Store context objects in Redis.
Create a new key when a new value is set.
Delete key/value after reading it
"""
def __init__(self, encryption_key: str, redishost: str):
self.redis = redis.Redis(host=redishost, port=6379)
self.aes_cipher = _AESCipher(encryption_key)

def set(self, context: object) -> int:
context_serlzd = pickle.dumps(context, pickle.HIGHEST_PROTOCOL)
context_enc = self.aes_cipher.encrypt(context_serlzd)
key = self.redis.incr('REDIRURL_sequence', 1)
self.redis.set(key, context_serlzd, 1800) # generous 30 min timeout to complete SSO transaction
return key

def get(self, key: int) -> object:
context_serlzd = self.redis.get(key)
self.redis.expire(key, 600) # delay deletion in case request is repeated due to network issues
return pickle.loads(context_serlzd)

91 changes: 91 additions & 0 deletions src/satosa/micro_services/redirect_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
ADFS/SAML-Support for role selection and profile completion after a SAML-Response
was issued using a redirect-to-idp flow.
* Store AuthnRequest for later replay
* Handle redirect-to-idp and replay AuthnRequest after redirect-to-idp flow
Persist state: Storing the the full context of the AuthnRequest in SATOSA_STATE is not feasible due to cookie size limitations.
Instead, it is stored in a local redis store, and the key is stored in SATOSA_STATE.
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.
(Instantiating a global connection pool across gunicorn worker threads would impose some additional complexity.)
The AuthnRequest is stored unencrypted with the assumption that a stolen request cannot do harm,
because the final Response will only be delivered to the metadata-specified ACS endpoint.
"""

import logging
import sys
from typing import Tuple
import satosa
from .base import RequestMicroService, ResponseMicroService
from satosa.micro_services.local_store import LocalStore

MIN_PYTHON = (3, 6)
if sys.version_info < MIN_PYTHON:
sys.exit("Python %s.%s or later is required.\n" % MIN_PYTHON)

STATE_KEY = "REDIRURLCONTEXT"


class RedirectUrlRequest(RequestMicroService):
""" Store AuthnRequest in SATOSA STATE in case it is required later for the RedirectUrl flow """
def __init__(self, config: dict, *args, **kwargs):
super().__init__(*args, **kwargs)
self.local_store = LocalStore(config['db_encryption_key'], redishost=config.get('redis_host', 'localhost'))
logging.info('RedirectUrlRequest microservice active')

def process(self, context: satosa.context.Context, internal_request: satosa.internal.InternalData) \
-> Tuple[satosa.context.Context, satosa.internal.InternalData]:
key = self.local_store.set(context)
context.state[STATE_KEY] = str(key)
logging.debug(f"RedirectUrlRequest: store context (stub)")
return super().process(context, internal_request)


class RedirectUrlResponse(ResponseMicroService):
"""
Handle following events:
* Processing a SAML Response:
if the redirectUrl attribute is set in the response/attribute statement:
Redirect to responder
* Processing a RedirectUrlResponse:
Retrieve previously saved AuthnRequest
Replay AuthnRequest
"""
def __init__(self, config: dict, *args, **kwargs):
super().__init__(*args, **kwargs)
self.endpoint = 'redirecturl_response'
self.self_entityid = config['self_entityid']
self.redir_attr = config['redirect_attr_name']
self.redir_entityid = config['redir_entityid']
self.local_store = LocalStore(config['db_encryption_key'], redishost=config.get('redis_host', 'localhost'))
logging.info('RedirectUrlResponse microservice active')

def _handle_redirecturl_response(
self,
context: satosa.context.Context,
wsgi_app: callable(satosa.context.Context)) -> satosa.response.Response:
logging.debug(f"RedirectUrl microservice: RedirectUrl processing complete")
key = int(context.state[STATE_KEY])
authnrequ_context = self.local_store.get(key)
resp = wsgi_app.run(authnrequ_context)
return resp

def process(self, context: satosa.context.Context,
internal_response: satosa.internal.InternalData) -> satosa.response.Response:
if self.redir_attr in internal_response.attributes:
logging.debug(f"RedirectUrl microservice: Attribute {self.redir_attr} found, starting redirect")
redirecturl = internal_response.attributes[self.redir_attr][0] + '?wtrealm=' + self.self_entityid
return satosa.response.Redirect(redirecturl)
else:
logging.debug(f"RedirectUrl microservice: Attribute {self.redir_attr} not found")
return super().process(context, internal_response)

def register_endpoints(self):
return [("^{}$".format(self.endpoint), self._handle_redirecturl_response), ]


if sys.version_info < (3, 6):
raise Exception("Must be using Python 3.6 or later")
179 changes: 179 additions & 0 deletions src/satosa/micro_services/simpleconsent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Integrate the "simple consent" application into SATOSA
Logic:
1. verify consent (API call)
2. continue with response if true
3. request consent (redirect to consent app)
4. (consent service app will redirect to _handle_consent_response)
5. verify consent (API call)
6. delete attributes if no consent
7. continue with response
"""
import base64
import hashlib
import hmac
import json
import logging
import pickle
import sys
import urllib.parse

import requests
from requests.exceptions import ConnectionError

import satosa
from satosa.internal import InternalData
from satosa.logging_util import satosa_logging
from satosa.micro_services.base import ResponseMicroService
from satosa.response import Redirect

logger = logging.getLogger(__name__)

RESPONSE_STATE = "Saml2IDP"
CONSENT_ID = "SimpleConsent"
CONSENT_INT_DATA = 'simpleconsent.internaldata'


class UnexpectedResponseError(Exception):
pass


class SimpleConsent(ResponseMicroService):
def __init__(self, config: dict, *args, **kwargs):
super().__init__(*args, **kwargs)
self.consent_attrname_display = config['consent_attrname_display']
self.consent_attr_not_displayed = config['consent_attr_not_displayed']
self.consent_cookie_name = config['consent_cookie_name']
self.consent_service_api_auth = config['consent_service_api_auth']
self.endpoint = "simpleconsent_response"
self.id_hash_alg = config['id_hash_alg']
self.name = "simpleconsent"
self.proxy_hmac_key = config['PROXY_HMAC_KEY'].encode('ascii')
self.request_consent_url = config['request_consent_url']
self.self_entityid = config['self_entityid']
self.sp_entityid_names: dict = config['sp_entityid_names']
self.verify_consent_url = config['verify_consent_url']
logging.info('SimpleConsent microservice active')

def _end_consent_flow(self, context: satosa.context.Context,
internal_response: satosa.internal.InternalData) -> satosa.response.Response:
del context.state[CONSENT_ID]
return super().process(context, internal_response)

def _handle_consent_response(
self,
context: satosa.context.Context,
wsgi_app: callable(satosa.context.Context)) -> satosa.response.Response:

logging.debug(f"SimpleConsent microservice: resuming response processing after requesting consent")
internal_resp_ser = base64.b64decode(context.state[CONSENT_INT_DATA].encode('ascii'))
internal_response = pickle.loads(internal_resp_ser)
consent_id = context.state[CONSENT_ID]

try:
consent_given = self._verify_consent(internal_response.requester, consent_id)
except ConnectionError:
satosa_logging(logger, logging.ERROR,
"Consent service is not reachable, no consent given.", context.state)
internal_response.attributes = {}

if consent_given:
satosa_logging(logger, logging.INFO, "Consent was given", context.state)
else:
satosa_logging(logger, logging.INFO, "Consent was NOT given, removing attributes", context.state)
internal_response.attributes = {}

return self._end_consent_flow(context, internal_response)

def _get_consent_id(self, user_id: str, attr_set: dict) -> str:
# include attributes in id_hash to ensure that consent is invalid if the attribute set changes
attr_key_list = sorted(attr_set.keys())
consent_id_json = json.dumps([user_id, attr_key_list])
if self.id_hash_alg == 'md5':
consent_id_hash = hashlib.md5(consent_id_json.encode('utf-8'))
elif self.id_hash_alg == 'sha224':
consent_id_hash = hashlib.sha224(consent_id_json.encode('utf-8'))
else:
raise Exception("Simpleconsent.config.id_hash_alg must be in ('md5', 'sha224')")
return consent_id_hash.hexdigest()

def process(self, context: satosa.context.Context,
internal_resp: satosa.internal.InternalData) -> satosa.response.Response:

response_state = context.state[RESPONSE_STATE]
consent_id = self._get_consent_id(internal_resp.subject_id, internal_resp.attributes)
context.state[CONSENT_ID] = consent_id
logging.debug(f"SimpleConsent microservice: verify consent, id={consent_id}")
try:
# Check if consent is already given
consent_given = self._verify_consent(internal_resp.requester, consent_id)
except requests.exceptions.ConnectionError:
satosa_logging(logger, logging.ERROR,
f"Consent service is not reachable at {self.verify_consent_url}, no consent given.",
context.state)
# Send an internal_resp without any attributes
internal_resp.attributes = {}
return self._end_consent_flow(context, internal_resp)

if consent_given:
satosa_logging(logger, logging.DEBUG, "SimpleConsent microservice: previous consent found", context.state)
return self._end_consent_flow(context, internal_resp) # return attribute set unmodified
else:
logging.debug(f"SimpleConsent microservice: starting redirect to request consent")
# save internal response
internal_resp_ser = pickle.dumps(internal_resp)
context.state[CONSENT_INT_DATA] = base64.b64encode(internal_resp_ser).decode('ascii')
# create request object & redirect
consent_requ_json = self._make_consent_request(response_state, consent_id, internal_resp.attributes)
hmac_str = hmac.new(self.proxy_hmac_key, consent_requ_json.encode('utf-8'), hashlib.sha256).hexdigest()
consent_requ_b64 = base64.urlsafe_b64encode(consent_requ_json.encode('ascii')).decode('ascii')
redirecturl = f"{self.request_consent_url}/{urllib.parse.quote_plus(consent_requ_b64)}/{hmac_str}/"
return satosa.response.Redirect(redirecturl)

return super().process(context, internal_resp)

def _make_consent_request(self, response_state: dict, consent_id: str, attr: list) -> dict:
display_attr: set = set.difference(set(attr), set(self.consent_attr_not_displayed))
for attr_name, attr_name_translated in self.consent_attrname_display.items():
if attr_name in display_attr:
display_attr.discard(attr_name)
display_attr.add(attr_name_translated)
displayname = attr['displayname'][0] if attr['displayname'] else ''
entityid = response_state['resp_args']['sp_entity_id']
sp_name = self.sp_entityid_names.get(entityid, entityid)
uid = attr['mail'][0] if attr['mail'] else ''

consent_requ_dict = {
"entityid": entityid,
"consentid": consent_id,
"displayname": displayname,
"mail": uid,
"sp": sp_name,
"attr_list": sorted(list(display_attr)),
}
consent_requ_json = json.dumps(consent_requ_dict)
return consent_requ_json

def register_endpoints(self) -> list:
return [("^{}$".format(self.endpoint), self._handle_consent_response), ]

def _verify_consent(self, requester, consent_id: str) -> bool:
requester_b64 = base64.urlsafe_b64encode(requester.encode('ascii')).decode('ascii')
url = f"{self.verify_consent_url}/{requester_b64}/{consent_id}/"
try:
api_cred = (self.consent_service_api_auth['userid'],
self.consent_service_api_auth['password'])
response = requests.request(method='GET', url=url, auth=(api_cred))
if response.status_code == 200:
return json.loads(response.text)
else:
raise ConnectionError(f"GET {url} returned status code {response.status_code}")
except requests.exceptions.ConnectionError as e:
logger.debug(f"GET {url} {str(e)}")
raise


if sys.version_info < (3, 6):
raise Exception("SimpleConsent microservice requires Python 3.6 or later")

0 comments on commit d8ba645

Please sign in to comment.