This is an automated email from the ASF dual-hosted git repository.
sfirke pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new aede3bb5ba fix(auth): redirect anonymous attempts to view dashboard
with next (#35345)
aede3bb5ba is described below
commit aede3bb5bab55cd89bb91e3188865d355cf60f11
Author: Sam Firke <[email protected]>
AuthorDate: Thu Oct 16 16:33:37 2025 -0400
fix(auth): redirect anonymous attempts to view dashboard with next (#35345)
---
superset-frontend/src/pages/Login/index.tsx | 28 ++++++--
superset/views/core.py | 17 +++--
superset/views/error_handling.py | 17 +++--
superset/views/utils.py | 34 +++++++++-
tests/integration_tests/dashboard_tests.py | 74 ++++++++++++++++++++--
.../dashboards/security/security_rbac_tests.py | 8 ++-
.../dashboards/superset_factory_util.py | 2 +
7 files changed, 155 insertions(+), 25 deletions(-)
diff --git a/superset-frontend/src/pages/Login/index.tsx
b/superset-frontend/src/pages/Login/index.tsx
index 9bb10dfd1d..67552a316d 100644
--- a/superset-frontend/src/pages/Login/index.tsx
+++ b/superset-frontend/src/pages/Login/index.tsx
@@ -27,7 +27,7 @@ import {
Typography,
Icons,
} from '@superset-ui/core/components';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { capitalize } from 'lodash/fp';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { useDispatch } from 'react-redux';
@@ -82,6 +82,26 @@ export default function Login() {
const dispatch = useDispatch();
const bootstrapData = getBootstrapData();
+ const nextUrl = useMemo(() => {
+ try {
+ const params = new URLSearchParams(window.location.search);
+ return params.get('next') || '';
+ } catch (_error) {
+ return '';
+ }
+ }, []);
+
+ const loginEndpoint = useMemo(
+ () => (nextUrl ? `/login/?next=${encodeURIComponent(nextUrl)}` :
'/login/'),
+ [nextUrl],
+ );
+
+ const buildProviderLoginUrl = (providerName: string) => {
+ const base = `/login/${providerName}`;
+ return nextUrl
+ ? `${base}${base.includes('?') ? '&' :
'?'}next=${encodeURIComponent(nextUrl)}`
+ : base;
+ };
const authType: AuthType = bootstrapData.common.conf.AUTH_TYPE;
const providers: Provider[] = bootstrapData.common.conf.AUTH_PROVIDERS;
@@ -109,7 +129,7 @@ export default function Login() {
sessionStorage.setItem('login_attempted', 'true');
// Use standard form submission for Flask-AppBuilder compatibility
- SupersetClient.postForm('/login/', values, '');
+ SupersetClient.postForm(loginEndpoint, values, '');
};
const getAuthIconElement = (
@@ -146,7 +166,7 @@ export default function Login() {
{providers.map((provider: OIDProvider) => (
<Form.Item<LoginForm>>
<Button
- href={`/login/${provider.name}`}
+ href={buildProviderLoginUrl(provider.name)}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
@@ -164,7 +184,7 @@ export default function Login() {
{providers.map((provider: OAuthProvider) => (
<Form.Item<LoginForm>>
<Button
- href={`/login/${provider.name}`}
+ href={buildProviderLoginUrl(provider.name)}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
diff --git a/superset/views/core.py b/superset/views/core.py
index 140c0775aa..b7610f6a2f 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -46,7 +46,6 @@ from sqlalchemy.exc import SQLAlchemyError
from werkzeug.utils import safe_join
from superset import (
- appbuilder,
db,
event_logger,
is_feature_enabled,
@@ -80,6 +79,7 @@ from superset.models.slice import Slice
from superset.models.sql_lab import Query
from superset.models.user_attributes import UserAttribute
from superset.superset_typing import FlaskResponse
+from superset.tasks.utils import get_current_user
from superset.utils import core as utils, json
from superset.utils.cache import etag_cache
from superset.utils.core import (
@@ -108,6 +108,7 @@ from superset.views.utils import (
get_form_data,
get_viz,
loads_request_json,
+ redirect_to_login,
sanitize_datasource_data,
)
from superset.viz import BaseViz
@@ -765,13 +766,21 @@ class Superset(BaseSupersetView):
dashboard = Dashboard.get(dashboard_id_or_slug)
if not dashboard:
+ if not get_current_user():
+ return redirect_to_login()
abort(404)
+ # Redirect anonymous users to login for unpublished dashboards,
+ # in the edge case where a dataset has been shared with public
+ if not get_current_user() and not dashboard.published:
+ return redirect_to_login()
+
try:
dashboard.raise_for_access()
except SupersetSecurityException:
- # Return 404 to avoid revealing dashboard existence
- return Response(status=404)
+ if not get_current_user():
+ return redirect_to_login()
+ abort(404)
add_extra_log_payload(
dashboard_id=dashboard.id,
dashboard_version="v2",
@@ -882,7 +891,7 @@ class Superset(BaseSupersetView):
def welcome(self) -> FlaskResponse:
"""Personalized welcome page"""
if not g.user or not get_user_id():
- return redirect(appbuilder.get_url_for_login)
+ return redirect_to_login()
if welcome_dashboard_id := (
db.session.query(UserAttribute.welcome_dashboard_id)
diff --git a/superset/views/error_handling.py b/superset/views/error_handling.py
index 56cd356a12..236291d96e 100644
--- a/superset/views/error_handling.py
+++ b/superset/views/error_handling.py
@@ -25,7 +25,6 @@ from typing import Any, Callable, cast
from flask import (
Flask,
- redirect,
request,
Response,
send_file,
@@ -34,7 +33,6 @@ from flask_wtf.csrf import CSRFError
from sqlalchemy import exc
from werkzeug.exceptions import HTTPException
-from superset import appbuilder
from superset.commands.exceptions import CommandException, CommandInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
@@ -46,6 +44,7 @@ from superset.exceptions import (
from superset.superset_typing import FlaskResponse
from superset.utils import core as utils, json
from superset.utils.log import get_logger_from_status
+from superset.views.utils import redirect_to_login
if typing.TYPE_CHECKING:
from superset.views.base import BaseSupersetView
@@ -153,7 +152,7 @@ def set_app_error_handlers(app: Flask) -> None: # noqa:
C901
if request.is_json:
return show_http_exception(ex)
- return redirect(appbuilder.get_url_for_login)
+ return redirect_to_login()
@app.errorhandler(HTTPException)
def show_http_exception(ex: HTTPException) -> FlaskResponse:
@@ -165,7 +164,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa:
C901
and ex.code in {404, 500}
):
path = files("superset") / f"static/assets/{ex.code}.html"
- return send_file(path, max_age=0), ex.code
+ # Try to serve HTML file; fall back to JSON if not built
+ try:
+ return send_file(path, max_age=0), ex.code
+ except FileNotFoundError:
+ pass
return json_error_response(
[
@@ -189,7 +192,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa:
C901
if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
path = files("superset") / "static/assets/500.html"
- return send_file(path, max_age=0), 500
+ # Try to serve HTML file; fall back to JSON if not built
+ try:
+ return send_file(path, max_age=0), 500
+ except FileNotFoundError:
+ pass
extra = ex.normalized_messages() if isinstance(ex,
CommandInvalidError) else {}
return json_error_response(
diff --git a/superset/views/utils.py b/superset/views/utils.py
index cbe5ef0dcf..86f72ef376 100644
--- a/superset/views/utils.py
+++ b/superset/views/utils.py
@@ -19,16 +19,17 @@ import logging
from collections import defaultdict
from functools import wraps
from typing import Any, Callable, DefaultDict, Optional, Union
+from urllib import parse
import msgpack
import pyarrow as pa
-from flask import current_app as app, g, has_request_context, request
+from flask import current_app as app, g, has_request_context, redirect, request
from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.exc import NoResultFound
-from superset import dataframe, db, result_set, viz
+from superset import appbuilder, dataframe, db, result_set, viz
from superset.common.db_query_status import QueryStatus
from superset.daos.datasource import DatasourceDAO
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -44,7 +45,7 @@ from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import Query
-from superset.superset_typing import FormData
+from superset.superset_typing import FlaskResponse, FormData
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import stats_timing
@@ -58,6 +59,33 @@ if not
feature_flag_manager.is_feature_enabled("ENABLE_JAVASCRIPT_CONTROLS"):
REJECTED_FORM_DATA_KEYS = ["js_tooltip", "js_onclick_href",
"js_data_mutator"]
+def redirect_to_login(next_target: str | None = None) -> FlaskResponse:
+ """Return a redirect response to the login view, preserving target URL.
+
+ When ``next_target`` is ``None`` the current request path (including query
+ string) is used, provided a request context is available. The resulting URL
+ always remains relative, mirroring Flask-AppBuilder expectations.
+ """
+
+ login_url = appbuilder.get_url_for_login
+ parsed = parse.urlparse(login_url)
+ query = parse.parse_qs(parsed.query, keep_blank_values=True)
+
+ target = next_target
+ if target is None and has_request_context():
+ if request.query_string:
+ target = request.full_path.rstrip("?")
+ else:
+ target = request.path
+
+ if target:
+ query["next"] = [target]
+
+ encoded_query = parse.urlencode(query, doseq=True)
+ redirect_url = parse.urlunparse(parsed._replace(query=encoded_query))
+ return redirect(redirect_url)
+
+
def sanitize_datasource_data(datasource_data: dict[str, Any]) -> dict[str,
Any]:
if datasource_data:
datasource_database = datasource_data.get("database")
diff --git a/tests/integration_tests/dashboard_tests.py
b/tests/integration_tests/dashboard_tests.py
index 78218744b3..31dd1e25db 100644
--- a/tests/integration_tests/dashboard_tests.py
+++ b/tests/integration_tests/dashboard_tests.py
@@ -18,8 +18,8 @@
"""Unit tests for Superset"""
import re
-import unittest
from random import random
+from urllib.parse import parse_qs, urlparse
import pytest
from flask import Response, escape, url_for
@@ -52,7 +52,7 @@ from tests.integration_tests.fixtures.world_bank_dashboard
import (
load_world_bank_data, # noqa: F401
)
-from .base_tests import SupersetTestCase
+from .base_tests import DEFAULT_PASSWORD, SupersetTestCase
class TestDashboard(SupersetTestCase):
@@ -186,6 +186,72 @@ class TestDashboard(SupersetTestCase):
# Cleanup
self.revoke_public_access_to_table(table)
+ @pytest.mark.usefixtures(
+ "load_energy_table_with_slice",
+ "load_dashboard",
+ )
+ def test_anonymous_user_redirects_to_login_with_next(self):
+ self.logout()
+ target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
+
+ response = self.client.get(target_path, follow_redirects=False)
+
+ assert response.status_code == 302
+
+ redirect_location = response.headers["Location"]
+ parsed = urlparse(redirect_location)
+ assert parsed.path.rstrip("/") == "/login"
+
+ next_values = parse_qs(parsed.query).get("next")
+ assert next_values is not None
+ assert next_values[0].endswith(target_path)
+
+ login_target = (
+ f"{parsed.path}{'?' + parsed.query if parsed.query else ''}"
+ if parsed.scheme or parsed.netloc
+ else redirect_location
+ )
+
+ login_response = self.client.post(
+ login_target,
+ data={"username": ADMIN_USERNAME, "password": DEFAULT_PASSWORD},
+ follow_redirects=False,
+ )
+ assert login_response.status_code == 302
+ assert login_response.headers["Location"].endswith(target_path)
+
+ target_response: Response = self.client.get(target_path,
follow_redirects=False)
+ assert target_response.status_code == 200
+
+ def test_anonymous_user_redirects_to_login_for_missing_dashboard(self):
+ self.logout()
+ target_path = "/superset/dashboard/nonexistent-dashboard/"
+
+ response = self.client.get(target_path, follow_redirects=False)
+
+ assert response.status_code == 302
+ parsed = urlparse(response.headers["Location"])
+ assert parsed.path.rstrip("/") == "/login"
+ next_values = parse_qs(parsed.query).get("next")
+ assert next_values is not None
+ assert next_values[0].endswith(target_path)
+
+ @pytest.mark.usefixtures(
+ "public_role_like_gamma",
+ "load_energy_table_with_slice",
+ "load_dashboard",
+ )
+ def test_authenticated_user_without_access_gets_404(self):
+ self.login(GAMMA_USERNAME)
+ target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
+
+ response = self.client.get(
+ target_path,
+ follow_redirects=False,
+ headers={"Accept": "text/html"},
+ )
+ assert response.status_code == 404
+
@pytest.mark.usefixtures(
"public_role_like_gamma",
"load_energy_table_with_slice",
@@ -248,7 +314,3 @@ class TestDashboard(SupersetTestCase):
db.session.commit()
assert f"/superset/dashboard/{slug}/" not in resp
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/integration_tests/dashboards/security/security_rbac_tests.py
b/tests/integration_tests/dashboards/security/security_rbac_tests.py
index c6ea811636..843a1102b0 100644
--- a/tests/integration_tests/dashboards/security/security_rbac_tests.py
+++ b/tests/integration_tests/dashboards/security/security_rbac_tests.py
@@ -108,7 +108,7 @@ class
TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
# act
response = self.get_dashboard_view_response(dashboard_to_access)
- assert response.status_code == 404
+ assert response.status_code == 404 # Authenticated users without
access get 404
request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
@@ -221,7 +221,8 @@ class
TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
- assert response.status_code == 404
+ # Anonymous users are redirected to login instead of getting 404
+ assert response.status_code == 302
@pytest.mark.usefixtures("public_role_like_gamma")
def
test_get_dashboard_view__public_user_with_dashboard_permission_can_not_access_draft(
# noqa: E501
@@ -234,7 +235,8 @@ class
TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
- assert response.status_code == 404
+ # Anonymous users are redirected to login for unpublished dashboards
+ assert response.status_code == 302
# post
revoke_access_to_dashboard(dashboard_to_access, "Public") # noqa: F405
diff --git a/tests/integration_tests/dashboards/superset_factory_util.py
b/tests/integration_tests/dashboards/superset_factory_util.py
index c4ea0deaeb..b569bc72d6 100644
--- a/tests/integration_tests/dashboards/superset_factory_util.py
+++ b/tests/integration_tests/dashboards/superset_factory_util.py
@@ -190,6 +190,8 @@ def delete_all_inserted_objects() -> None:
def delete_all_inserted_dashboards():
try:
+ # Expire all objects to ensure fresh state after potential rollbacks
+ db.session.expire_all()
dashboards_to_delete: list[Dashboard] = (
db.session.query(Dashboard)
.filter(Dashboard.id.in_(inserted_dashboards_ids))