Implement urn:oasis:names:tc:SAML:2.0:nameid-format:transient
[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 import uuid
31
32
33 class UnknownProvider(ProviderException):
34
35     def __init__(self, message):
36         super(UnknownProvider, self).__init__(message)
37         self._debug(message)
38
39
40 class AuthenticateRequest(ProviderPageBase):
41
42     def __init__(self, *args, **kwargs):
43         super(AuthenticateRequest, self).__init__(*args, **kwargs)
44         self.stage = 'init'
45         self.trans = None
46
47     def _preop(self, *args, **kwargs):
48         try:
49             # generate a new id or get current one
50             self.trans = Transaction('saml2', **kwargs)
51             if self.trans.cookie.value != self.trans.provider:
52                 self.debug('Invalid transaction, %s != %s' % (
53                            self.trans.cookie.value, self.trans.provider))
54         except Exception, e:  # pylint: disable=broad-except
55             self.debug('Transaction initialization failed: %s' % repr(e))
56             raise cherrypy.HTTPError(400, 'Invalid transaction id')
57
58     def pre_GET(self, *args, **kwargs):
59         self._preop(*args, **kwargs)
60
61     def pre_POST(self, *args, **kwargs):
62         self._preop(*args, **kwargs)
63
64     def auth(self, login):
65         try:
66             self.saml2checks(login)
67         except AuthenticationError, e:
68             self.saml2error(login, e.code, e.message)
69         return self.reply(login)
70
71     def _parse_request(self, message):
72
73         login = self.cfg.idp.get_login_handler()
74
75         try:
76             login.processAuthnRequestMsg(message)
77         except (lasso.ProfileInvalidMsgError,
78                 lasso.ProfileMissingIssuerError), e:
79
80             msg = 'Malformed Request %r [%r]' % (e, message)
81             raise InvalidRequest(msg)
82
83         except (lasso.ProfileInvalidProtocolprofileError,
84                 lasso.DsError), e:
85
86             msg = 'Invalid SAML Request: %r (%r [%r])' % (login.request,
87                                                           e, message)
88             raise InvalidRequest(msg)
89
90         except (lasso.ServerProviderNotFoundError,
91                 lasso.ProfileUnknownProviderError), e:
92
93             msg = 'Invalid SP [%s] (%r [%r])' % (login.remoteProviderId,
94                                                  e, message)
95             raise UnknownProvider(msg)
96
97         self._debug('SP %s requested authentication' % login.remoteProviderId)
98
99         return login
100
101     def saml2login(self, request):
102
103         if not request:
104             raise cherrypy.HTTPError(400,
105                                      'SAML request token missing or empty')
106
107         try:
108             login = self._parse_request(request)
109         except InvalidRequest, e:
110             self._debug(str(e))
111             raise cherrypy.HTTPError(400, 'Invalid SAML request token')
112         except UnknownProvider, e:
113             self._debug(str(e))
114             raise cherrypy.HTTPError(400, 'Unknown Service Provider')
115         except Exception, e:  # pylint: disable=broad-except
116             self._debug(str(e))
117             raise cherrypy.HTTPError(500)
118
119         return login
120
121     def saml2checks(self, login):
122
123         us = UserSession()
124         user = us.get_user()
125         if user.is_anonymous:
126             if self.stage == 'init':
127                 returl = '%s/saml2/SSO/Continue?%s' % (
128                     self.basepath, self.trans.get_GET_arg())
129                 data = {'saml2_stage': 'auth',
130                         'saml2_request': login.dump(),
131                         'login_return': returl,
132                         'login_target': login.remoteProviderId}
133                 self.trans.store(data)
134                 redirect = '%s/login?%s' % (self.basepath,
135                                             self.trans.get_GET_arg())
136                 raise cherrypy.HTTPRedirect(redirect)
137             else:
138                 raise AuthenticationError(
139                     "Unknown user", lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
140
141         self._audit("Logged in user: %s [%s]" % (user.name, user.fullname))
142
143         # We can wipe the transaction now, as this is the last step
144         self.trans.wipe()
145
146         # TODO: check if this is the first time this user access this SP
147         # If required by user prefs, ask user for consent once and then
148         # record it
149         consent = True
150
151         # TODO: check destination
152
153         try:
154             provider = ServiceProvider(self.cfg, login.remoteProviderId)
155             nameidfmt = provider.get_valid_nameid(login.request.nameIdPolicy)
156         except NameIdNotAllowed, e:
157             raise AuthenticationError(
158                 str(e), lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY)
159         except InvalidProviderId, e:
160             raise AuthenticationError(
161                 str(e), lasso.SAML2_STATUS_CODE_AUTHN_FAILED)
162
163         # TODO: check login.request.forceAuthn
164
165         login.validateRequestMsg(not user.is_anonymous, consent)
166
167         authtime = datetime.datetime.utcnow()
168         skew = datetime.timedelta(0, 60)
169         authtime_notbefore = authtime - skew
170         authtime_notafter = authtime + skew
171
172         # TODO: get authentication type fnd name format from session
173         # need to save which login manager authenticated and map it to a
174         # saml2 authentication context
175         authn_context = lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED
176
177         timeformat = '%Y-%m-%dT%H:%M:%SZ'
178         login.buildAssertion(authn_context,
179                              authtime.strftime(timeformat),
180                              None,
181                              authtime_notbefore.strftime(timeformat),
182                              authtime_notafter.strftime(timeformat))
183
184         nameid = None
185         if nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT:
186             # TODO map to something else ?
187             nameid = provider.normalize_username(user.name)
188         elif nameidfmt == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT:
189             nameid = '_' + uuid.uuid4().hex
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)