44ed8349c3e91b825d0beeabe344dcbd5c546958
[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.user import UserSession
25 from ipsilon.util.trans import Transaction
26 import cherrypy
27 import datetime
28 import lasso
29
30
31 class UnknownProvider(ProviderException):
32
33     def __init__(self, message):
34         super(UnknownProvider, self).__init__(message)
35         self._debug(message)
36
37
38 class AuthenticateRequest(ProviderPageBase):
39
40     def __init__(self, *args, **kwargs):
41         super(AuthenticateRequest, self).__init__(*args, **kwargs)
42         self.stage = 'init'
43         self.trans = None
44
45     def _preop(self, *args, **kwargs):
46         try:
47             # generate a new id or get current one
48             self.trans = Transaction('saml2', **kwargs)
49             if self.trans.cookie.value != self.trans.provider:
50                 self.debug('Invalid transaction, %s != %s' % (
51                            self.trans.cookie.value, self.trans.provider))
52         except Exception, e:  # pylint: disable=broad-except
53             self.debug('Transaction initialization failed: %s' % repr(e))
54             raise cherrypy.HTTPError(400, 'Invalid transaction id')
55
56     def pre_GET(self, *args, **kwargs):
57         self._preop(*args, **kwargs)
58
59     def pre_POST(self, *args, **kwargs):
60         self._preop(*args, **kwargs)
61
62     def auth(self, login):
63         try:
64             self.saml2checks(login)
65         except AuthenticationError, e:
66             self.saml2error(login, e.code, e.message)
67         return self.reply(login)
68
69     def _parse_request(self, message):
70
71         login = self.cfg.idp.get_login_handler()
72
73         try:
74             login.processAuthnRequestMsg(message)
75         except (lasso.ProfileInvalidMsgError,
76                 lasso.ProfileMissingIssuerError), e:
77
78             msg = 'Malformed Request %r [%r]' % (e, message)
79             raise InvalidRequest(msg)
80
81         except (lasso.ProfileInvalidProtocolprofileError,
82                 lasso.DsError), e:
83
84             msg = 'Invalid SAML Request: %r (%r [%r])' % (login.request,
85                                                           e, message)
86             raise InvalidRequest(msg)
87
88         except (lasso.ServerProviderNotFoundError,
89                 lasso.ProfileUnknownProviderError), e:
90
91             msg = 'Invalid SP [%s] (%r [%r])' % (login.remoteProviderId,
92                                                  e, message)
93             raise UnknownProvider(msg)
94
95         self._debug('SP %s requested authentication' % login.remoteProviderId)
96
97         return login
98
99     def saml2login(self, request):
100
101         if not request:
102             raise cherrypy.HTTPError(400,
103                                      'SAML request token missing or empty')
104
105         try:
106             login = self._parse_request(request)
107         except InvalidRequest, e:
108             self._debug(str(e))
109             raise cherrypy.HTTPError(400, 'Invalid SAML request token')
110         except UnknownProvider, e:
111             self._debug(str(e))
112             raise cherrypy.HTTPError(400, 'Unknown Service Provider')
113         except Exception, e:  # pylint: disable=broad-except
114             self._debug(str(e))
115             raise cherrypy.HTTPError(500)
116
117         return login
118
119     def saml2checks(self, login):
120
121         us = UserSession()
122         user = us.get_user()
123         if user.is_anonymous:
124             if self.stage == 'init':
125                 returl = '%s/saml2/SSO/Continue?%s' % (
126                     self.basepath, self.trans.get_GET_arg())
127                 data = {'saml2_stage': 'auth',
128                         'saml2_request': login.dump(),
129                         'login_return': returl,
130                         'login_target': login.remoteProviderId}
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 = dict()
214         userattrs = us.get_user_attrs()
215         for key, value in userattrs.get('userdata', {}).iteritems():
216             if type(value) is str:
217                 attributes[key] = value
218         if 'groups' in userattrs:
219             attributes['group'] = userattrs['groups']
220         for _, info in userattrs.get('extras', {}).iteritems():
221             for key, value in info.items():
222                 attributes[key] = value
223
224         for key in attributes:
225             values = attributes[key]
226             if type(values) is not list:
227                 values = [values]
228             for value in values:
229                 attr = lasso.Saml2Attribute()
230                 attr.name = key
231                 attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
232                 value = str(value).encode('utf-8')
233                 self.debug('value %s' % value)
234                 node = lasso.MiscTextNode.newWithString(value)
235                 node.textChild = True
236                 attrvalue = lasso.Saml2AttributeValue()
237                 attrvalue.any = [node]
238                 attr.attributeValue = [attrvalue]
239                 attrstat.attribute = attrstat.attribute + (attr,)
240
241         self.debug('Assertion: %s' % login.assertion.dump())
242
243         saml_sessions = us.get_provider_data('saml2')
244         if saml_sessions is None:
245             saml_sessions = SAMLSessionsContainer()
246
247         session = saml_sessions.find_session_by_provider(
248             login.remoteProviderId)
249         if session:
250             # TODO: something...
251             self.debug('Login session for this user already exists!?')
252             session.dump()
253
254         lasso_session = lasso.Session()
255         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
256         saml_sessions.add_session(login.assertion.id,
257                                   login.remoteProviderId,
258                                   lasso_session)
259         us.save_provider_data('saml2', saml_sessions)
260
261     def saml2error(self, login, code, message):
262         status = lasso.Samlp2Status()
263         status.statusCode = lasso.Samlp2StatusCode()
264         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
265         status.statusCode.statusCode = lasso.Samlp2StatusCode()
266         status.statusCode.statusCode.value = code
267         login.response.status = status
268
269     def reply(self, login):
270         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
271             # TODO
272             raise cherrypy.HTTPError(501)
273         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
274             login.buildAuthnResponseMsg()
275             self._debug('POSTing back to SP [%s]' % (login.msgUrl))
276             context = {
277                 "title": 'Redirecting back to the web application',
278                 "action": login.msgUrl,
279                 "fields": [
280                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
281                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
282                 ],
283                 "submit": 'Return to application',
284             }
285             # pylint: disable=star-args
286             return self._template('saml2/post_response.html', **context)
287
288         else:
289             raise cherrypy.HTTPError(500)