521e0c011814276727d74d86ee989998737c7d1d
[cascardo/ipsilon.git] / ipsilon / providers / saml2 / auth.py
1 # Copyright (C) 2014  Simo Sorce <simo@redhat.com>
2 #
3 # see file 'COPYING' for use and warranty information
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 from ipsilon.providers.common import ProviderPageBase, ProviderException
19 from ipsilon.providers.common import AuthenticationError, InvalidRequest
20 from ipsilon.providers.saml2.provider import ServiceProvider
21 from ipsilon.providers.saml2.provider import InvalidProviderId
22 from ipsilon.providers.saml2.provider import NameIdNotAllowed
23 from ipsilon.providers.saml2.sessions import SAMLSessionsContainer
24 from ipsilon.util.policy import Policy
25 from ipsilon.util.user import UserSession
26 from ipsilon.util.trans import Transaction
27 import cherrypy
28 import datetime
29 import lasso
30 import uuid
31 import hashlib
32
33
34 class UnknownProvider(ProviderException):
35
36     def __init__(self, message):
37         super(UnknownProvider, self).__init__(message)
38         self._debug(message)
39
40
41 class AuthenticateRequest(ProviderPageBase):
42
43     def __init__(self, *args, **kwargs):
44         super(AuthenticateRequest, self).__init__(*args, **kwargs)
45         self.stage = 'init'
46         self.trans = None
47
48     def _preop(self, *args, **kwargs):
49         try:
50             # generate a new id or get current one
51             self.trans = Transaction('saml2', **kwargs)
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             nameid = us.get_data('user', 'gssapi_principal_name')
201         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL:
202             nameid = us.get_user().email
203             if not nameid:
204                 nameid = '%s@%s' % (user.name, self.cfg.default_email_domain)
205         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED:
206             nameid = provider.normalize_username(user.name)
207
208         if nameid:
209             login.assertion.subject.nameId.format = nameidfmt
210             login.assertion.subject.nameId.content = nameid
211         else:
212             self.trans.wipe()
213             raise AuthenticationError("Unavailable Name ID type",
214                                       lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
215
216         # Check attribute policy and perform mapping and filtering.
217         # If the SP has its own mapping or filtering policy use that
218         # instead of the global policy.
219         if (provider.attribute_mappings is not None and
220                 len(provider.attribute_mappings) > 0):
221             attribute_mappings = provider.attribute_mappings
222         else:
223             attribute_mappings = self.cfg.default_attribute_mapping
224         if (provider.allowed_attributes is not None and
225                 len(provider.allowed_attributes) > 0):
226             allowed_attributes = provider.allowed_attributes
227         else:
228             allowed_attributes = self.cfg.default_allowed_attributes
229         self.debug("Allowed attrs: %s" % allowed_attributes)
230         self.debug("Mapping: %s" % attribute_mappings)
231         policy = Policy(attribute_mappings, allowed_attributes)
232         userattrs = us.get_user_attrs()
233         mappedattrs, _ = policy.map_attributes(userattrs)
234         attributes = policy.filter_attributes(mappedattrs)
235
236         if '_groups' in attributes and 'groups' not in attributes:
237             attributes['groups'] = attributes['_groups']
238
239         self.debug("%s's attributes: %s" % (user.name, attributes))
240
241         # The saml-core-2.0-os specification section 2.7.3 requires
242         # the AttributeStatement element to be non-empty.
243         if attributes:
244             if not login.assertion.attributeStatement:
245                 attrstat = lasso.Saml2AttributeStatement()
246                 login.assertion.attributeStatement = [attrstat]
247             else:
248                 attrstat = login.assertion.attributeStatement[0]
249             if not attrstat.attribute:
250                 attrstat.attribute = ()
251
252         for key in attributes:
253             # skip internal info
254             if key[0] == '_':
255                 continue
256             values = attributes[key]
257             if isinstance(values, dict):
258                 continue
259             if not isinstance(values, list):
260                 values = [values]
261             for value in values:
262                 attr = lasso.Saml2Attribute()
263                 attr.name = key
264                 attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
265                 value = str(value).encode('utf-8')
266                 self.debug('value %s' % value)
267                 node = lasso.MiscTextNode.newWithString(value)
268                 node.textChild = True
269                 attrvalue = lasso.Saml2AttributeValue()
270                 attrvalue.any = [node]
271                 attr.attributeValue = [attrvalue]
272                 attrstat.attribute = attrstat.attribute + (attr,)
273
274         self.debug('Assertion: %s' % login.assertion.dump())
275
276         saml_sessions = us.get_provider_data('saml2')
277         if saml_sessions is None:
278             saml_sessions = SAMLSessionsContainer()
279
280         session = saml_sessions.find_session_by_provider(
281             login.remoteProviderId)
282         if session:
283             # TODO: something...
284             self.debug('Login session for this user already exists!?')
285             session.dump()
286
287         lasso_session = lasso.Session()
288         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
289         saml_sessions.add_session(login.assertion.id,
290                                   login.remoteProviderId,
291                                   lasso_session)
292         us.save_provider_data('saml2', saml_sessions)
293
294     def saml2error(self, login, code, message):
295         status = lasso.Samlp2Status()
296         status.statusCode = lasso.Samlp2StatusCode()
297         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
298         status.statusCode.statusCode = lasso.Samlp2StatusCode()
299         status.statusCode.statusCode.value = code
300         login.response.status = status
301
302     def reply(self, login):
303         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
304             # TODO
305             raise cherrypy.HTTPError(501)
306         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
307             login.buildAuthnResponseMsg()
308             self._debug('POSTing back to SP [%s]' % (login.msgUrl))
309             context = {
310                 "title": 'Redirecting back to the web application',
311                 "action": login.msgUrl,
312                 "fields": [
313                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
314                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
315                 ],
316                 "submit": 'Return to application',
317             }
318             # pylint: disable=star-args
319             return self._template('saml2/post_response.html', **context)
320
321         else:
322             raise cherrypy.HTTPError(500)