Assertion AttributeStatements must be non-empty
[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         # Check attribute policy and perform mapping and filtering
206         policy = Policy(self.cfg.default_attribute_mapping,
207                         self.cfg.default_allowed_attributes)
208         userattrs = us.get_user_attrs()
209         mappedattrs, _ = policy.map_attributes(userattrs)
210         attributes = policy.filter_attributes(mappedattrs)
211
212         if '_groups' in attributes and 'groups' not in attributes:
213             attributes['groups'] = attributes['_groups']
214
215         self.debug("%s's attributes: %s" % (user.name, attributes))
216
217         # The saml-core-2.0-os specification section 2.7.3 requires
218         # the AttributeStatement element to be non-empty.
219         if attributes:
220             if not login.assertion.attributeStatement:
221                 attrstat = lasso.Saml2AttributeStatement()
222                 login.assertion.attributeStatement = [attrstat]
223             else:
224                 attrstat = login.assertion.attributeStatement[0]
225             if not attrstat.attribute:
226                 attrstat.attribute = ()
227
228         for key in attributes:
229             # skip internal info
230             if key[0] == '_':
231                 continue
232             values = attributes[key]
233             if isinstance(values, dict):
234                 continue
235             if not isinstance(values, list):
236                 values = [values]
237             for value in values:
238                 attr = lasso.Saml2Attribute()
239                 attr.name = key
240                 attr.nameFormat = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
241                 value = str(value).encode('utf-8')
242                 self.debug('value %s' % value)
243                 node = lasso.MiscTextNode.newWithString(value)
244                 node.textChild = True
245                 attrvalue = lasso.Saml2AttributeValue()
246                 attrvalue.any = [node]
247                 attr.attributeValue = [attrvalue]
248                 attrstat.attribute = attrstat.attribute + (attr,)
249
250         self.debug('Assertion: %s' % login.assertion.dump())
251
252         saml_sessions = us.get_provider_data('saml2')
253         if saml_sessions is None:
254             saml_sessions = SAMLSessionsContainer()
255
256         session = saml_sessions.find_session_by_provider(
257             login.remoteProviderId)
258         if session:
259             # TODO: something...
260             self.debug('Login session for this user already exists!?')
261             session.dump()
262
263         lasso_session = lasso.Session()
264         lasso_session.addAssertion(login.remoteProviderId, login.assertion)
265         saml_sessions.add_session(login.assertion.id,
266                                   login.remoteProviderId,
267                                   lasso_session)
268         us.save_provider_data('saml2', saml_sessions)
269
270     def saml2error(self, login, code, message):
271         status = lasso.Samlp2Status()
272         status.statusCode = lasso.Samlp2StatusCode()
273         status.statusCode.value = lasso.SAML2_STATUS_CODE_RESPONDER
274         status.statusCode.statusCode = lasso.Samlp2StatusCode()
275         status.statusCode.statusCode.value = code
276         login.response.status = status
277
278     def reply(self, login):
279         if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
280             # TODO
281             raise cherrypy.HTTPError(501)
282         elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
283             login.buildAuthnResponseMsg()
284             self._debug('POSTing back to SP [%s]' % (login.msgUrl))
285             context = {
286                 "title": 'Redirecting back to the web application',
287                 "action": login.msgUrl,
288                 "fields": [
289                     [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
290                     [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
291                 ],
292                 "submit": 'Return to application',
293             }
294             # pylint: disable=star-args
295             return self._template('saml2/post_response.html', **context)
296
297         else:
298             raise cherrypy.HTTPError(500)