• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Products.PluggableAuthService / 14325864773

08 Apr 2025 05:29AM UTC coverage: 90.685% (+0.08%) from 90.603%
14325864773

Pull #122

github

dataflake
Merge branch 'master' into dataflake/improve_sessioning
Pull Request #122: Improve session handling, guard against session fixation

1046 of 1468 branches covered (71.25%)

Branch coverage included in aggregate %.

141 of 144 new or added lines in 9 files covered. (97.92%)

9 existing lines in 1 file now uncovered.

9848 of 10545 relevant lines covered (93.39%)

0.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.73
/src/Products/PluggableAuthService/plugins/CookieAuthHelper.py
1
##############################################################################
2
#
3
# Copyright (c) 2001 Zope Foundation and Contributors
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this
7
# distribution.
8
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
9
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
10
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
11
# FOR A PARTICULAR PURPOSE.
12
#
13
##############################################################################
14
""" Class: CookieAuthHelper
15
"""
16

17
import codecs
1✔
18
from base64 import decodebytes
1✔
19
from base64 import encodebytes
1✔
20
from binascii import Error
1✔
21
from binascii import hexlify
1✔
22
from urllib.parse import quote
1✔
23
from urllib.parse import unquote
1✔
24

25
from AccessControl.class_init import InitializeClass
1✔
26
from AccessControl.Permissions import view
1✔
27
from AccessControl.SecurityInfo import ClassSecurityInfo
1✔
28
from Acquisition import aq_inner
1✔
29
from Acquisition import aq_parent
1✔
30
from OFS.Folder import Folder
1✔
31
from Products.PageTemplates.PageTemplateFile import PageTemplateFile
1✔
32
from Products.PageTemplates.ZopePageTemplate import ZopePageTemplate
1✔
33
from zope.event import notify
1✔
34
from zope.interface import Interface
1✔
35

36
from ..events import UserSessionStarted
1✔
37
from ..interfaces.plugins import IChallengePlugin
1✔
38
from ..interfaces.plugins import ICredentialsResetPlugin
1✔
39
from ..interfaces.plugins import ICredentialsUpdatePlugin
1✔
40
from ..interfaces.plugins import ILoginPasswordHostExtractionPlugin
1✔
41
from ..plugins.BasePlugin import BasePlugin
1✔
42
from ..utils import classImplements
1✔
43
from ..utils import url_local
1✔
44

45

46
class ICookieAuthHelper(Interface):
1✔
47
    """ Marker interface.
48
    """
49

50

51
manage_addCookieAuthHelperForm = PageTemplateFile(
1✔
52
    'www/caAdd', globals(), __name__='manage_addCookieAuthHelperForm')
53

54

55
def addCookieAuthHelper(dispatcher, id, title=None, cookie_name='',
1✔
56
                        REQUEST=None):
57
    """ Add a Cookie Auth Helper to a Pluggable Auth Service. """
58
    sp = CookieAuthHelper(id, title, cookie_name)
1✔
59
    dispatcher._setObject(sp.getId(), sp)
1✔
60

61
    if REQUEST is not None:
1!
62
        REQUEST['RESPONSE'].redirect('%s/manage_workspace'
×
63
                                     '?manage_tabs_message='
64
                                     'CookieAuthHelper+added.' %
65
                                     dispatcher.absolute_url())
66

67

68
def decode_cookie(raw):
1✔
69
    value = unquote(raw)
1✔
70
    value = value.encode('utf8')
1✔
71
    value = decodebytes(value)
1✔
72
    value = value.decode('utf8')
1✔
73
    return value
1✔
74

75

76
def decode_hex(raw):
1✔
77
    if isinstance(raw, str):
1!
78
        raw = raw.encode('utf8')
1✔
79
    value = codecs.decode(raw, 'hex_codec')
1✔
80
    value = value.decode('utf-8')
1✔
81
    return value
1✔
82

83

84
class CookieAuthHelper(Folder, BasePlugin):
1✔
85
    """ Multi-plugin for managing details of Cookie Authentication. """
86

87
    meta_type = 'Cookie Auth Helper'
1✔
88
    zmi_icon = 'fas fa-cookie-bite'
1✔
89
    cookie_name = '__ginger_snap'
1✔
90
    login_path = 'login_form'
1✔
91
    cookie_same_site = 'Lax'
1✔
92
    cookie_same_site_choices = ('None', 'Lax', 'Strict')
1✔
93
    cookie_secure = False
1✔
94
    security = ClassSecurityInfo()
1✔
95

96
    _properties = ({'id': 'title', 'label': 'Title',
1✔
97
                    'type': 'string', 'mode': 'w'},
98
                   {'id': 'cookie_name', 'label': 'Cookie Name',
99
                    'type': 'string', 'mode': 'w'},
100
                   {'id': 'cookie_secure', 'type': 'boolean', 'mode': 'w',
101
                    'label': 'Send cookie over HTTPS only'},
102
                   {'id': 'cookie_same_site', 'type': 'selection',
103
                    'label': 'Cookie SameSite restriction', 'mode': 'w',
104
                    'select_variable': 'cookie_same_site_choices'},
105
                   {'id': 'login_path', 'label': 'Login Form',
106
                    'type': 'string', 'mode': 'w'})
107

108
    manage_options = (BasePlugin.manage_options[:1]
1✔
109
                      + Folder.manage_options[:1]
110
                      + Folder.manage_options[2:])
111

112
    def __init__(self, id, title=None, cookie_name=''):
1✔
113
        self._setId(id)
1✔
114
        self.title = title
1✔
115

116
        if cookie_name:
1✔
117
            self.cookie_name = cookie_name
1✔
118

119
    def _getCookieData(self, request):
1✔
120
        cookie_creds = {}
1✔
121
        cookie = request.get(self.cookie_name, '')
1✔
122

123
        if cookie and cookie != 'deleted':
1✔
124
            try:
1✔
125
                cookie_val = decode_cookie(cookie)
1✔
126
            except Error:
1✔
127
                # Cookie is in a different format, so it is not ours
128
                return cookie_creds
1✔
129

130
            try:
1✔
131
                login, password = cookie_val.split(':')
1✔
132
            except ValueError:
×
133
                # Cookie is in a different format, so it is not ours
NEW
134
                return cookie_creds
×
135

136
            try:
1✔
137
                cookie_creds['login'] = decode_hex(login)
1✔
138
                cookie_creds['password'] = decode_hex(password)
1✔
139
            except (Error, TypeError):
1✔
140
                # Cookie is in a different format, so it is not ours
141
                return cookie_creds
1✔
142

143
        return cookie_creds
1✔
144

145
    @security.private
1✔
146
    def extractCredentials(self, request):
1✔
147
        """ Extract credentials from cookie or 'request'. """
148
        creds = {}
1✔
149
        # Look in the request.form for the names coming from the login form
150
        login = request.form.get('__ac_name', '')
1✔
151

152
        if login and '__ac_password' in request.form:
1✔
153
            creds['login'] = login
1✔
154
            creds['password'] = request.form.get('__ac_password', '')
1✔
155
        else:
156
            creds = self._getCookieData(request)
1✔
157

158
        if creds:
1✔
159
            creds['remote_host'] = request.get('REMOTE_HOST', '')
1✔
160

161
            try:
1✔
162
                creds['remote_address'] = request.getClientAddr()
1✔
163
            except AttributeError:
1✔
164
                creds['remote_address'] = request.get('REMOTE_ADDR', '')
1✔
165

166
        return creds
1✔
167

168
    @security.private
1✔
169
    def challenge(self, request, response, **kw):
1✔
170
        """ Challenge the user for credentials. """
171
        return self.unauthorized()
1✔
172

173
    @security.private
1✔
174
    def get_cookie_value(self, login, new_password):
1✔
175
        cookie_str = b':'.join([
1✔
176
            hexlify(login.encode('utf-8')),
177
            hexlify(new_password.encode('utf-8'))])
178
        cookie_val = encodebytes(cookie_str)
1✔
179
        cookie_val = cookie_val.rstrip()
1✔
180
        return cookie_val
1✔
181

182
    @security.private
1✔
183
    def updateCredentials(self, request, response, login, new_password):
1✔
184
        """ Respond to change of credentials (NOOP for basic auth). """
185
        old_creds = self._getCookieData(request)
1✔
186
        cookie_val = self.get_cookie_value(login, new_password)
1✔
187
        cookie_secure = self.cookie_same_site == 'None' or self.cookie_secure
1✔
188
        response.setCookie(self.cookie_name, quote(cookie_val),
1✔
189
                           path='/', same_site=self.cookie_same_site,
190
                           secure=cookie_secure)
191
        if old_creds.get('login') != login:
1✔
192
            # Only notify if cookie is new or the login changed
193
            notify(UserSessionStarted(login))
1✔
194

195
    @security.private
1✔
196
    def resetCredentials(self, request, response):
1✔
197
        """ Raise unauthorized to tell browser to clear credentials. """
198
        response.expireCookie(self.cookie_name, path='/')
1✔
199

200
    @security.private
1✔
201
    def manage_afterAdd(self, item, container):
1✔
202
        """ Setup tasks upon instantiation """
203
        if 'login_form' not in self.objectIds():
1!
204
            login_form = ZopePageTemplate(id='login_form',
1✔
205
                                          text=BASIC_LOGIN_FORM)
206
            login_form.title = 'Login Form'
1✔
207
            login_form.manage_permission(view, roles=['Anonymous'], acquire=1)
1✔
208
            self._setObject('login_form', login_form, set_owner=0)
1✔
209

210
    @security.private
1✔
211
    def unauthorized(self):
1✔
212
        req = self.REQUEST
1✔
213
        resp = req['RESPONSE']
1✔
214

215
        # If we set the auth cookie before, delete it now.
216
        if self.cookie_name in resp.cookies:
1!
UNCOV
217
            del resp.cookies[self.cookie_name]
×
218

219
        # Redirect if desired.
220
        url = self.getLoginURL()
1✔
221
        if url is not None:
1!
222
            came_from = req.get('came_from', None)
1✔
223

224
            if came_from is None:
1!
225
                came_from = req.get('ACTUAL_URL', '')
1✔
226
                query = req.get('QUERY_STRING')
1✔
227
                if query:
1!
UNCOV
228
                    if not query.startswith('?'):
×
UNCOV
229
                        query = '?' + query
×
230
                    came_from = came_from + query
×
231
            else:
232
                # If came_from contains a value it means the user
233
                # must be coming through here a second time
234
                # Reasons could be typos when providing credentials
235
                # or a redirect loop (see below)
UNCOV
236
                req_url = req.get('ACTUAL_URL', '')
×
237

238
                if req_url and req_url == url:
×
239
                    # Oops... The login_form cannot be reached by the user -
240
                    # it might be protected itself due to misconfiguration -
241
                    # the only sane thing to do is to give up because we are
242
                    # in an endless redirect loop.
UNCOV
243
                    return 0
×
244

245
            # Sanitize the return URL ``came_from`` and only allow local URLs
246
            # to prevent an open exploitable redirect issue
247
            came_from = url_local(came_from)
1✔
248

249
            if '?' in url:
1!
UNCOV
250
                sep = '&'
×
251
            else:
252
                sep = '?'
1✔
253
            url = f'{url}{sep}came_from={quote(came_from)}'
1✔
254
            resp.redirect(url, lock=1)
1✔
255
            resp.setHeader('Expires', 'Sat, 01 Jan 2000 00:00:00 GMT')
1✔
256
            resp.setHeader('Cache-Control', 'no-cache')
1✔
257
            return 1
1✔
258

259
        # Could not challenge.
UNCOV
260
        return 0
×
261

262
    @security.private
1✔
263
    def getLoginURL(self):
1✔
264
        """ Where to send people for logging in """
265
        if self.login_path.startswith('/') or '://' in self.login_path:
1!
UNCOV
266
            return self.login_path
×
267
        elif self.login_path != '':
1!
268
            return f'{self.absolute_url()}/{self.login_path}'
1✔
269
        else:
UNCOV
270
            return None
×
271

272
    @security.public
1✔
273
    def login(self):
1✔
274
        """ Set a cookie and redirect to the url that we tried to
275
        authenticate against originally.
276
        """
277
        request = self.REQUEST
1✔
278
        response = request['RESPONSE']
1✔
279

280
        login = request.get('__ac_name', '')
1✔
281
        password = request.get('__ac_password', '')
1✔
282

283
        # In order to use the CookieAuthHelper for its nice login page
284
        # facility but store and manage credentials somewhere else we need
285
        # to make sure that upon login only plugins activated as
286
        # IUpdateCredentialPlugins get their updateCredentials method
287
        # called. If the method is called on the CookieAuthHelper it will
288
        # simply set its own auth cookie, to the exclusion of any other
289
        # plugins that might want to store the credentials.
290
        pas_instance = self._getPAS()
1✔
291

292
        if pas_instance is not None:
1✔
293
            pas_instance.updateCredentials(request, response, login, password)
1✔
294
        came_from = request.form.get('came_from')
1✔
295
        if came_from is not None:
1✔
296
            return response.redirect(url_local(came_from))
1✔
297
        # When this happens, this either means
298
        # - the administrator did not setup the login form properly
299
        # - the user manipulated the login form and removed `came_from`
300
        # Still, the user provided correct credentials and is logged in.
301
        pas_root = aq_parent(aq_inner(self._getPAS()))
1✔
302
        return response.redirect(pas_root.absolute_url())
1✔
303

304

305
classImplements(CookieAuthHelper, ICookieAuthHelper,
1✔
306
                ILoginPasswordHostExtractionPlugin, IChallengePlugin,
307
                ICredentialsUpdatePlugin, ICredentialsResetPlugin)
308

309
InitializeClass(CookieAuthHelper)
1✔
310

311

312
BASIC_LOGIN_FORM = """<html>
1✔
313
  <head>
314
    <title> Login Form </title>
315
  </head>
316

317
  <body>
318

319
    <h3> Please log in </h3>
320

321
    <form method="post" action=""
322
          tal:attributes="action string:${here/absolute_url}/login">
323

324
      <input type="hidden" name="came_from" value=""
325
             tal:attributes="value request/came_from | string:"/>
326
      <table cellpadding="2">
327
        <tr>
328
          <td><b>Login:</b> </td>
329
          <td><input type="text" name="__ac_name" size="30" /></td>
330
        </tr>
331
        <tr>
332
          <td><b>Password:</b></td>
333
          <td><input type="password" name="__ac_password" size="30" /></td>
334
        </tr>
335
        <tr>
336
          <td colspan="2">
337
            <br />
338
            <input type="submit" value=" Log In " />
339
          </td>
340
        </tr>
341
      </table>
342

343
    </form>
344

345
  </body>
346

347
</html>
348
"""
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc