Use transactions throughout the code
[cascardo/ipsilon.git] / ipsilon / providers / saml2 / auth.py
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2014  Simo Sorce <simo@redhat.com>
4 #
5 # see file 'COPYING' for use and warranty information
6 #
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 from ipsilon.providers.common import ProviderPageBase, ProviderException
21 from ipsilon.providers.saml2.provider import ServiceProvider
22 from ipsilon.providers.saml2.provider import InvalidProviderId
23 from ipsilon.providers.saml2.provider import NameIdNotAllowed
24 from ipsilon.util.user import UserSession
25 from ipsilon.util.trans import Transaction
26 import cherrypy
27 import datetime
28 import lasso
29
30
31 class AuthenticationError(ProviderException):
32
33     def __init__(self, message, code):
34         super(AuthenticationError, self).__init__(message)
35         self.code = code
36         self._debug('%s [%s]' % (message, code))
37
38
39 class InvalidRequest(ProviderException):
40
41     def __init__(self, message):
42         super(InvalidRequest, self).__init__(message)
43         self._debug(message)
44
45
46 class UnknownProvider(ProviderException):
47
48     def __init__(self, message):
49         super(UnknownProvider, self).__init__(message)
50         self._debug(message)
51
52
53 class AuthenticateRequest(ProviderPageBase):
54
55     def __init__(self, *args, **kwargs):
56         super(AuthenticateRequest, self).__init__(*args, **kwargs)
57         self.stage = 'init'
58         self.trans = None
59
60     def _preop(self, *args, **kwargs):
61         try:
62             # generate a new id or get current one
63             self.trans = Transaction('saml2', **kwargs)
64             if self.trans.cookie.value != self.trans.provider:
65                 self.debug('Invalid transaction, %s != %s' % (
66                            self.trans.cookie.value, self.trans.provider))
67         except Exception, e:  # pylint: disable=broad-except
68             self.debug('Transaction initialization failed: %s' % repr(e))
69             raise cherrypy.HTTPError(400, 'Invalid transaction id')
70
71     def pre_GET(self, *args, **kwargs):
72         self._preop(*args, **kwargs)
73
74     def pre_POST(self, *args, **kwargs):
75         self._preop(*args, **kwargs)
76
77     def auth(self, login):
78         try:
79             self.saml2checks(login)
80         except AuthenticationError, e:
81             self.saml2error(login, e.code, e.message)
82         return self.reply(login)
83
84     def _parse_request(self, message):
85
86         login = self.cfg.idp.get_login_handler()
87
88         try:
89             login.processAuthnRequestMsg(message)
90         except (lasso.ProfileInvalidMsgError,
91                 lasso.ProfileMissingIssuerError), e:
92
93             msg = 'Malformed Request %r [%r]' % (e, message)
94             raise InvalidRequest(msg)
95
96         except (lasso.ProfileInvalidProtocolprofileError,
97                 lasso.DsError), e:
98
99             msg = 'Invalid SAML Request: %r (%r [%r])' % (login.request,
100                                                           e, message)
101             raise InvalidRequest(msg)
102
103         except (lasso.ServerProviderNotFoundError,
104                 lasso.ProfileUnknownProviderError), e:
105
106             msg = 'Invalid SP [%s] (%r [%r])' % (login.remoteProviderId,
107                                                  e, message)
108             raise UnknownProvider(msg)
109
110         self._debug('SP %s requested authentication' % login.remoteProviderId)
111
112         return login
113
114     def saml2login(self, request):
115
116         if not request:
117             raise cherrypy.HTTPError(400,
118                                      'SAML request token missing or empty')
119
120         try:
121             login = self._parse_request(request)
122         except InvalidRequest, e:
123             self._debug(str(e))
124             raise cherrypy.HTTPError(400, 'Invalid SAML request token')
125         except UnknownProvider, e:
126             self._debug(str(e))
127             raise cherrypy.HTTPError(400, 'Unknown Service Provider')
128         except Exception, e:  # pylint: disable=broad-except
129             self._debug(str(e))
130             raise cherrypy.HTTPError(500)
131
132         return login
133
134     def saml2checks(self, login):
135
136         us = UserSession()
137         user = us.get_user()
138         if user.is_anonymous:
139             if self.stage == 'init':
140                 returl = '%s/saml2/SSO/Continue?%s' % (
141                     self.basepath, self.trans.get_GET_arg())
142                 data = {'saml2_stage': 'auth',
143                         'saml2_request': login.dump(),
144                         'login_return': returl}
145                 self.trans.store(data)
146                 redirect = '%s/login?%s' % (self.basepath,
147                                             self.trans.get_GET_arg())
148                 raise cherrypy.HTTPRedirect(redirect)
149             else:
150                 raise AuthenticationError(
151                     "Unknown user", lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
152
153         self._audit("Logged in user: %s [%s]" % (user.name, user.fullname))
154
155         # We can wipe the transaction now, as this is the last step
156         self.trans.wipe()
157
158         # TODO: check if this is the first time this user access this SP
159         # If required by user prefs, ask user for consent once and then
160         # record it
161         consent = True
162
163         # TODO: check destination
164
165         try:
166             provider = ServiceProvider(self.cfg, login.remoteProviderId)
167             nameidfmt = provider.get_valid_nameid(login.request.nameIdPolicy)
168         except NameIdNotAllowed, e:
169             raise AuthenticationError(
170                 str(e), lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY)
171         except InvalidProviderId, e:
172             raise AuthenticationError(
173                 str(e), lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
174
175         # TODO: check login.request.forceAuthn
176
177         login.validateRequestMsg(not user.is_anonymous, consent)
178
179         authtime = datetime.datetime.utcnow()
180         skew = datetime.timedelta(0, 60)
181         authtime_notbefore = authtime - skew
182         authtime_notafter = authtime + skew
183
184         # TODO: get authentication type fnd name format from session
185         # need to save which login manager authenticated and map it to a
186         # saml2 authentication context
187         authn_context = lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED
188
189         timeformat = '%Y-%m-%dT%H:%M:%SZ'
190         login.buildAssertion(authn_context,
191                              authtime.strftime(timeformat),
192                              None,
193                              authtime_notbefore.strftime(timeformat),
194                              authtime_notafter.strftime(timeformat))
195
196         nameid = None
197         if nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT:
198             # TODO map to something else ?
199             nameid = provider.normalize_username(user.name)
200         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT:
201             # TODO map to something else ?
202             nameid = provider.normalize_username(user.name)
203         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS:
204             nameid = us.get_data('user', 'krb_principal_name')
205         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL:
206             nameid = us.get_user().email
207             if not nameid:
208                 nameid = '%s@%s' % (user.name, self.cfg.default_email_domain)
209
210         if nameid:
211             login.assertion.subject.nameId.format = nameidfmt
212             login.assertion.subject.nameId.content = nameid
213         else:
214             self.trans.wipe()
215             raise AuthenticationError("Unavailable Name ID type",
216                                       lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
217
218         # TODO: filter user attributes as policy requires from 'usersession'
219         if not login.assertion.attributeStatement:
220             attrstat = lasso.Saml2AttributeStatement()
221             login.assertion.attributeStatement = [attrstat]
222         else:
223             attrstat = login.assertion.attributeStatement[0]
224         if not attrstat.attribute:
225             attrstat.attribute = ()
226
227         attributes = us.get_user_attrs()
228         for key in attributes:
229             attr = lasso.Saml2Attribute()
230             attr.name = key
231             attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
232             value = str(attributes[key]).encode('utf-8')
233             node = lasso.MiscTextNode.newWithString(value)
234             node.textChild = True
235             attrvalue = lasso.Saml2AttributeValue()
236             attrvalue.any = [node]
237             attr.attributeValue = [attrvalue]
238             attrstat.attribute = attrstat.attribute + (attr,)
239
240         self.debug('Assertion: %s' % login.assertion.dump())
241
242     def saml2error(self, login, code, message):
243         status = lasso.Samlp2Status()
244         status.statusCode = lasso.Samlp2StatusCode()
245         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
246         status.statusCode.statusCode = lasso.Samlp2StatusCode()
247         status.statusCode.statusCode.value = code
248         login.response.status = status
249
250     def reply(self, login):
251         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
252             # TODO
253             raise cherrypy.HTTPError(501)
254         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
255             login.buildAuthnResponseMsg()
256             self._debug('POSTing back to SP [%s]' % (login.msgUrl))
257             context = {
258                 "title": 'Redirecting back to the web application',
259                 "action": login.msgUrl,
260                 "fields": [
261                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
262                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
263                 ],
264                 "submit": 'Return to application',
265             }
266             # pylint: disable=star-args
267             return self._template('saml2/post_response.html', **context)
268
269         else:
270             raise cherrypy.HTTPError(500)