ddebd8c0e53ef5e071f14961206804d3958ef7f9
[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
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                         'login_target': login.remoteProviderId}
132                 self.trans.store(data)
133                 redirect = '%s/login?%s' % (self.basepath,
134                                             self.trans.get_GET_arg())
135                 raise cherrypy.HTTPRedirect(redirect)
136             else:
137                 raise AuthenticationError(
138                     "Unknown user", lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
139
140         self._audit("Logged in user: %s [%s]" % (user.name, user.fullname))
141
142         # We can wipe the transaction now, as this is the last step
143         self.trans.wipe()
144
145         # TODO: check if this is the first time this user access this SP
146         # If required by user prefs, ask user for consent once and then
147         # record it
148         consent = True
149
150         # TODO: check destination
151
152         try:
153             provider = ServiceProvider(self.cfg, login.remoteProviderId)
154             nameidfmt = provider.get_valid_nameid(login.request.nameIdPolicy)
155         except NameIdNotAllowed, e:
156             raise AuthenticationError(
157                 str(e), lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY)
158         except InvalidProviderId, e:
159             raise AuthenticationError(
160                 str(e), lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
161
162         # TODO: check login.request.forceAuthn
163
164         login.validateRequestMsg(not user.is_anonymous, consent)
165
166         authtime = datetime.datetime.utcnow()
167         skew = datetime.timedelta(0, 60)
168         authtime_notbefore = authtime - skew
169         authtime_notafter = authtime + skew
170
171         # TODO: get authentication type fnd name format from session
172         # need to save which login manager authenticated and map it to a
173         # saml2 authentication context
174         authn_context = lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED
175
176         timeformat = '%Y-%m-%dT%H:%M:%SZ'
177         login.buildAssertion(authn_context,
178                              authtime.strftime(timeformat),
179                              None,
180                              authtime_notbefore.strftime(timeformat),
181                              authtime_notafter.strftime(timeformat))
182
183         nameid = None
184         if nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT:
185             # TODO map to something else ?
186             nameid = provider.normalize_username(user.name)
187         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT:
188             # TODO map to something else ?
189             nameid = provider.normalize_username(user.name)
190         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS:
191             nameid = us.get_data('user', 'krb_principal_name')
192         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL:
193             nameid = us.get_user().email
194             if not nameid:
195                 nameid = '%s@%s' % (user.name, self.cfg.default_email_domain)
196
197         if nameid:
198             login.assertion.subject.nameId.format = nameidfmt
199             login.assertion.subject.nameId.content = nameid
200         else:
201             self.trans.wipe()
202             raise AuthenticationError("Unavailable Name ID type",
203                                       lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
204
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         # Check attribute policy and perform mapping and filtering
214         policy = Policy(self.cfg.default_attribute_mapping,
215                         self.cfg.default_allowed_attributes)
216         userattrs = us.get_user_attrs()
217         mappedattrs, _ = policy.map_attributes(userattrs)
218         attributes = policy.filter_attributes(mappedattrs)
219
220         if '_groups' in attributes and 'groups' not in attributes:
221             attributes['groups'] = attributes['_groups']
222
223         self.debug("%s's attributes: %s" % (user.name, attributes))
224
225         for key in attributes:
226             # skip internal info
227             if key[0] == '_':
228                 continue
229             values = attributes[key]
230             if isinstance(values, dict):
231                 continue
232             if not isinstance(values, list):
233                 values = [values]
234             for value in values:
235                 attr = lasso.Saml2Attribute()
236                 attr.name = key
237                 attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
238                 value = str(value).encode('utf-8')
239                 self.debug('value %s' % value)
240                 node = lasso.MiscTextNode.newWithString(value)
241                 node.textChild = True
242                 attrvalue = lasso.Saml2AttributeValue()
243                 attrvalue.any = [node]
244                 attr.attributeValue = [attrvalue]
245                 attrstat.attribute = attrstat.attribute + (attr,)
246
247         self.debug('Assertion: %s' % login.assertion.dump())
248
249         saml_sessions = us.get_provider_data('saml2')
250         if saml_sessions is None:
251             saml_sessions = SAMLSessionsContainer()
252
253         session = saml_sessions.find_session_by_provider(
254             login.remoteProviderId)
255         if session:
256             # TODO: something...
257             self.debug('Login session for this user already exists!?')
258             session.dump()
259
260         lasso_session = lasso.Session()
261         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
262         saml_sessions.add_session(login.assertion.id,
263                                   login.remoteProviderId,
264                                   lasso_session)
265         us.save_provider_data('saml2', saml_sessions)
266
267     def saml2error(self, login, code, message):
268         status = lasso.Samlp2Status()
269         status.statusCode = lasso.Samlp2StatusCode()
270         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
271         status.statusCode.statusCode = lasso.Samlp2StatusCode()
272         status.statusCode.statusCode.value = code
273         login.response.status = status
274
275     def reply(self, login):
276         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
277             # TODO
278             raise cherrypy.HTTPError(501)
279         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
280             login.buildAuthnResponseMsg()
281             self._debug('POSTing back to SP [%s]' % (login.msgUrl))
282             context = {
283                 "title": 'Redirecting back to the web application',
284                 "action": login.msgUrl,
285                 "fields": [
286                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
287                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
288                 ],
289                 "submit": 'Return to application',
290             }
291             # pylint: disable=star-args
292             return self._template('saml2/post_response.html', **context)
293
294         else:
295             raise cherrypy.HTTPError(500)