Handle user session data for both internal and external authentication
[cascardo/ipsilon.git] / ipsilon / providers / saml2 / auth.py
1 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
2
3 from ipsilon.providers.common import ProviderPageBase, ProviderException
4 from ipsilon.providers.common import AuthenticationError, InvalidRequest
5 from ipsilon.providers.saml2.provider import ServiceProvider
6 from ipsilon.providers.saml2.provider import InvalidProviderId
7 from ipsilon.providers.saml2.provider import NameIdNotAllowed
8 from ipsilon.tools import saml2metadata as metadata
9 from ipsilon.util.policy import Policy
10 from ipsilon.util.user import UserSession
11 from ipsilon.util.trans import Transaction
12 import cherrypy
13 import datetime
14 import lasso
15 import uuid
16 import hashlib
17
18
19 class UnknownProvider(ProviderException):
20
21     def __init__(self, message):
22         super(UnknownProvider, self).__init__(message)
23         self.debug(message)
24
25
26 class AuthenticateRequest(ProviderPageBase):
27
28     def __init__(self, site, provider, *args, **kwargs):
29         super(AuthenticateRequest, self).__init__(site, provider)
30         self.stage = 'init'
31         self.trans = None
32         self.binding = None
33
34     def _preop(self, *args, **kwargs):
35         try:
36             # generate a new id or get current one
37             self.trans = Transaction('saml2', **kwargs)
38
39             self.debug('self.binding=%s, transdata=%s' %
40                        (self.binding, self.trans.retrieve()))
41             if self.binding is None:
42                 # SAML binding is unknown, try to get it from transaction
43                 transdata = self.trans.retrieve()
44                 self.binding = transdata.get('saml2_binding')
45             else:
46                 # SAML binding known, store in transaction
47                 data = {'saml2_binding': self.binding}
48                 self.trans.store(data)
49
50             # Only check for cookie for those bindings which use one
51             if self.binding not in (metadata.SAML2_SERVICE_MAP['sso-soap'][1]):
52                 if self.trans.cookie.value != self.trans.provider:
53                     self.debug('Invalid transaction, %s != %s' % (
54                         self.trans.cookie.value, self.trans.provider))
55         except Exception, e:  # pylint: disable=broad-except
56             self.debug('Transaction initialization failed: %s' % repr(e))
57             raise cherrypy.HTTPError(400, 'Invalid transaction id')
58
59     def pre_GET(self, *args, **kwargs):
60         self._preop(*args, **kwargs)
61
62     def pre_POST(self, *args, **kwargs):
63         self._preop(*args, **kwargs)
64
65     def auth(self, login):
66         try:
67             self.saml2checks(login)
68         except AuthenticationError, e:
69             self.saml2error(login, e.code, e.message)
70         return self.reply(login)
71
72     def _parse_request(self, message):
73
74         login = self.cfg.idp.get_login_handler()
75
76         try:
77             login.processAuthnRequestMsg(message)
78         except (lasso.ProfileInvalidMsgError,
79                 lasso.ProfileMissingIssuerError), e:
80
81             msg = 'Malformed Request %r [%r]' % (e, message)
82             raise InvalidRequest(msg)
83
84         except (lasso.ProfileInvalidProtocolprofileError,
85                 lasso.DsError), e:
86
87             msg = 'Invalid SAML Request: %r (%r [%r])' % (login.request,
88                                                           e, message)
89             raise InvalidRequest(msg)
90
91         except (lasso.ServerProviderNotFoundError,
92                 lasso.ProfileUnknownProviderError), e:
93
94             msg = 'Invalid SP [%s] (%r [%r])' % (login.remoteProviderId,
95                                                  e, message)
96             raise UnknownProvider(msg)
97
98         self.debug('SP %s requested authentication' % login.remoteProviderId)
99
100         return login
101
102     def saml2login(self, request):
103
104         if not request:
105             raise cherrypy.HTTPError(400,
106                                      'SAML request token missing or empty')
107
108         try:
109             login = self._parse_request(request)
110         except InvalidRequest, e:
111             self.debug(str(e))
112             raise cherrypy.HTTPError(400, 'Invalid SAML request token')
113         except UnknownProvider, e:
114             self.debug(str(e))
115             raise cherrypy.HTTPError(400, 'Unknown Service Provider')
116         except Exception, e:  # pylint: disable=broad-except
117             self.debug(str(e))
118             raise cherrypy.HTTPError(500)
119
120         return login
121
122     def saml2checks(self, login):
123
124         us = UserSession()
125         user = us.get_user()
126         if user.is_anonymous:
127             if self.stage == 'init':
128                 returl = '%s/saml2/SSO/Continue?%s' % (
129                     self.basepath, self.trans.get_GET_arg())
130                 data = {'saml2_stage': 'auth',
131                         'saml2_request': login.dump(),
132                         'login_return': returl,
133                         'login_target': login.remoteProviderId}
134                 self.trans.store(data)
135                 redirect = '%s/login?%s' % (self.basepath,
136                                             self.trans.get_GET_arg())
137                 raise cherrypy.HTTPRedirect(redirect)
138             else:
139                 raise AuthenticationError(
140                     "Unknown user", lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
141
142         self._audit("Logged in user: %s [%s]" % (user.name, user.fullname))
143
144         # We can wipe the transaction now, as this is the last step
145         self.trans.wipe()
146
147         # TODO: check if this is the first time this user access this SP
148         # If required by user prefs, ask user for consent once and then
149         # record it
150         consent = True
151
152         # TODO: check destination
153
154         try:
155             provider = ServiceProvider(self.cfg, login.remoteProviderId)
156             nameidfmt = provider.get_valid_nameid(login.request.nameIdPolicy)
157         except NameIdNotAllowed, e:
158             raise AuthenticationError(
159                 str(e), lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY)
160         except InvalidProviderId, e:
161             raise AuthenticationError(
162                 str(e), lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
163
164         # TODO: check login.request.forceAuthn
165
166         login.validateRequestMsg(not user.is_anonymous, consent)
167
168         authtime = datetime.datetime.utcnow()
169         skew = datetime.timedelta(0, 60)
170         authtime_notbefore = authtime - skew
171         authtime_notafter = authtime + skew
172
173         # TODO: get authentication type fnd name format from session
174         # need to save which login manager authenticated and map it to a
175         # saml2 authentication context
176         authn_context = lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED
177
178         timeformat = '%Y-%m-%dT%H:%M:%SZ'
179         login.buildAssertion(authn_context,
180                              authtime.strftime(timeformat),
181                              None,
182                              authtime_notbefore.strftime(timeformat),
183                              authtime_notafter.strftime(timeformat))
184
185         nameid = None
186         if nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT:
187             idpsalt = self.cfg.idp_nameid_salt
188             if idpsalt is None:
189                 raise AuthenticationError(
190                     "idp nameid salt is not set in configuration"
191                 )
192             value = hashlib.sha512()
193             value.update(idpsalt)
194             value.update(login.remoteProviderId)
195             value.update(user.name)
196             nameid = '_' + value.hexdigest()
197         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT:
198             nameid = '_' + uuid.uuid4().hex
199         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS:
200             userattrs = us.get_user_attrs()
201             nameid = userattrs.get('gssapi_principal_name')
202         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL:
203             nameid = us.get_user().email
204             if not nameid:
205                 nameid = '%s@%s' % (user.name, self.cfg.default_email_domain)
206         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED:
207             nameid = provider.normalize_username(user.name)
208
209         if nameid:
210             login.assertion.subject.nameId.format = nameidfmt
211             login.assertion.subject.nameId.content = nameid
212         else:
213             self.trans.wipe()
214             self.error('Authentication succeeded but it was not ' +
215                        'provided by NameID %s' % nameidfmt)
216             raise AuthenticationError("Unavailable Name ID type",
217                                       lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
218
219         # Check attribute policy and perform mapping and filtering.
220         # If the SP has its own mapping or filtering policy use that
221         # instead of the global policy.
222         if (provider.attribute_mappings is not None and
223                 len(provider.attribute_mappings) > 0):
224             attribute_mappings = provider.attribute_mappings
225         else:
226             attribute_mappings = self.cfg.default_attribute_mapping
227         if (provider.allowed_attributes is not None and
228                 len(provider.allowed_attributes) > 0):
229             allowed_attributes = provider.allowed_attributes
230         else:
231             allowed_attributes = self.cfg.default_allowed_attributes
232         self.debug("Allowed attrs: %s" % allowed_attributes)
233         self.debug("Mapping: %s" % attribute_mappings)
234         policy = Policy(attribute_mappings, allowed_attributes)
235         userattrs = us.get_user_attrs()
236         mappedattrs, _ = policy.map_attributes(userattrs)
237         attributes = policy.filter_attributes(mappedattrs)
238
239         if '_groups' in attributes and 'groups' not in attributes:
240             attributes['groups'] = attributes['_groups']
241
242         self.debug("%s's attributes: %s" % (user.name, attributes))
243
244         # The saml-core-2.0-os specification section 2.7.3 requires
245         # the AttributeStatement element to be non-empty.
246         if attributes:
247             if not login.assertion.attributeStatement:
248                 attrstat = lasso.Saml2AttributeStatement()
249                 login.assertion.attributeStatement = [attrstat]
250             else:
251                 attrstat = login.assertion.attributeStatement[0]
252             if not attrstat.attribute:
253                 attrstat.attribute = ()
254
255         for key in attributes:
256             # skip internal info
257             if key[0] == '_':
258                 continue
259             values = attributes[key]
260             if isinstance(values, dict):
261                 continue
262             if not isinstance(values, list):
263                 values = [values]
264             for value in values:
265                 attr = lasso.Saml2Attribute()
266                 attr.name = key
267                 attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
268                 value = str(value).encode('utf-8')
269                 self.debug('value %s' % value)
270                 node = lasso.MiscTextNode.newWithString(value)
271                 node.textChild = True
272                 attrvalue = lasso.Saml2AttributeValue()
273                 attrvalue.any = [node]
274                 attr.attributeValue = [attrvalue]
275                 attrstat.attribute = attrstat.attribute + (attr,)
276
277         self.debug('Assertion: %s' % login.assertion.dump())
278
279         saml_sessions = self.cfg.idp.sessionfactory
280
281         lasso_session = lasso.Session()
282         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
283         provider = ServiceProvider(self.cfg, login.remoteProviderId)
284         saml_sessions.add_session(login.assertion.id,
285                                   login.remoteProviderId,
286                                   user.name,
287                                   lasso_session.dump(),
288                                   None,
289                                   provider.logout_mechs)
290
291     def saml2error(self, login, code, message):
292         status = lasso.Samlp2Status()
293         status.statusCode = lasso.Samlp2StatusCode()
294         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
295         status.statusCode.statusCode = lasso.Samlp2StatusCode()
296         status.statusCode.statusCode.value = code
297         login.response.status = status
298
299     def reply(self, login):
300         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
301             # TODO
302             raise cherrypy.HTTPError(501)
303         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
304             login.buildAuthnResponseMsg()
305             self.debug('POSTing back to SP [%s]' % (login.msgUrl))
306             context = {
307                 "title": 'Redirecting back to the web application',
308                 "action": login.msgUrl,
309                 "fields": [
310                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
311                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
312                 ],
313                 "submit": 'Return to application',
314             }
315             return self._template('saml2/post_response.html', **context)
316
317         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_LECP:
318             login.buildResponseMsg()
319             self.debug("Returning ECP: %s" % login.msgBody)
320             return login.msgBody
321
322         else:
323             raise cherrypy.HTTPError(500)