This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch cc/11645 in repository https://gitbox.apache.org/repos/asf/allura.git
commit ea2a050d87d56d4fbeb38d53590d84ddaf8cc198 Author: Carlos Cruz <[email protected]> AuthorDate: Wed May 27 18:40:58 2026 +0000 [#11645] Force /auth/logout to go through a POST request --- Allura/allura/controllers/auth.py | 18 ++++++--- Allura/allura/model/auth.py | 2 +- Allura/allura/public/nf/js/allura-base.js | 37 +++++++++++++++++++ Allura/allura/templates/logout.html | 51 ++++++++++++++++++++++++++ Allura/allura/tests/functional/test_auth.py | 57 ++++++++++++++++++++++++----- 5 files changed, 149 insertions(+), 16 deletions(-) diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py index a500df93d..50f2fa6b9 100644 --- a/Allura/allura/controllers/auth.py +++ b/Allura/allura/controllers/auth.py @@ -381,12 +381,20 @@ def verify_addr(self, a): self._verify_addr(addr) redirect('/auth/preferences/') - @expose() + @expose('jinja:allura:templates/logout.html') def logout(self, return_to=None): - plugin.AuthenticationProvider.get(request).logout() - if return_to: - redirect(self._verify_return_to(return_to)) - redirect(config.get('auth.post_logout_url', '/')) + if request.method == 'POST': + plugin.AuthenticationProvider.get(request).logout() + if return_to: + redirect(self._verify_return_to(return_to)) + redirect(config.get('auth.post_logout_url', '/')) + if request.method == 'GET': + if c.user.is_anonymous(): + redirect(config.get('auth.post_logout_url', '/')) + return { + 'return_to': self._verify_return_to(return_to) if return_to else None, + } + raise wexc.HTTPMethodNotAllowed(headers={'Allow': 'GET, POST'}) @staticmethod def _verify_return_to(return_to: str | None) -> str: diff --git a/Allura/allura/model/auth.py b/Allura/allura/model/auth.py index 8fdbcc600..87f970189 100644 --- a/Allura/allura/model/auth.py +++ b/Allura/allura/model/auth.py @@ -471,7 +471,7 @@ def make_password_reset_url(self): reset_url = h.absurl(f'/auth/forgotten_password/{hash}') return reset_url - def send_email_auth_code(self, return_to='/', subject_tmpl='{site_name} Authentication Link', login_details=None): + def send_email_auth_code(self, return_to='/', subject_tmpl='{site_name} Authentication Code', login_details=None): from allura.controllers.auth import AuthController email_address = self.get_pref('email_address') diff --git a/Allura/allura/public/nf/js/allura-base.js b/Allura/allura/public/nf/js/allura-base.js index 26abee4b4..a9109b549 100644 --- a/Allura/allura/public/nf/js/allura-base.js +++ b/Allura/allura/public/nf/js/allura-base.js @@ -17,6 +17,43 @@ under the License. */ +(function($) { + if (!window.SF) { + window.SF = {}; + } + if (window.SF.logoutLinkHandlerInstalled) { + return; + } + window.SF.logoutLinkHandlerInstalled = true; + + function getCookie(name) { + var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); + return match ? decodeURIComponent(match[1]) : ''; + } + + $(document).on('click', 'a[href]', function(e) { + if (e.isDefaultPrevented() || e.which && e.which !== 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return; + } + var url; + try { + url = new URL(this.href, window.location.href); + } catch (err) { + return; + } + if (url.origin !== window.location.origin || url.pathname.replace(/\/$/, '') !== '/auth/logout') { + return; + } + e.preventDefault(); + + var form = $('<form>', {method: 'post', action: '/auth/logout' + url.search}); + form.append($('<input type="hidden" name="_csrf_token">').val($.cookie ? $.cookie('_csrf_token') : getCookie('_csrf_token'))); + $('body').append(form); + form[0].submit(); + form.remove(); + }); +})(jQuery); + (function($) { // Setup editable widgets $('div.editable, span.editable, h1.editable') diff --git a/Allura/allura/templates/logout.html b/Allura/allura/templates/logout.html new file mode 100644 index 000000000..3499cf481 --- /dev/null +++ b/Allura/allura/templates/logout.html @@ -0,0 +1,51 @@ +{#- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +-#} +{% set hide_left_bar = True %} +{% extends g.theme.master %} + +{% block title %}{{ config['site_name'] }} Sign Out{% endblock %} + +{% block header_classes %} logout-confirm-hidden-header{% endblock %} +{% block header %}{% endblock %} +{% block page_body_classes %} logout-confirm-page-body{% endblock %} + +{% block content %} +{% set signed_in_username = c.user.username if c.user and not c.user.is_anonymous() else 'Not signed in' %} +<div class="logout-confirm-page"> + <div class="logout-confirm-panel"> + <h2 class="logout-confirm-title">Are you sure you want to sign out?</h2> + <form class="logout-account-card" method="post" action="/auth/logout"> + <div class="logout-avatar"> + {% if c.user and not c.user.is_anonymous() %} + {{ lib.gravatar(c.user, size=40) }} + {% endif %} + </div> + <div class="logout-account-copy"> + <div class="logout-account-label">Signed in as</div> + <div class="logout-account-name">{{ signed_in_username }}</div> + </div> + {% if return_to %} + <input type="hidden" name="return_to" value="{{ return_to }}"> + {% endif %} + {{ lib.csrf_token() }} + <button class="logout-signout-button" type="submit">Sign out</button> + </form> + </div> +</div> +{% endblock %} diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py index e2e568370..31ee14281 100644 --- a/Allura/allura/tests/functional/test_auth.py +++ b/Allura/allura/tests/functional/test_auth.py @@ -61,6 +61,15 @@ def login(app, username='test-user', pwd='foo', query_string=''): return f.submit(extra_environ={'username': '*anonymous'}) +def logout(app, extra_environ=None, return_to=None, status=302): + if '_csrf_token' not in app.cookies: + app.get('/', extra_environ=extra_environ or {}).maybe_follow() + params = {'_csrf_token': app.cookies['_csrf_token']} + if return_to is not None: + params['return_to'] = return_to + return app.post('/auth/logout', params=params, extra_environ=extra_environ, status=status) + + class TestAuth(TestController): def test_login(self): self.app.get('/auth/preferences/') # establish _csrf_token cookie @@ -177,7 +186,7 @@ def test_login_hibp_compromised_password_trusted_client(self, sendsimplemail): f[encoded['password']] = 'foo' with audits('Successful login', user=True): f.submit(status=302) - self.app.get('/auth/logout') + logout(self.app) # this login will get caught by HIBP check, but trusted due to IP address being same with patch('allura.lib.plugin.AuthenticationProvider.hibp_password_check_enabled', Mock(return_value=True)): @@ -296,6 +305,9 @@ def test_logout(self): nav_pattern = ('nav', {'class': 'nav-main'}) r = self.app.get('/auth/') + r = self.app.get('/auth/logout', status=302) + assert r.location == 'http://localhost/' + r = self.app.post('/auth/do_login', params=dict( username='test-user', password='foo', _csrf_token=self.app.cookies['_csrf_token']), @@ -307,6 +319,31 @@ def test_logout(self): assert links[-1].string == "Log Out" r = self.app.get('/auth/logout') + assert 'Clear-Site-Data' not in r.headers + assert r.session['_id'] == logged_in_session + assert 'Are you sure you want to sign out?' in r + assert 'Signed in as' in r + assert 'test-user' in r + assert r.forms[0].action.endswith('/auth/logout') + assert r.forms[0].method.upper() == 'POST' + assert r.forms[0]['_csrf_token'].value + + r = self.app.get('/auth/logout', params={'return_to': '/foo'}) + assert r.forms[0]['return_to'].value == '/foo' + r = self.app.get('/auth/logout', params={'return_to': 'http://example.com/foo'}) + assert r.forms[0]['return_to'].value == '/' + + self.app.put('/auth/logout', status=405) + self.app.post('/auth/logout', params={}, status=403) + self.app.post('/auth/logout', params={'_csrf_token': 'bogus'}, status=403) + + r = logout(self.app, return_to='/foo') + assert r.location == 'http://localhost/foo' + + r = login(self.app).follow().follow() + logged_in_session = r.session['_id'] + + r = logout(self.app) assert 'Clear-Site-Data' in r.headers r = r.follow().follow() @@ -1028,7 +1065,7 @@ def test_create_account(self): _csrf_token=self.app.cookies['_csrf_token'], )) assert 'That username is already taken. Please choose another.' in r - r = self.app.get('/auth/logout') + r = logout(self.app) r = self.app.post( '/auth/do_login', params=dict(username='aaa', password='12345678', @@ -1162,7 +1199,7 @@ def test_disabled_user(self): assert r.location == 'http://localhost/auth/?return_to=%2Fp%2Ftest%2Fadmin%2F' def test_no_open_return_to(self): - r = self.app.get('/auth/logout').follow().follow() + r = logout(self.app).follow().follow() r = self.app.post('/auth/do_login', params=dict( username='test-user', password='foo', return_to='/foo', @@ -1171,21 +1208,21 @@ def test_no_open_return_to(self): ) assert r.location == 'http://localhost/foo' - r = self.app.get('/auth/logout') + r = logout(self.app) r = self.app.post('/auth/do_login', antispam=True, params=dict( username='test-user', password='foo', return_to='http://localhost/foo', _csrf_token=self.app.cookies['_csrf_token'])) assert r.location == 'http://localhost/foo' - r = self.app.get('/auth/logout') + r = logout(self.app) r = self.app.post('/auth/do_login', antispam=True, params=dict( username='test-user', password='foo', return_to='http://example.com/foo', _csrf_token=self.app.cookies['_csrf_token'])).follow() assert r.location == 'http://localhost/dashboard' - r = self.app.get('/auth/logout') + r = logout(self.app) r = self.app.post('/auth/do_login', antispam=True, params=dict( username='test-user', password='foo', return_to='//example.com/foo', @@ -1193,7 +1230,7 @@ def test_no_open_return_to(self): assert r.location == 'http://localhost/dashboard' def test_no_injected_headers_in_return_to(self): - r = self.app.get('/auth/logout').follow().follow() + r = logout(self.app).follow().follow() r = self.app.post('/auth/do_login', params=dict( username='test-user', password='foo', return_to='/foo\nContent-Length: 777', @@ -2770,7 +2807,7 @@ def test_logout(self): r = login(self.app) assert self.expired(r) self.assert_redirects() - r = self.app.get('/auth/logout', extra_environ={'username': 'test-user'}) + r = logout(self.app, extra_environ={'username': 'test-user'}) assert not self.expired(r) self.assert_not_redirects() @@ -3466,7 +3503,7 @@ def test_email_auth_code(self, send_system_mail_to_user): # Validate the email with the verification link was sent args, kwargs = send_system_mail_to_user.call_args assert r.session.get('mode') == 'email_code' - assert args[1] == f"{config['site_name']} Authentication Link" + assert args[1] == f"{config['site_name']} Authentication Code" assert send_system_mail_to_user.call_count == 1 email_text = send_system_mail_to_user.call_args[0][2] @@ -3548,7 +3585,7 @@ def test_untrack_user_session(self): session_ids = user.get_tool_data('web_session', 'ids') assert len(session_ids) == 1 - r = self.app.get('/auth/logout', extra_environ={'username': 'test-user'}) + r = logout(self.app, extra_environ={'username': 'test-user'}) user = M.User.by_username('test-user') session_ids = user.get_tool_data('web_session', 'ids') assert len(session_ids) == 0
