Saml2 in Repoze
This commit is contained in:
3
src/saml2_repoze/__init__.py
Normal file
3
src/saml2_repoze/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Created by Roland Hedberg
|
||||
# Copyright (c) 2009 Umeå Universitet. All rights reserved.
|
||||
3
src/saml2_repoze/plugins/__init__.py
Normal file
3
src/saml2_repoze/plugins/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Created by Roland Hedberg
|
||||
# Copyright (c) 2009 Umeå Universitet. All rights reserved.
|
||||
40
src/saml2_repoze/plugins/ini.py
Normal file
40
src/saml2_repoze/plugins/ini.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import ConfigParser, os
|
||||
|
||||
from zope.interface import implements
|
||||
|
||||
from repoze.who.interfaces import IChallenger, IIdentifier, IAuthenticator
|
||||
from repoze.who.interfaces import IMetadataProvider
|
||||
|
||||
class INIMetadataProvider(object):
|
||||
|
||||
implements(IMetadataProvider)
|
||||
|
||||
def __init__(self, ini_file):
|
||||
|
||||
self.users = ConfigParser.ConfigParser()
|
||||
self.users.readfp(open(ini_file))
|
||||
|
||||
# def authenticate(self, environ, identity):
|
||||
# try:
|
||||
# username = identity['login']
|
||||
# password = identity['password']
|
||||
# except KeyError:
|
||||
# return None
|
||||
#
|
||||
# success = User.authenticate(username, password)
|
||||
#
|
||||
# return success
|
||||
|
||||
def add_metadata(self, environ, identity):
|
||||
logger = environ.get('repoze.who.logger','')
|
||||
|
||||
username = identity.get('repoze.who.userid')
|
||||
logger and logger.info("Identity: %s (before)" % (identity.items(),))
|
||||
try:
|
||||
identity["user"] = self.users.items(username)
|
||||
logger and logger.info("Identity: %s (after)" % (identity.items(),))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def make_plugin(ini_file):
|
||||
return INIMetadataProvider(ini_file)
|
||||
343
src/saml2_repoze/plugins/sp.py
Normal file
343
src/saml2_repoze/plugins/sp.py
Normal file
@@ -0,0 +1,343 @@
|
||||
# Copyright (C) 2009 Umea University
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
A plugin that allows you to use SAML2 SSO as authentication
|
||||
and SAML2 attribute aggregations as metadata collector in your
|
||||
WSGI application.
|
||||
|
||||
"""
|
||||
import re
|
||||
import urlparse
|
||||
import urllib
|
||||
import cgi
|
||||
import os
|
||||
|
||||
from paste.httpheaders import CONTENT_LENGTH
|
||||
from paste.httpheaders import CONTENT_TYPE
|
||||
from paste.httpheaders import LOCATION
|
||||
from paste.httpexceptions import HTTPFound
|
||||
from paste.httpexceptions import HTTPUnauthorized
|
||||
from paste.httpexceptions import HTTPTemporaryRedirect
|
||||
from paste.request import parse_dict_querystring
|
||||
from paste.request import parse_formvars
|
||||
from paste.request import construct_url
|
||||
from paste.request import parse_querystring
|
||||
|
||||
from paste.response import header_value
|
||||
|
||||
from zope.interface import implements
|
||||
|
||||
from repoze.who.interfaces import IChallenger, IIdentifier, IAuthenticator
|
||||
from repoze.who.interfaces import IMetadataProvider
|
||||
from repoze.who.plugins.form import FormPluginBase
|
||||
|
||||
from saml2.client import Saml2Client
|
||||
from saml2.attribute_resolver import AttributeResolver
|
||||
from saml2.metadata import MetaData
|
||||
from saml2.saml import NAMEID_FORMAT_TRANSIENT
|
||||
|
||||
def construct_came_from(environ):
|
||||
""" The URL that the user used when the process where interupted
|
||||
for single-sign-on processing. """
|
||||
|
||||
came_from = environ.get("PATH_INFO")
|
||||
qs = environ.get("QUERY_STRING","")
|
||||
if qs:
|
||||
came_from += '?' + qs
|
||||
return came_from
|
||||
|
||||
# FormPluginBase defines the methods remember and forget
|
||||
|
||||
class SAML2Plugin(FormPluginBase):
|
||||
|
||||
implements(IChallenger, IIdentifier, IAuthenticator, IMetadataProvider)
|
||||
|
||||
def __init__(self, rememberer_name, saml_conf_file, store,
|
||||
path_logout, path_toskip, debug):
|
||||
|
||||
self.rememberer_name = rememberer_name
|
||||
self.path_logout = path_logout
|
||||
self.path_toskip = path_toskip
|
||||
self.debug = debug
|
||||
|
||||
self.conf = {}
|
||||
self.verify_conf(saml_conf_file)
|
||||
|
||||
self.outstanding_authn = {}
|
||||
self.iam = os.uname()[1]
|
||||
|
||||
if store==u"file":
|
||||
self.store = shelve.open(store_filename)
|
||||
elif store==u"mem":
|
||||
self.store = {}
|
||||
|
||||
def verify_conf(self, conf_file):
|
||||
""" """
|
||||
|
||||
self.conf = eval(open(conf_file).read())
|
||||
|
||||
# check for those that have to be there
|
||||
assert "xmlsec_binary" in self.conf
|
||||
assert "service_url" in self.conf
|
||||
assert "sp_entityid" in self.conf
|
||||
|
||||
if "my_name" not in self.conf:
|
||||
self.conf["my_name"] = "MY NAME"
|
||||
if "my_key" not in self.conf:
|
||||
self.conf["my_key"] = None
|
||||
else:
|
||||
# If you have a key file you have to have a cert file
|
||||
assert "my_cert" in self.conf
|
||||
|
||||
if "metadata" in self.conf:
|
||||
md = MetaData()
|
||||
for mdfile in self.conf["metadata"]:
|
||||
md.import_metadata(open(mdfile).read())
|
||||
self.metadata = md
|
||||
|
||||
# if metadata is defined, the IdP url should be gotten from there
|
||||
if "md_idp" in self.conf:
|
||||
self.conf["idp_url"] = self.metadata.single_sign_on_services(
|
||||
self.conf["md_idp"])[0]
|
||||
else:
|
||||
assert "idp_url" in self.conf
|
||||
if "virtual_organization" in self.conf:
|
||||
raise Exception(
|
||||
"Can't deal with virtual organization without metadata")
|
||||
self.metadata = None
|
||||
|
||||
if "virtual_organization" in self.conf:
|
||||
if "nameid_format" not in self.conf:
|
||||
self.conf["nameid_format"] = NAMEID_FORMAT_TRANSIENT
|
||||
|
||||
print "Configuration: %s" % (self.conf,)
|
||||
|
||||
#### IChallenger ####
|
||||
def challenge(self, environ, status, app_headers, forget_headers):
|
||||
|
||||
# this challenge consist in loggin out
|
||||
if environ.has_key('rwpc.logout'):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
logger = environ.get('repoze.who.logger','')
|
||||
# ELSE, perform a real challenge => asking for loggin
|
||||
# here by redirecting the user to a IdP.
|
||||
|
||||
cl = Saml2Client(environ,
|
||||
metadata = self.metadata,
|
||||
key_file=self.conf["my_key"],
|
||||
xmlsec_binary=self.conf["xmlsec_binary"])
|
||||
came_from = construct_came_from(environ)
|
||||
if self.debug:
|
||||
logger and logger.info("RelayState >> %s" % came_from)
|
||||
(sid, result) = cl.authenticate(self.conf["sp_entityid"],
|
||||
self.conf["idp_url"],
|
||||
self.conf["service_url"],
|
||||
self.conf["my_name"],
|
||||
relay_state=came_from, log=logger)
|
||||
self.outstanding_authn[sid] = came_from
|
||||
|
||||
if self.debug:
|
||||
logger and logger.info('sc returned: %s' % (result,))
|
||||
if isinstance(result, tuple):
|
||||
return HTTPTemporaryRedirect(headers=[result])
|
||||
else :
|
||||
# possible to normally not used
|
||||
body = "\n".join(result)
|
||||
def auth_form(environ, start_response):
|
||||
content_length = CONTENT_LENGTH.tuples(str(len(result)))
|
||||
content_type = CONTENT_TYPE.tuples('text/html')
|
||||
headers = content_length + content_type + forget_headers
|
||||
start_response('200 OK', headers)
|
||||
return [result]
|
||||
|
||||
return auth_form
|
||||
|
||||
#### IIdentifier ####
|
||||
def identify(self, environ):
|
||||
logger = environ.get('repoze.who.logger','')
|
||||
|
||||
uri = environ.get('REQUEST_URI',construct_url(environ))
|
||||
if self.debug:
|
||||
logger and logger.info("environ.keys(): %s" % environ.keys())
|
||||
logger and logger.info("Environment: %s" % environ)
|
||||
logger and logger.info('identify uri: %s' % (uri,))
|
||||
|
||||
query = parse_dict_querystring(environ)
|
||||
if self.debug:
|
||||
logger and logger.info('identify query: %s' % (query,))
|
||||
|
||||
# path_logout for every app.
|
||||
for regex in self.path_logout:
|
||||
if re.match(regex, uri) != None:
|
||||
if self.debug :
|
||||
logger and logger.info("LOGOUT #### ")
|
||||
# we've been asked to perform a logout
|
||||
|
||||
# use all except : POST
|
||||
# trigger the challenge and tells the challenge this is a logout
|
||||
query['bhp'] = 'go'
|
||||
environ['rwpc.logout'] = \
|
||||
self._serviceURL(environ,urllib.urlencode(query))
|
||||
|
||||
return None
|
||||
|
||||
# skipping, whatever it is (loggin, validating ticket etc.)
|
||||
# except for logout (see above)
|
||||
for regex in self.path_toskip:
|
||||
if re.match(regex, uri) != None:
|
||||
if self.debug :
|
||||
logger and logger.info("########### SKIPPING")
|
||||
return None
|
||||
|
||||
post_env = environ.copy()
|
||||
post_env['QUERY_STRING'] = ''
|
||||
post = cgi.FieldStorage(
|
||||
fp=environ['wsgi.input'],
|
||||
environ=post_env,
|
||||
keep_blank_values=True
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
logger and logger.info('identify post keys: %s' % (post.keys(),))
|
||||
|
||||
# check for SAML2 authN
|
||||
cl = Saml2Client(environ,
|
||||
metadata = self.metadata,
|
||||
key_file = self.conf["my_key"],
|
||||
xmlsec_binary = self.conf["xmlsec_binary"])
|
||||
try:
|
||||
(ava, came_from) = cl.response(post,
|
||||
self.conf["sp_entityid"],
|
||||
self.outstanding_authn,
|
||||
logger)
|
||||
name_id = ava["__userid"]
|
||||
del ava["__userid"]
|
||||
self.store[name_id] = ava
|
||||
if self.debug:
|
||||
logger and logger.info("stored %s with key %s" % (ava, name_id))
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
if came_from:
|
||||
if self.debug:
|
||||
logger and logger.info("came_from << %s" % came_from)
|
||||
try:
|
||||
path, query = came_from.split('?')
|
||||
environ["PATH_INFO"] = path
|
||||
environ["QUERY_STRING"] = query
|
||||
except ValueError:
|
||||
environ["PATH_INFO"] = came_from
|
||||
|
||||
identity = {}
|
||||
identity["login"] = name_id
|
||||
identity["password"] = ""
|
||||
identity['repoze.who.userid'] = name_id
|
||||
identity.update(ava)
|
||||
if self.debug:
|
||||
logger and logger.info("Identity: %s" % identity)
|
||||
return identity
|
||||
|
||||
# IMetadataProvider
|
||||
def add_metadata(self, environ, identity):
|
||||
if self.debug:
|
||||
logger = environ.get('repoze.who.logger','')
|
||||
logger and logger.info(
|
||||
"add_metadata for %s" % identity['repoze.who.userid'])
|
||||
try:
|
||||
ava = self.store[identity['repoze.who.userid']]
|
||||
if self.debug:
|
||||
logger and logger.info("Adding %s" % ava)
|
||||
identity.update(ava)
|
||||
self.store[identity['repoze.who.userid']] = identity
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if "pysaml2_vo_expanded" not in identity:
|
||||
# is this a Virtual Organization situation
|
||||
if "virtual_organization" in self.conf:
|
||||
logger and logger.info("** Do VO aggregation **")
|
||||
try:
|
||||
subject_id = identity[self.conf["common_identifier"]][0]
|
||||
except KeyError:
|
||||
return
|
||||
logger and logger.info("SubjectID: %s" % subject_id)
|
||||
ar = AttributeResolver(environ, self.metadata,
|
||||
self.conf["xmlsec_binary"],
|
||||
self.conf["my_key"],
|
||||
self.conf["my_cert"])
|
||||
vo_members = [
|
||||
member for member in self.metadata.vo_members(
|
||||
self.conf["virtual_organization"])\
|
||||
if member != self.conf["md_idp"]]
|
||||
logger and logger.info("VO members: %s" % vo_members)
|
||||
|
||||
if vo_members:
|
||||
extra = ar.extend(subject_id,
|
||||
self.conf["sp_entityid"],
|
||||
vo_members,
|
||||
self.conf["nameid_format"],
|
||||
log=logger)
|
||||
|
||||
for attr,val in extra.items():
|
||||
try:
|
||||
# might lead to duplicates !
|
||||
identity[attr].extend(val)
|
||||
except KeyError:
|
||||
identity[attr] = val
|
||||
|
||||
# Only do this once
|
||||
identity["pysaml2_vo_expanded"] = 1
|
||||
self.store[identity['repoze.who.userid']] = identity
|
||||
|
||||
# @return
|
||||
# used 2 times : one to get the ticket, the other to validate it
|
||||
def _serviceURL(self,environ,qs=None):
|
||||
if qs != None:
|
||||
url = construct_url(environ, querystring=qs)
|
||||
else:
|
||||
url = construct_url(environ)
|
||||
return url
|
||||
|
||||
#### IAuthenticatorPlugin ####
|
||||
def authenticate(self, environ, identity={}):
|
||||
return identity.get('login',None)
|
||||
|
||||
|
||||
def make_plugin(rememberer_name=None, # plugin for remember
|
||||
store= "mem", # store for remember
|
||||
path_logout='', # regex url to logout
|
||||
path_toskip='', # regex url to skip
|
||||
saml_conf="",
|
||||
debug=0,
|
||||
):
|
||||
|
||||
if saml_conf is None:
|
||||
raise ValueError(
|
||||
'must include saml_conf in configuration')
|
||||
|
||||
if rememberer_name is None:
|
||||
raise ValueError(
|
||||
'must include rememberer_name in configuration')
|
||||
path_logout = path_logout.lstrip().split('\n');
|
||||
path_toskip = path_toskip.lstrip().splitlines()
|
||||
|
||||
plugin = SAML2Plugin(rememberer_name, saml_conf, store,
|
||||
path_logout, path_toskip, debug)
|
||||
return plugin
|
||||
|
||||
# came_from = re.sub(r'ticket=[^&]*&?', '', came_from)
|
||||
|
||||
Reference in New Issue
Block a user