diff --git a/app/__init__.py b/app/__init__.py index ddcf9fd..b019783 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,6 @@ from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, session, abort from flask_socketio import SocketIO -from flask_login import logout_user, login_required, current_user +from flask_login import login_required, current_user from flask_wtf.csrf import CSRFProtect from werkzeug.middleware.proxy_fix import ProxyFix import config @@ -18,6 +18,7 @@ from .tailscale_ssh import user_can_use_tailscale_ssh from .runtime_lifecycle import RuntimeLifecycle from .browser_identity import connection_history_scope +from .auth_assurance import clear_browser_authentication socketio = SocketIO( async_mode=config.SOCKETIO_ASYNC_MODE, @@ -173,8 +174,7 @@ def enforce_restore_maintenance_and_session_epoch(): user_id = current_user.id user_lifecycle.revoke_user_access(user_id, socketio) - logout_user() - session.clear() + clear_browser_authentication() return redirect(url_for('login')) if ( current_user.is_authenticated @@ -197,8 +197,7 @@ def enforce_restore_maintenance_and_session_epoch(): error=type(exc).__name__, ) user_lifecycle.revoke_user_access(user_id, socketio) - logout_user() - session.clear() + clear_browser_authentication() return redirect(url_for('login')) session['_ldap_verified_at'] = int(time.time()) if initialize_storage and current_user.is_authenticated: @@ -208,8 +207,7 @@ def enforce_restore_maintenance_and_session_epoch(): if stored_epoch is None: session['_auth_epoch'] = epoch elif stored_epoch != epoch: - logout_user() - session.clear() + clear_browser_authentication() return redirect(url_for('login', next=request.path)) if initialize_storage and current_user.is_authenticated: from .auth_assurance import current_authentication_session @@ -217,8 +215,7 @@ def enforce_restore_maintenance_and_session_epoch(): auth_session = current_authentication_session() if auth_session is None: username = current_user.username - logout_user() - session.clear() + clear_browser_authentication() log_warning( 'Authentication session rejected', user=username, @@ -618,8 +615,7 @@ def logout(): error=type(exc).__name__, ) user_lifecycle.revoke_user_access(user_id, socketio) - logout_user() - session.clear() + clear_browser_authentication() return redirect(url_for('login')) @app.route('/change-password', methods=['GET', 'POST']) diff --git a/app/auth_assurance.py b/app/auth_assurance.py index 1706319..798b212 100644 --- a/app/auth_assurance.py +++ b/app/auth_assurance.py @@ -244,6 +244,15 @@ def _session_lifetime(remember): return timedelta(seconds=max(1, int(value))) +def clear_browser_authentication(): + """Clear browser auth state while preserving remember-cookie removal.""" + logout_user() + clear_remember_cookie = session.get('_remember') == 'clear' + session.clear() + if clear_remember_cookie: + session['_remember'] = 'clear' + + def finalize_login(pending, *, methods, strong_authenticated_at=None): """Create the sole authenticated browser-session record.""" if not isinstance(pending, PendingAuthentication): @@ -313,8 +322,7 @@ def finalize_login(pending, *, methods, strong_authenticated_at=None): db.session.commit() except Exception as exc: db.session.rollback() - logout_user() - session.clear() + clear_browser_authentication() raise AuthenticationFinalizationError( 'authentication session could not be stored' ) from exc diff --git a/tests/test_auth_assurance.py b/tests/test_auth_assurance.py index 12434e1..3fcde53 100644 --- a/tests/test_auth_assurance.py +++ b/tests/test_auth_assurance.py @@ -341,6 +341,28 @@ def test_remember_cookie_restores_the_bound_authentication_session(app, client): assert browser_session.get('_auth_session') +def test_legacy_remember_cookie_is_cleared_before_login_redirect(app, client): + from flask_login.utils import encode_cookie + from app.models import User, db + + user_id = _create_user(app, 'legacyremembered') + with app.app_context(): + legacy_identifier = db.session.get(User, user_id).get_id() + legacy_cookie = encode_cookie(legacy_identifier) + + client.set_cookie('remember_token', legacy_cookie) + + rejected = client.get('/') + + assert rejected.status_code == 302 + assert '/login?next=' in rejected.headers['Location'] + assert client.get_cookie('remember_token') is None + + login = client.get(rejected.headers['Location']) + + assert login.status_code == 200 + + def test_request_guard_rejects_invalid_server_side_sessions( app, client, @@ -430,12 +452,15 @@ def test_logout_deletes_current_authentication_session(app, client): assert client.post('/login', data={ 'username': 'logoutassurance', 'password': 'password123', + 'remember': 'on', }).status_code == 302 + assert client.get_cookie('remember_token') is not None with app.app_context(): assert AuthenticationSession.query.count() == 1 response = client.post('/logout') assert response.status_code == 302 + assert client.get_cookie('remember_token') is None with app.app_context(): assert AuthenticationSession.query.count() == 0