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