feat: On window focus, redirect to login if the user has been logged out (#18773)

* /me api

* test it

* watch for window activation and check auth

* simplify

* more comment

* making ci happy

* mypy should ignore tests
This commit is contained in:
David Aaron Suddjian
2022-02-24 10:09:41 -08:00
committed by GitHub
parent 94e245d5eb
commit da3bc48803
10 changed files with 168 additions and 2 deletions

View File

@@ -75,6 +75,7 @@ describe('canUserEditDashboard', () => {
email: 'user@example.com',
firstName: 'Test',
isActive: true,
isAnonymous: false,
lastName: 'User',
userId: 1,
username: 'owner',

View File

@@ -20,19 +20,24 @@ import { setConfig as setHotLoaderConfig } from 'react-hot-loader';
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import moment from 'moment';
// eslint-disable-next-line no-restricted-imports
import { configure, supersetTheme } from '@superset-ui/core';
import { configure, makeApi, supersetTheme } from '@superset-ui/core';
import { merge } from 'lodash';
import setupClient from './setup/setupClient';
import setupColors from './setup/setupColors';
import setupFormatters from './setup/setupFormatters';
import setupDashboardComponents from './setup/setupDasboardComponents';
import { User } from './types/bootstrapTypes';
if (process.env.WEBPACK_MODE === 'development') {
setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false });
}
// eslint-disable-next-line import/no-mutable-exports
export let bootstrapData: any;
export let bootstrapData: {
user?: User | undefined;
common?: any;
config?: any;
} = {};
// Configure translation
if (typeof window !== 'undefined') {
const root = document.getElementById('app');
@@ -67,3 +72,24 @@ export const theme = merge(
supersetTheme,
bootstrapData?.common?.theme_overrides ?? {},
);
const getMe = makeApi<void, User>({
method: 'GET',
endpoint: '/api/v1/me/',
});
/**
* When you re-open the window, we check if you are still logged in.
* If your session expired or you signed out, we'll redirect to login.
* If you aren't logged in in the first place (!isActive), then we shouldn't do this.
*/
if (bootstrapData.user?.isActive) {
document.addEventListener('visibilitychange', () => {
// we only care about the tab becoming visible, not vice versa
if (document.visibilityState !== 'visible') return;
getMe().catch(() => {
// ignore error, SupersetClient will redirect to login on a 401
});
});
}

View File

@@ -40,6 +40,7 @@ export const user: UserWithPermissionsAndRoles = {
userId: 5,
email: 'alpha@alpha.com',
isActive: true,
isAnonymous: false,
permissions: {
datasource_access: ['table1', 'table2'],
database_access: ['db1', 'db2', 'db3'],

View File

@@ -23,6 +23,7 @@ export type User = {
email: string;
firstName: string;
isActive: boolean;
isAnonymous: boolean;
lastName: string;
userId: number;
username: string;

View File

@@ -50,6 +50,7 @@ from superset.extensions import (
)
from superset.security import SupersetSecurityManager
from superset.typing import FlaskResponse
from superset.users.api import CurrentUserRestApi
from superset.utils.core import pessimistic_connection_handling
from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
@@ -205,6 +206,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(ChartDataRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(CurrentUserRestApi)
appbuilder.add_api(DashboardFilterStateRestApi)
appbuilder.add_api(DashboardRestApi)
appbuilder.add_api(DatabaseRestApi)

56
superset/users/api.py Normal file
View File

@@ -0,0 +1,56 @@
# 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.
from flask import g, Response
from flask_appbuilder.api import BaseApi, expose, safe
from .schemas import UserResponseSchema
user_response_schema = UserResponseSchema()
class CurrentUserRestApi(BaseApi):
""" An api to get information about the current user """
resource_name = "me"
openapi_spec_tag = "Current User"
openapi_spec_component_schemas = (UserResponseSchema,)
@expose("/", methods=["GET"])
@safe
def me(self) -> Response:
"""Get the user object corresponding to the agent making the request
---
get:
description: >-
Returns the user object corresponding to the agent making the request,
or returns a 401 error if the user is unauthenticated.
responses:
200:
description: The current user
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/UserResponseSchema'
401:
$ref: '#/components/responses/401'
"""
if g.user is None or g.user.is_anonymous:
return self.response_401()
return self.response(200, result=user_response_schema.dump(g.user))

28
superset/users/schemas.py Normal file
View File

@@ -0,0 +1,28 @@
# 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.
from marshmallow import Schema
from marshmallow.fields import Boolean, Integer, String
class UserResponseSchema(Schema):
id = Integer()
username = String()
email = String()
first_name = String()
last_name = String()
is_active = Boolean()
is_anonymous = Boolean()

View File

@@ -81,6 +81,7 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> Dict[str, An
"lastName": user.last_name,
"userId": user.id,
"isActive": user.is_active,
"isAnonymous": user.is_anonymous,
"createdOn": user.created_on.isoformat(),
"email": user.email,
}

View File

@@ -906,6 +906,7 @@ class TestRolePermission(SupersetTestCase):
["LocaleView", "index"],
["AuthDBView", "login"],
["AuthDBView", "logout"],
["CurrentUserRestApi", "me"],
["Dashboard", "embedded"],
["R", "index"],
["Superset", "log"],

View File

@@ -0,0 +1,49 @@
# 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.
# type: ignore
"""Unit tests for Superset"""
import json
from unittest.mock import patch
from superset import security_manager
from tests.integration_tests.base_tests import SupersetTestCase
meUri = "/api/v1/me/"
class TestCurrentUserApi(SupersetTestCase):
def test_get_me_logged_in(self):
self.login(username="admin")
rv = self.client.get(meUri)
self.assertEqual(200, rv.status_code)
response = json.loads(rv.data.decode("utf-8"))
self.assertEqual("admin", response["result"]["username"])
self.assertEqual(True, response["result"]["is_active"])
self.assertEqual(False, response["result"]["is_anonymous"])
def test_get_me_unauthorized(self):
self.logout()
rv = self.client.get(meUri)
self.assertEqual(401, rv.status_code)
@patch("superset.security.manager.g")
def test_get_me_anonymous(self, mock_g):
mock_g.user = security_manager.get_anonymous_user
rv = self.client.get(meUri)
self.assertEqual(401, rv.status_code)