first commit
commit
c910ca04be
|
@ -0,0 +1,32 @@
|
|||
from django.utils.timezone import now
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
class Plugin(object):
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
return urls.urlpatterns
|
||||
|
||||
def get_apps(self):
|
||||
return [__name__]
|
||||
|
||||
def logout_list(self, request):
|
||||
from . import models
|
||||
|
||||
qs = models.CasService.objects.filter(accesstoken__user=request.user,
|
||||
accesstoken__expires__gt=now(), logout_url__isnull=False) \
|
||||
.distinct()
|
||||
|
||||
l = []
|
||||
for client in qs:
|
||||
name = client.name
|
||||
url = client.get_logout_url()
|
||||
ctx = {
|
||||
'needs_iframe': client.logout_use_iframe,
|
||||
'name': name,
|
||||
'url': url,
|
||||
'iframe_timeout': client.logout_use_iframe_timeout,
|
||||
}
|
||||
content = render_to_string('idp/saml/logout_fragment.html', ctx)
|
||||
l.append(content)
|
||||
return l
|
|
@ -0,0 +1,32 @@
|
|||
from django.utils.importlib import import_module
|
||||
|
||||
class AppSettings(object):
|
||||
|
||||
def __init__(self, prefix):
|
||||
self.prefix = prefix
|
||||
|
||||
@property
|
||||
def PROVIDER(self):
|
||||
cas_provider = self._setting('CAS_PROVIDER', 'authentic2_idp_cas.views.Authentic2CasProvider')
|
||||
module, cls = cas_provider.rsplit('.', 1)
|
||||
module = import_module(module)
|
||||
return getattr(module, cls)
|
||||
|
||||
@property
|
||||
def TICKET_EXPIRATION(self):
|
||||
return self._setting('TICKET_EXPIRATION', 240)
|
||||
|
||||
|
||||
|
||||
def _setting(self, name, dflt):
|
||||
from django.conf import settings
|
||||
return getattr(settings, self.prefix + name, dflt)
|
||||
|
||||
|
||||
|
||||
# Ugly? Guido recommends this himself ...
|
||||
# http://mail.python.org/pipermail/python-ideas/2012-May/014969.html
|
||||
import sys
|
||||
app_settings = AppSettings('A2_IDP_CAS_')
|
||||
app_settings.__name__ = __name__
|
||||
sys.modules[__name__] = app_settings
|
|
@ -0,0 +1,58 @@
|
|||
# Constants #
|
||||
CAS_NAMESPACE = 'http://www.yale.edu/tp/cas'
|
||||
RENEW_PARAM = 'renew'
|
||||
SERVICE_PARAM = 'service'
|
||||
GATEWAY_PARAM = 'gateway'
|
||||
WARN_PARAM = 'warn'
|
||||
URL_PARAM = 'url'
|
||||
TICKET_PARAM = 'ticket'
|
||||
PGT_URL_PARAM = 'pgtUrl'
|
||||
PGT_PARAM = 'pgt'
|
||||
PGT_ID_PARAM = 'pgtId'
|
||||
PGT_IOU_PARAM = 'pgtIou'
|
||||
TARGET_SERVICE_PARAM = 'targetService'
|
||||
USERNAME_FIELD = 'username' # unused
|
||||
PASSWORD_FIELD = 'password' # unused
|
||||
LT_FIELD = 'lt' # unused
|
||||
SERVICE_TICKET_PREFIX = 'ST-'
|
||||
ID_PARAM = 'id'
|
||||
CANCEL_PARAM = 'cancel'
|
||||
|
||||
# ERROR codes
|
||||
INVALID_REQUEST_ERROR = 'INVALID_REQUEST'
|
||||
INVALID_TICKET_ERROR = 'INVALID_TICKET'
|
||||
INVALID_SERVICE_ERROR = 'INVALID_SERVICE'
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR'
|
||||
BAD_PGT_ERROR = 'BAD_PGT'
|
||||
|
||||
# XML Elements for CAS 2.0
|
||||
SERVICE_RESPONSE_ELT = 'serviceResponse'
|
||||
|
||||
AUTHENTICATION_SUCCESS_ELT = 'authenticationSuccess'
|
||||
USER_ELT = 'user'
|
||||
PGT_ELT = 'proxyGrantingTicket'
|
||||
PROXIES_ELT = 'proxies'
|
||||
PROXY_ELT = 'proxy'
|
||||
|
||||
AUTHENTICATION_FAILURE_ELT = 'authenticationFailure'
|
||||
CODE_ELT = 'code'
|
||||
|
||||
PROXY_SUCCESS_ELT = 'proxySuccess'
|
||||
PROXY_TICKET_ELT = 'proxyTicket'
|
||||
|
||||
PROXY_FAILURE_ELT = 'proxyFailure'
|
||||
|
||||
# Templates
|
||||
|
||||
CAS10_VALIDATION_FAILURE = 'no\n\n'
|
||||
CAS10_VALIDATION_SUCCESS = 'yes\n%s\n'
|
||||
CAS20_VALIDATION_FAILURE = '''<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
|
||||
<cas:authenticationFailure code="%s">
|
||||
%s
|
||||
</cas:authenticationFailure>
|
||||
</cas:serviceResponse>'''
|
||||
CAS20_VALIDATION_SUCCESS = '''<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>%s</cas:user>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>'''
|
|
@ -0,0 +1,22 @@
|
|||
import datetime
|
||||
|
||||
from django.db.models import query
|
||||
from django.utils.timezone import now
|
||||
|
||||
from model_utils import managers
|
||||
|
||||
from . import app_settings
|
||||
|
||||
|
||||
class CasTicketQuerySet(query.QuerySet):
|
||||
def clean_expired(self):
|
||||
'''Remove expired tickets'''
|
||||
self.filter(expire__gte=now()).delete()
|
||||
|
||||
def cleanup(self):
|
||||
'''Delete old tickets'''
|
||||
delta = datetime.timedelta(seconds=app_settings.TICKET_EXPIRATION)
|
||||
qs = self.filter(creation__lt=now()-delta)
|
||||
qs.delete()
|
||||
|
||||
CasTicketManager = managers.PassThroughManager.for_queryset_class(CasTicketQuerySet)
|
|
@ -0,0 +1,42 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from authentic2.models import LogoutUrlAbstract
|
||||
|
||||
from . import app_setting, managers
|
||||
|
||||
|
||||
class CasTicket(models.Model):
|
||||
'''Session ticket with a CAS 1.0 or 2.0 consumer'''
|
||||
|
||||
ticket_id = models.CharField(max_length=64)
|
||||
renew = models.BooleanField(default=False)
|
||||
validity = models.BooleanField(default=False)
|
||||
service = models.CharField(max_length=256)
|
||||
user = models.CharField(max_length=128,blank=True,null=True)
|
||||
creation = models.DateTimeField(auto_now_add=True)
|
||||
'''Duration length for the ticket as seconds'''
|
||||
expire = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
objects = managers.CasTicketManager()
|
||||
|
||||
def valid(self):
|
||||
return self.validity and not self.expired()
|
||||
|
||||
def expired(self):
|
||||
'''Check if the given CAS ticket has expired'''
|
||||
if self.expire:
|
||||
return now() >= self.expire
|
||||
else:
|
||||
return False
|
||||
|
||||
class CasService(LogoutUrlAbstract):
|
||||
name = models.CharField(max_length=128, unique=True, verbose_name=_('name'))
|
||||
slug = models.SlugField(max_length=128, unique=True, verbose_name=_('slug'))
|
||||
domain = models.CharField(max_length=128, unique=True,
|
||||
verbose_name=_('domain'))
|
||||
|
||||
class Meta:
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
|
||||
from authentic2.compat import get_user_model
|
||||
from .models import CasTicket
|
||||
from . import views
|
||||
from . import constants
|
||||
|
||||
|
||||
class CasTests(TestCase):
|
||||
LOGIN = 'test'
|
||||
PASSWORD = 'test'
|
||||
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(self.LOGIN, password=self.PASSWORD)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_service_validate_with_default_attributes(self):
|
||||
CasTicket.objects.create(
|
||||
ticket_id='ST-xxx',
|
||||
service='yyy',
|
||||
user=self.user,
|
||||
validity=True)
|
||||
request = self.factory.get('/idp/cas/serviceValidate',
|
||||
{'service': 'yyy', 'ticket': 'ST-xxx'})
|
||||
class TestCasProvider(views.CasProvider):
|
||||
def get_attributes(self, request, st):
|
||||
assert st.service == 'yyy'
|
||||
assert st.ticket_id == 'ST-xxx'
|
||||
return { 'username': 'bob', 'email': 'bob@example.com' }, 'default'
|
||||
provider = TestCasProvider()
|
||||
response = provider.service_validate(request)
|
||||
print response.content
|
||||
root = ET.fromstring(response.content)
|
||||
ns_ctx = { 'cas': constants.CAS_NAMESPACE }
|
||||
user_elt = root.find('cas:authenticationSuccess/cas:utilisateur', namespaces=ns_ctx)
|
||||
assert user_elt is not None
|
||||
|
||||
def test_service_validate_with_custom_attributes(self):
|
||||
CasTicket.objects.create(
|
||||
ticket_id='ST-xxx',
|
||||
service='yyy',
|
||||
user=self.user,
|
||||
validity=True)
|
||||
request = self.factory.get('/idp/cas/serviceValidate',
|
||||
{'service': 'yyy', 'ticket': 'ST-xxx'})
|
||||
class TestCasProvider(views.CasProvider):
|
||||
def get_attributes(self, request, st):
|
||||
assert st.service == 'yyy'
|
||||
assert st.ticket_id == 'ST-xxx'
|
||||
return { 'username': 'bob', 'email': 'bob@example.com' }, 'utilisateur'
|
||||
provider = TestCasProvider()
|
||||
response = provider.service_validate(request)
|
||||
print response.content
|
|
@ -0,0 +1,6 @@
|
|||
from django.conf.urls import patterns, include
|
||||
|
||||
from . import app_settings
|
||||
|
||||
urlpatterns = patterns('',
|
||||
('^idp/cas/', include(app_settings.PROVIDER()().url)))
|
|
@ -0,0 +1,414 @@
|
|||
import urlparse
|
||||
import logging
|
||||
import random
|
||||
import datetime
|
||||
import string
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from django.http import HttpResponseRedirect, HttpResponseBadRequest, \
|
||||
HttpResponse, HttpResponseNotAllowed
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.views import redirect_to_login, logout
|
||||
from django.utils.http import urlquote, urlencode
|
||||
from django.conf.urls import patterns, url
|
||||
from django.conf import settings
|
||||
|
||||
from models import CasTicket
|
||||
from authentic2.views import redirect_to_login as \
|
||||
auth2_redirect_to_login
|
||||
from authentic2.models import AuthenticationEvent
|
||||
from constants import SERVICE_PARAM, RENEW_PARAM, GATEWAY_PARAM, ID_PARAM, \
|
||||
CANCEL_PARAM, SERVICE_TICKET_PREFIX, TICKET_PARAM, \
|
||||
CAS10_VALIDATION_FAILURE, CAS10_VALIDATION_SUCCESS, PGT_URL_PARAM, \
|
||||
INVALID_REQUEST_ERROR, INVALID_TICKET_ERROR, INVALID_SERVICE_ERROR, \
|
||||
INTERNAL_ERROR, CAS20_VALIDATION_FAILURE, CAS20_VALIDATION_SUCCESS, \
|
||||
CAS_NAMESPACE, USER_ELT, SERVICE_RESPONSE_ELT, AUTHENTICATION_SUCCESS_ELT
|
||||
|
||||
from . import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALPHABET = string.letters+string.digits+'-'
|
||||
|
||||
SAML_RESPONSE_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<SOAP-ENV:Header/>
|
||||
<SOAP-ENV:Body>
|
||||
<Response xmlns="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IssueInstant="2013-05-16T16:07:35Z" MajorVersion="1" MinorVersion="1" Recipient="https://amonecole.monreseau.lan/webcalendar/login.php" ResponseID="{reponse_id}">
|
||||
<Status>
|
||||
<StatusCode Value="samlp:Success">
|
||||
</StatusCode>
|
||||
</Status>
|
||||
<Assertion xmlns="urn:oasis:names:tc:SAML:1.0:assertion" AssertionID="{assertion_id}" IssueInstant="{issue_instant}" Issuer="{issuer}" MajorVersion="1" MinorVersion="1">
|
||||
<Conditions NotBefore="{not_before}" NotOnOrAfter="{not_on_or_after}">
|
||||
<AudienceRestrictionCondition>
|
||||
<Audience>{audience}</Audience>
|
||||
</AudienceRestrictionCondition>
|
||||
</Conditions>
|
||||
<AttributeStatement>
|
||||
<Subject>
|
||||
<NameIdentifier>{name_id}</NameIdentifier>
|
||||
<SubjectConfirmation>
|
||||
<ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:artifact</ConfirmationMethod>
|
||||
</SubjectConfirmation>
|
||||
</Subject>
|
||||
{attributes}
|
||||
|
||||
</AttributeStatement>
|
||||
<AuthenticationStatement AuthenticationInstant="{authentication_instant}" AuthenticationMethod="{authentication_method}">
|
||||
<Subject>
|
||||
<NameIdentifier>{name_id}</NameIdentifier>
|
||||
<SubjectConfirmation>
|
||||
<ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:artifact</ConfirmationMethod>
|
||||
</SubjectConfirmation>
|
||||
</Subject>
|
||||
</AuthenticationStatement>
|
||||
</Assertion>
|
||||
</Response>
|
||||
</SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>'''
|
||||
|
||||
class CasProvider(object):
|
||||
def get_url(self):
|
||||
return patterns('cas',
|
||||
url('^login/$', self.login),
|
||||
url('^continue/$', self.continue_cas),
|
||||
url('^validate/$', self.validate),
|
||||
url('^serviceValidate/$', self.service_validate),
|
||||
url('^samlValidate/$', self.saml_validate),
|
||||
url('^logout/$', self.logout))
|
||||
url = property(get_url)
|
||||
|
||||
def make_id(self, prefix='', length=29):
|
||||
l = length-len(prefix)
|
||||
content = ( random.SystemRandom().choice(ALPHABET) for x in range(l) )
|
||||
return prefix + ''.join(content)
|
||||
|
||||
def create_service_ticket(self, service, renew=False, validity=True,
|
||||
expire=None, user=None):
|
||||
'''Create a fresh service ticket'''
|
||||
validity = validity and not renew
|
||||
return CasTicket.objects.create(ticket_id=self.make_id(prefix='ST-'),
|
||||
service=service,
|
||||
renew=renew,
|
||||
validity=validity,
|
||||
expire=None,
|
||||
user=user)
|
||||
|
||||
def check_authentication(self, request, st):
|
||||
'''
|
||||
Check that the given service ticket is linked to an authentication
|
||||
event.
|
||||
'''
|
||||
return False
|
||||
|
||||
def failure(self, request, reason):
|
||||
'''
|
||||
Return a HTTP 400 code with the @reason argument as content.
|
||||
'''
|
||||
return HttpResponseBadRequest(content=reason)
|
||||
|
||||
def login(self, request):
|
||||
if request.method != 'GET':
|
||||
return HttpResponseBadRequest('Only GET HTTP verb is accepted')
|
||||
service = request.GET.get(SERVICE_PARAM)
|
||||
renew = request.GET.get(RENEW_PARAM) is not None
|
||||
gateway = request.GET.get(GATEWAY_PARAM) is not None
|
||||
|
||||
if not service:
|
||||
return self.failure(request, 'no service field')
|
||||
if not service.startswith('http://') and not \
|
||||
service.startswith('https://'):
|
||||
return self.failure(request, 'service is not an HTTP or HTTPS URL')
|
||||
scheme, domain, x, x, x, x = urlparse.urlparse(service)
|
||||
try:
|
||||
cas_service = models.CasService.get(domain=domain)
|
||||
except models.CasService.DoesNotExist:
|
||||
self.failure(request, 'service %r is not allowed' % service)
|
||||
return self.handle_login(request, cas_service, service, renew, gateway)
|
||||
|
||||
def must_authenticate(self, request, renew):
|
||||
'''Does the user needs to authenticate ?
|
||||
|
||||
You can refer to the current request and to the renew parameter from
|
||||
the login reuest.
|
||||
|
||||
Returns a boolean.
|
||||
'''
|
||||
return not request.user.is_authenticated() or renew
|
||||
|
||||
def get_cas_user(self, request):
|
||||
'''Return an ascii string representing the user.
|
||||
|
||||
It should usually be the uid from an user record in a LDAP
|
||||
'''
|
||||
return request.user.username
|
||||
|
||||
def handle_login(self, request, cas_service, service, renew, gateway,
|
||||
duration=None):
|
||||
'''
|
||||
Handle a login request
|
||||
|
||||
@service: URL where the CAS ticket will be returned
|
||||
@renew: whether to re-authenticate the user
|
||||
@gateway: do not let the IdP interact with the user
|
||||
|
||||
It is an extension point
|
||||
'''
|
||||
logger.debug('Handling CAS login for service:%r with parameters \
|
||||
renew:%s and gateway:%s' % (service, renew, gateway))
|
||||
|
||||
if duration is None or duration < 0:
|
||||
duration = 5*60
|
||||
if duration:
|
||||
expire = datetime.datetime.now() + \
|
||||
datetime.timedelta(seconds=duration)
|
||||
else:
|
||||
expire = None
|
||||
if self.must_authenticate(request, renew):
|
||||
st = self.create_service_ticket(service, validity=False,
|
||||
renew=renew, expire=expire)
|
||||
return self.authenticate(request, st, passive=gateway)
|
||||
else:
|
||||
st = self.create_service_ticket(service, expire=expire,
|
||||
user=self.get_cas_user(request))
|
||||
return self.handle_login_after_authentication(request, st)
|
||||
|
||||
def cas_failure(self, request, st, reason):
|
||||
logger.debug('%s, redirecting without ticket to %r' % (reason, \
|
||||
st.service))
|
||||
st.delete()
|
||||
return HttpResponseRedirect(st.service)
|
||||
|
||||
def authenticate(self, request, st, passive=False):
|
||||
'''
|
||||
Redirect to an login page, pass a cookie to the login page to
|
||||
associate the login event with the service ticket, if renew was
|
||||
asked
|
||||
|
||||
It is an extension point. If your application support some passive
|
||||
authentication, it must be tried here instead of failing.
|
||||
@request: current django request
|
||||
@st: a currently invalid service ticket
|
||||
@passive: whether we can interact with the user
|
||||
'''
|
||||
|
||||
if passive:
|
||||
return self.cas_failure(request, st,
|
||||
'user needs to log in and gateway is True')
|
||||
if st.renew:
|
||||
raise NotImplementedError('renew is not implemented')
|
||||
return redirect_to_login(next='%s?id=%s' % (reverse(self.continue_cas),
|
||||
urlquote(st.ticket_id)))
|
||||
|
||||
def continue_cas(self, request):
|
||||
'''Continue CAS login after authentication'''
|
||||
ticket_id = request.GET.get(ID_PARAM)
|
||||
cancel = request.GET.get(CANCEL_PARAM) is not None
|
||||
if ticket_id is None:
|
||||
return self.failure(request, 'missing ticket id')
|
||||
if not ticket_id.startswith(SERVICE_TICKET_PREFIX):
|
||||
return self.failure(request, 'invalid ticket id')
|
||||
try:
|
||||
st = CasTicket.objects.get(ticket_id=ticket_id)
|
||||
except CasTicket.DoesNotExist:
|
||||
return self.failure(request, 'unknown ticket id')
|
||||
if cancel:
|
||||
return self.cas_failure(request, st, 'login cancelled')
|
||||
if st.renew:
|
||||
# renew login
|
||||
if self.check_authentication(request, st):
|
||||
return self.handle_login_after_authentication(request, st)
|
||||
else:
|
||||
return self.authenticate(request, st)
|
||||
elif self.must_authenticate(request, False):
|
||||
# not logged ? Yeah do it again!
|
||||
return self.authenticate(request, st)
|
||||
else:
|
||||
# normal login
|
||||
st.user = self.get_cas_user(request)
|
||||
st.validity = True
|
||||
st.save()
|
||||
return self.handle_login_after_authentication(request, st)
|
||||
|
||||
def handle_login_after_authentication(self, request, st):
|
||||
if not st.valid():
|
||||
return self.cas_failure(request, st,
|
||||
'service ticket id is not valid')
|
||||
else:
|
||||
return self.return_ticket(request, st)
|
||||
|
||||
def return_ticket(self, request, st):
|
||||
if '?' in st.service:
|
||||
return HttpResponseRedirect('%s&ticket=%s' % (st.service,st.ticket_id))
|
||||
else:
|
||||
return HttpResponseRedirect('%s?ticket=%s' % (st.service,st.ticket_id))
|
||||
|
||||
def validate(self, request):
|
||||
if request.method != 'GET':
|
||||
return self.failure(request, 'Only GET HTTP verb is accepted')
|
||||
service = request.GET.get(SERVICE_PARAM)
|
||||
ticket = request.GET.get(TICKET_PARAM)
|
||||
renew = request.GET.get(RENEW_PARAM) is not None
|
||||
if service is None:
|
||||
return self.failure(request, 'service parameter is missing')
|
||||
if service is None:
|
||||
return self.failure(request, 'ticket parameter is missing')
|
||||
if not ticket.startswith(SERVICE_TICKET_PREFIX):
|
||||
return self.failure(request, 'invalid ticket prefix')
|
||||
try:
|
||||
st = CasTicket.objects.get(ticket_id=ticket)
|
||||
st.delete()
|
||||
except CasTicket.DoesNotExist:
|
||||
st = None
|
||||
if st is None \
|
||||
or not st.valid() \
|
||||
or (st.renew ^ renew) \
|
||||
or st.service != service:
|
||||
return HttpResponse(CAS10_VALIDATION_FAILURE)
|
||||
else:
|
||||
return HttpResponse(CAS10_VALIDATION_SUCCESS % st.user)
|
||||
|
||||
def get_cas20_error_message(self, code):
|
||||
return '' # FIXME
|
||||
|
||||
def cas20_error(self, request, code):
|
||||
message = self.get_cas20_error_message(code)
|
||||
return HttpResponse(CAS20_VALIDATION_FAILURE % (code, message),
|
||||
content_type='text/xml')
|
||||
|
||||
def get_attributes(self, request, st):
|
||||
# XXX: st.service contains the requesting service URL, use it to match CAS attribute policy
|
||||
return {}, False
|
||||
|
||||
def saml_build_attributes(self, request, st):
|
||||
attributes, section = self.get_attributes(request, st)
|
||||
result = []
|
||||
for key, value in attributes.iteritems():
|
||||
key = key.encode('utf-8')
|
||||
value = value.encode('utf-8')
|
||||
result.append('''<Attribute AttributeName="{key}" AttributeNamespace="http://www.ja-sig.org/products/cas/">
|
||||
<AttributeValue>{value}</AttributeValue>
|
||||
</Attribute>'''.format(key=key, value=value))
|
||||
return ''.join(result)
|
||||
|
||||
def saml_validate(self, request):
|
||||
if request.method != 'POST':
|
||||
return HttpResponseNotAllowed(['POST'])
|
||||
root = ET.fromstring(request.body)
|
||||
ns = dict(
|
||||
SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||
samlp = 'urn:oasis:names:tc:SAML:1.0:protocol')
|
||||
if root.tag != '{%(SOAP_ENV)s}Envelope' % ns:
|
||||
return self.saml_error(request, INVALID_REQUEST_ERROR)
|
||||
assertion_artifact = root.find('{%(SOAP_ENV)s}Body/{%(samlp)s}Request/{%(samlp)s}AssertionArtifact')
|
||||
ticket = assertion_artifact.text
|
||||
try:
|
||||
st = CasTicket.objects.get(ticket_id=ticket)
|
||||
st.delete()
|
||||
except CasTicket.DoesNotExist:
|
||||
st = None
|
||||
if st is None or not st.valid():
|
||||
return self.saml_error(request, INVALID_TICKET_ERROR)
|
||||
new_id = self.generate_id()
|
||||
|
||||
ctx = {
|
||||
'response_id': new_id,
|
||||
'assertion_id': new_id,
|
||||
'issue_instant': '', # XXX: iso time
|
||||
'issuer': request.build_absolute_uri('/'),
|
||||
'not_before': '', # XXX: issue time - lag
|
||||
'not_on_or_after': '', # XXX issue time + lag,
|
||||
'audience': st.service.encode('utf-8'),
|
||||
'name_id': request.user.username,
|
||||
'attributes': self.saml_build_attributes(request, st),
|
||||
}
|
||||
return HttpResponse(SAML_RESPONSE_TEMPLATE.format(**ctx),
|
||||
content_type='text/xml')
|
||||
|
||||
def service_validate_success_response(self, request, st):
|
||||
attributes, section = self.get_attributes(request, st)
|
||||
try:
|
||||
ET.register_namespace('cas', 'http://www.yale.edu/tp/cas')
|
||||
except AttributeError:
|
||||
ET._namespace_map['http://www.yale.edu/tp/cas'] = 'cas'
|
||||
root = ET.Element('{%s}%s' % (CAS_NAMESPACE, SERVICE_RESPONSE_ELT))
|
||||
success = ET.SubElement(root, '{%s}%s' % (CAS_NAMESPACE, AUTHENTICATION_SUCCESS_ELT))
|
||||
if attributes:
|
||||
if section == 'default':
|
||||
user = success
|
||||
else:
|
||||
user = ET.SubElement(success, '{%s}%s' % (CAS_NAMESPACE, section))
|
||||
for key, value in attributes.iteritems():
|
||||
elt = ET.SubElement(user, '{%s}%s' % (CAS_NAMESPACE, key))
|
||||
elt.text = unicode(value)
|
||||
else:
|
||||
user = ET.SubElement(success, '{%s}%s' % (CAS_NAMESPACE, USER_ELT))
|
||||
user.text = unicode(st.user)
|
||||
return HttpResponse(ET.tostring(root, encoding='utf8'),
|
||||
content_type='text/xml')
|
||||
|
||||
def service_validate(self, request):
|
||||
'''
|
||||
CAS 2.0 serviceValidate endpoint.
|
||||
'''
|
||||
try:
|
||||
if request.method != 'GET':
|
||||
return self.failure('Only GET HTTP verb is accepted')
|
||||
service = request.GET.get(SERVICE_PARAM)
|
||||
ticket = request.GET.get(TICKET_PARAM)
|
||||
renew = request.GET.get(RENEW_PARAM) is not None
|
||||
pgt_url = request.GET.get(PGT_URL_PARAM)
|
||||
if service is None:
|
||||
return self.cas20_error(request, INVALID_REQUEST_ERROR)
|
||||
if service is None:
|
||||
return self.cas20_error(request, INVALID_REQUEST_ERROR)
|
||||
if not ticket.startswith(SERVICE_TICKET_PREFIX):
|
||||
return self.cas20_error(request, INVALID_TICKET_ERROR)
|
||||
try:
|
||||
st = CasTicket.objects.get(ticket_id=ticket)
|
||||
st.delete()
|
||||
except CasTicket.DoesNotExist:
|
||||
st = None
|
||||
if st is None \
|
||||
or not st.valid() \
|
||||
or (st.renew ^ renew):
|
||||
return self.cas20_error(request, INVALID_TICKET_ERROR)
|
||||
if st.service != service:
|
||||
return self.cas20_error(request, INVALID_SERVICE_ERROR)
|
||||
if pgt_url:
|
||||
raise NotImplementedError(
|
||||
'CAS 2.0 pgtUrl parameter is not handled')
|
||||
return self.service_validate_success_response(request, st)
|
||||
except Exception:
|
||||
logger.exception('error in cas:service_validate')
|
||||
return self.cas20_error(INTERNAL_ERROR)
|
||||
|
||||
def logout(self, request):
|
||||
return HttpResponseRedirect(settings.LOGOUT_URL)
|
||||
|
||||
class Authentic2CasProvider(CasProvider):
|
||||
def authenticate(self, request, st, passive=False):
|
||||
next = '%s?id=%s' % (reverse(self.continue_cas),
|
||||
urlquote(st.ticket_id))
|
||||
if passive:
|
||||
if getattr(settings, 'AUTH_SSL', False):
|
||||
query = { 'next': next,
|
||||
'nonce': st.ticket_id }
|
||||
return HttpResponseRedirect('%s?%s' %
|
||||
(reverse('user_signin_ssl'), urlencode(query)))
|
||||
else:
|
||||
return self.cas_failure(request, st,
|
||||
'''user needs to login and no passive authentication \
|
||||
is possible''')
|
||||
return auth2_redirect_to_login(request, next=next, nonce=st.ticket_id)
|
||||
|
||||
def check_authentication(self, request, st):
|
||||
try:
|
||||
ae = AuthenticationEvent.objects.get(nonce=st.ticket_id)
|
||||
st.user = ae.who
|
||||
st.validity = True
|
||||
st.save()
|
||||
return True
|
||||
except AuthenticationEvent.DoesNotExist:
|
||||
return False
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/python
|
||||
from setuptools import setup, find_packages
|
||||
import os
|
||||
|
||||
setup(name='authentic2-idp-cas',
|
||||
version='1.0',
|
||||
license='AGPLv3',
|
||||
description='Authentic2 IdP CAS',
|
||||
author="Entr'ouvert",
|
||||
author_email="info@entrouvert.com",
|
||||
packages=find_packages(os.path.dirname(__file__) or '.'),
|
||||
install_requires=[
|
||||
'djangorestframework',
|
||||
],
|
||||
entry_points={
|
||||
'authentic2.plugin': [
|
||||
'authentic-idp-cas = authentic2_idp_cas:Plugin',
|
||||
],
|
||||
},
|
||||
)
|
Reference in New Issue