Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1480] ui/auth: Query current user permissions to detect stale token #1569

Merged
merged 5 commits into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The types of changes are:
### Fixed

* After editing a dataset, the table will stay on the previously selected collection instead of resetting to the first one. [#1511](https://github.com/ethyca/fides/pull/1511)
* Expired auth tokens will now log the user out automatically. [#1569](https://github.com/ethyca/fides/pull/1569)

## [1.9.2](https://github.com/ethyca/fides/compare/1.9.1...1.9.2)

Expand Down
16 changes: 10 additions & 6 deletions clients/admin-ui/cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="cypress" />

import { STORAGE_ROOT_KEY } from "~/constants";
import { STORAGE_ROOT_KEY, USER_PRIVILEGES } from "~/constants";

Cypress.Commands.add("getByTestId", (selector, ...args) =>
cy.get(`[data-testid='${selector}']`, ...args)
Expand All @@ -9,13 +9,17 @@ Cypress.Commands.add("getByTestId", (selector, ...args) =>
Cypress.Commands.add("login", () => {
cy.fixture("login.json").then((body) => {
const authState = {
user_data: body.user_data,
user: body.user_data,
token: body.token_data.access_token,
};
window.localStorage.setItem(
STORAGE_ROOT_KEY,
JSON.stringify(authState)
);
window.localStorage.setItem(STORAGE_ROOT_KEY, JSON.stringify(authState));
cy.intercept("/api/v1/user/*/permission", {
body: {
id: body.user_data.id,
user_id: body.user_data.id,
scopes: USER_PRIVILEGES.map((up) => up.scope),
},
}).as("getUserPermission");
});
});

Expand Down
4 changes: 0 additions & 4 deletions clients/admin-ui/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ import {
} from "~/features/organization";
import { reducer as systemReducer, systemApi } from "~/features/system";
import { reducer as taxonomyReducer, taxonomyApi } from "~/features/taxonomy";
import { reducer as userReducer } from "~/features/user";

import { authApi, AuthState, reducer as authReducer } from "../features/auth";

Expand Down Expand Up @@ -90,15 +89,13 @@ const reducer = {
[dataUseApi.reducerPath]: dataUseApi.reducer,
[datasetApi.reducerPath]: datasetApi.reducer,
[datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer,
[datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer,
[organizationApi.reducerPath]: organizationApi.reducer,
[plusApi.reducerPath]: plusApi.reducer,
[privacyRequestApi.reducerPath]: privacyRequestApi.reducer,
[scannerApi.reducerPath]: scannerApi.reducer,
[systemApi.reducerPath]: systemApi.reducer,
[taxonomyApi.reducerPath]: taxonomyApi.reducer,
[userApi.reducerPath]: userApi.reducer,
[userApi.reducerPath]: userApi.reducer,
auth: authReducer,
configWizard: configWizardReducer,
connectionType: connectionTypeReducer,
Expand All @@ -111,7 +108,6 @@ const reducer = {
subjectRequests: privacyRequestsReducer,
system: systemReducer,
taxonomy: taxonomyReducer,
user: userReducer,
userManagement: userManagementReducer,
};

Expand Down
7 changes: 7 additions & 0 deletions clients/admin-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export const USER_PRIVILEGES: UserPrivileges[] = [
},
];

/**
* Interval between re-fetching a logged-in user's permission to validate their auth token.
* Only applies to an active page -- token will always revalidate on page refresh.
* Ten minutes in milliseconds.
*/
export const VERIFY_AUTH_INTERVAL = 10 * 60 * 1000;

// API ROUTES
export const INDEX_ROUTE = "/";
export const LOGIN_ROUTE = "/login";
Expand Down
27 changes: 19 additions & 8 deletions clients/admin-ui/src/features/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { useRouter } from "next/router";
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";

import { LOGIN_ROUTE } from "../../constants";
import { selectToken } from "./auth.slice";
import { LOGIN_ROUTE, VERIFY_AUTH_INTERVAL } from "~/constants";
import { useGetUserPermissionsQuery } from "~/features/user-management";

import { logout, selectToken, selectUser } from "./auth.slice";

const useProtectedRoute = (redirectUrl: string) => {
const router = useRouter();
const dispatch = useDispatch();
const token = useSelector(selectToken);

// TODO: check for token invalidation
if (!token && typeof window !== "undefined") {
router.push(redirectUrl);
const user = useSelector(selectUser);
const userId = user?.id;
const permissionsQuery = useGetUserPermissionsQuery(userId!, {
pollingInterval: VERIFY_AUTH_INTERVAL,
skip: !userId,
});

if (!token || !userId || permissionsQuery.isError) {
dispatch(logout());
if (typeof window !== "undefined") {
router.push(redirectUrl);
}
return false;
}

return true;
return permissionsQuery.isSuccess;
};

interface ProtectedRouteProps {
Expand Down
38 changes: 7 additions & 31 deletions clients/admin-ui/src/features/auth/auth.slice.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import {
createListenerMiddleware,
createSlice,
PayloadAction,
} from "@reduxjs/toolkit";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { addCommonHeaders } from "common/CommonHeaders";
import { utf8ToB64 } from "common/utils";
import { User } from "user-management/types";

import type { RootState } from "../../app/store";
import { BASE_URL, STORAGE_ROOT_KEY } from "../../constants";
import type { RootState } from "~/app/store";
import { BASE_URL } from "~/constants";
import { addCommonHeaders } from "~/features/common/CommonHeaders";
import { utf8ToB64 } from "~/features/common/utils";
import { User } from "~/features/user-management/types";

import {
LoginRequest,
LoginResponse,
Expand Down Expand Up @@ -56,27 +53,6 @@ export const selectToken = (state: RootState) => selectAuth(state).token;

export const { login, logout } = authSlice.actions;

export const credentialStorage = createListenerMiddleware();
credentialStorage.startListening({
actionCreator: login,
effect: (action, { getState }) => {
if (window && window.localStorage) {
localStorage.setItem(
STORAGE_ROOT_KEY,
JSON.stringify(selectAuth(getState() as RootState))
);
}
},
});
credentialStorage.startListening({
actionCreator: logout,
effect: () => {
if (window && window.localStorage) {
localStorage.removeItem(STORAGE_ROOT_KEY);
}
},
});

// Auth API
export const authApi = createApi({
reducerPath: "authApi",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const useUserForm = (profile: User, permissions: UserPermissions) => {
username: profile.username ?? "",
first_name: profile.first_name ?? "",
last_name: profile.last_name ?? "",
password: profile.password ?? "",
password: "",
scopes: permissions.scopes ?? [],
id: profile.id,
};
Expand Down
6 changes: 3 additions & 3 deletions clients/admin-ui/src/features/user-management/UserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as Yup from "yup";

import { USER_MANAGEMENT_ROUTE, USER_PRIVILEGES } from "../../constants";
import { CustomTextInput } from "./form/inputs";
import { User } from "./types";
import { User, UserCreateResponse } from "./types";
import UpdatePasswordModal from "./UpdatePasswordModal";
import { useUpdateUserPermissionsMutation } from "./user-management.slice";

Expand Down Expand Up @@ -49,7 +49,7 @@ const ValidationSchema = Yup.object().shape({
interface Props {
onSubmit: (values: FormValues) => Promise<
| {
data: User;
data: User | UserCreateResponse;
}
| {
error: FetchBaseQueryError | SerializedError;
Expand Down Expand Up @@ -86,7 +86,7 @@ const UserForm = ({
// then issue a separate call to update their permissions
const { data } = result;
const userWithPrivileges = {
id: data.id,
user_id: data.id,
scopes: [...new Set([...values.scopes, "privacy-request:read"])],
};
const updateUserPermissionsResult = await updateUserPermissions(
Expand Down
72 changes: 33 additions & 39 deletions clients/admin-ui/src/features/user-management/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
export interface User {
id: string;
first_name?: string;
last_name?: string;
username?: string;
password?: string;
created_at?: string;
}
import {
Page_UserResponse_,
UserCreate,
UserCreateResponse,
UserPasswordReset,
UserPermissionsCreate,
UserPermissionsEdit,
UserPermissionsResponse,
UserResponse,
UserUpdate,
} from "~/types/api";

// Now that we have generated API types, this file can mostly re-export those interfaces.
export type {
UserCreate,
UserCreateResponse,
UserPermissionsCreate,
UserPermissionsResponse,
UserResponse,
UserUpdate,
};

export interface UserResponse {
id: string;
}
export interface UsersResponse extends Page_UserResponse_ {}

export interface UsersResponse {
items: User[];
total: number;
export interface User extends UserResponse {}

export interface UserPermissions extends UserPermissionsResponse {}

export interface UserUpdateParams extends UserUpdate {
id: string;
}

export interface UsersListParams {
Expand All @@ -22,37 +36,17 @@ export interface UsersListParams {
username: string;
}

export interface UserPasswordUpdate {
export interface UserPasswordResetParams extends UserPasswordReset {
id: string;
old_password: string;
new_password: string;
}

export interface UserPermissionsUpdate {
id: string | null;
scopes: string[];
}

export interface UserPermissionsResponse {
data: {
id: string;
};
scope: string[];
export interface UserPermissionsEditParams {
// This is the Id of the User, not the the Id field of the UserPermissions model.
user_id: string;
scopes: UserPermissionsEdit["scopes"];
}

export interface UserPrivileges {
privilege: string;
scope: string;
}

export type CreateUserError = {
detail: {
msg: string;
}[];
};

export type UserPermissions = {
id: string;
scopes: string[];
user_id: string;
};
Loading