This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git
The following commit(s) were added to refs/heads/master by this push:
new 3c975226b [#11645] Force /auth/logout to go through a POST request
3c975226b is described below
commit 3c975226bfdef8801a99c391235f300303226c67
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 2db61175c..e2fec3254 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 d323137bf..f883b65fd 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()
@@ -1102,7 +1139,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',
@@ -1245,7 +1282,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',
@@ -1254,21 +1291,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',
@@ -1276,7 +1313,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',
@@ -2886,7 +2923,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()
@@ -3612,7 +3649,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]
@@ -3694,7 +3731,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