From 96aecc8860a906d2d185bcbba60fae61ca847fdc Mon Sep 17 00:00:00 2001 From: Amir Bitaraf Haghighi Date: Sat, 12 Sep 2020 01:35:37 +0430 Subject: [PATCH] feat: Preserve authorization on browser refresh and close/reopen (#5939) * Add default configuration `preserveAuthorization` * Add localStorage to auth plugin * Add persistAuthorization unit tests * Refactor persistAuthorization to use wrapped actions * Upgrade unit tests to be compatible with jest * Add persistAuthorization documentation Co-authored-by: Tim Lai --- docs/usage/configuration.md | 6 + src/core/components/auth/auths.jsx | 4 +- src/core/components/auth/oauth2.jsx | 2 +- src/core/index.js | 1 + src/core/plugins/auth/actions.js | 38 ++++++- src/core/plugins/auth/reducers.js | 14 ++- src/core/plugins/configs/actions.js | 15 ++- test/unit/core/plugins/auth/actions.js | 130 +++++++++++++++++++++- test/unit/core/plugins/configs/actions.js | 41 ++++++- 9 files changed, 239 insertions(+), 12 deletions(-) diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 21533af7..1a26defc 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -84,6 +84,12 @@ Parameter name | Docker variable | Description `modelPropertyMacro` | _Unavailable_ | `Function`. Function to set default values to each property in model. Accepts one argument modelPropertyMacro(property), property is immutable `parameterMacro` | _Unavailable_ | `Function`. Function to set default value to parameters. Accepts two arguments parameterMacro(operation, parameter). Operation and parameter are objects passed for context, both remain immutable +##### Authorization + +Parameter name | Docker variable | Description +--- | --- | ----- +`persistAuthorization` | _Unavailable_ | `Boolean=false`. If set to `true`, it persists authorization data and it would not be lost on browser close/refresh + ### Instance methods **💡 Take note! These are methods, not parameters**. diff --git a/src/core/components/auth/auths.jsx b/src/core/components/auth/auths.jsx index 9a9b0cdd..62a9bd61 100644 --- a/src/core/components/auth/auths.jsx +++ b/src/core/components/auth/auths.jsx @@ -27,7 +27,7 @@ export default class Auths extends React.Component { e.preventDefault() let { authActions } = this.props - authActions.authorize(this.state) + authActions.authorizeWithPersistOption(this.state) } logoutClick =(e) => { @@ -43,7 +43,7 @@ export default class Auths extends React.Component { return prev }, {})) - authActions.logout(auths) + authActions.logoutWithPersistOption(auths) } close =(e) => { diff --git a/src/core/components/auth/oauth2.jsx b/src/core/components/auth/oauth2.jsx index efe02e4f..3dc042cb 100644 --- a/src/core/components/auth/oauth2.jsx +++ b/src/core/components/auth/oauth2.jsx @@ -96,7 +96,7 @@ export default class Oauth2 extends React.Component { let { authActions, errActions, name } = this.props errActions.clear({authId: name, type: "auth", source: "auth"}) - authActions.logout([ name ]) + authActions.logoutWithPersistOption([ name ]) } render() { diff --git a/src/core/index.js b/src/core/index.js index 05758621..109c70f9 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -37,6 +37,7 @@ export default function SwaggerUI(opts) { filter: null, validatorUrl: "https://validator.swagger.io/validator", oauth2RedirectUrl: `${window.location.protocol}//${window.location.host}/oauth2-redirect.html`, + persistAuthorization: false, configs: {}, custom: {}, displayOperationId: false, diff --git a/src/core/plugins/auth/actions.js b/src/core/plugins/auth/actions.js index f7c153c9..d3c4e3af 100644 --- a/src/core/plugins/auth/actions.js +++ b/src/core/plugins/auth/actions.js @@ -9,6 +9,7 @@ export const PRE_AUTHORIZE_OAUTH2 = "pre_authorize_oauth2" export const AUTHORIZE_OAUTH2 = "authorize_oauth2" export const VALIDATE = "validate" export const CONFIGURE_AUTH = "configure_auth" +export const RESTORE_AUTHORIZATION = "restore_authorization" const scopeSeparator = " " @@ -26,6 +27,11 @@ export function authorize(payload) { } } +export const authorizeWithPersistOption = (payload) => ( { authActions } ) => { + authActions.authorize(payload) + authActions.persistAuthorizationIfNeeded() +} + export function logout(payload) { return { type: LOGOUT, @@ -33,6 +39,11 @@ export function logout(payload) { } } +export const logoutWithPersistOption = (payload) => ( { authActions } ) => { + authActions.logout(payload) + authActions.persistAuthorizationIfNeeded() +} + export const preAuthorizeImplicit = (payload) => ( { authActions, errActions } ) => { let { auth , token, isValid } = payload let { schema, name } = auth @@ -60,9 +71,10 @@ export const preAuthorizeImplicit = (payload) => ( { authActions, errActions } ) return } - authActions.authorizeOauth2({ auth, token }) + authActions.authorizeOauth2WithPersistOption({ auth, token }) } + export function authorizeOauth2(payload) { return { type: AUTHORIZE_OAUTH2, @@ -70,6 +82,12 @@ export function authorizeOauth2(payload) { } } + +export const authorizeOauth2WithPersistOption = (payload) => ( { authActions } ) => { + authActions.authorizeOauth2(payload) + authActions.persistAuthorizationIfNeeded() +} + export const authorizePassword = ( auth ) => ( { authActions } ) => { let { schema, name, username, password, passwordType, clientId, clientSecret } = auth let form = { @@ -208,7 +226,7 @@ export const authorizeRequest = ( data ) => ( { fn, getConfigs, authActions, err return } - authActions.authorizeOauth2({ auth, token}) + authActions.authorizeOauth2WithPersistOption({ auth, token}) }) .catch(e => { let err = new Error(e) @@ -244,3 +262,19 @@ export function configureAuth(payload) { payload: payload } } + +export function restoreAuthorization(payload) { + return { + type: RESTORE_AUTHORIZATION, + payload: payload + } +} + +export const persistAuthorizationIfNeeded = () => ( { authSelectors, getConfigs } ) => { + const configs = getConfigs() + if (configs.persistAuthorization) + { + const authorized = authSelectors.authorized() + localStorage.setItem("authorized", JSON.stringify(authorized.toJS())) + } +} \ No newline at end of file diff --git a/src/core/plugins/auth/reducers.js b/src/core/plugins/auth/reducers.js index 756f1d14..5bfb7c1d 100644 --- a/src/core/plugins/auth/reducers.js +++ b/src/core/plugins/auth/reducers.js @@ -6,7 +6,8 @@ import { AUTHORIZE, AUTHORIZE_OAUTH2, LOGOUT, - CONFIGURE_AUTH + CONFIGURE_AUTH, + RESTORE_AUTHORIZATION } from "./actions" export default { @@ -50,7 +51,10 @@ export default { auth.token = Object.assign({}, token) parsedAuth = fromJS(auth) - return state.setIn( [ "authorized", parsedAuth.get("name") ], parsedAuth ) + let map = state.get("authorized") || Map() + map = map.set(parsedAuth.get("name"), parsedAuth) + + return state.set( "authorized", map ) }, [LOGOUT]: (state, { payload } ) =>{ @@ -65,5 +69,9 @@ export default { [CONFIGURE_AUTH]: (state, { payload } ) =>{ return state.set("configs", payload) - } + }, + + [RESTORE_AUTHORIZATION]: (state, { payload } ) =>{ + return state.set("authorized", fromJS(payload.authorized)) + }, } diff --git a/src/core/plugins/configs/actions.js b/src/core/plugins/configs/actions.js index 977407de..0c0ef0d9 100644 --- a/src/core/plugins/configs/actions.js +++ b/src/core/plugins/configs/actions.js @@ -21,4 +21,17 @@ export function toggle(configName) { // Hook -export const loaded = () => () => {} +export const loaded = () => ({getConfigs, authActions}) => { + // check if we should restore authorization data from localStorage + const configs = getConfigs() + if (configs.persistAuthorization) + { + const authorized = localStorage.getItem("authorized") + if(authorized) + { + authActions.restoreAuthorization({ + authorized: JSON.parse(authorized) + }) + } + } +} diff --git a/test/unit/core/plugins/auth/actions.js b/test/unit/core/plugins/auth/actions.js index 9e29e010..349f19ee 100644 --- a/test/unit/core/plugins/auth/actions.js +++ b/test/unit/core/plugins/auth/actions.js @@ -1,5 +1,12 @@ - -import { authorizeRequest, authorizeAccessCodeWithFormParams } from "corePlugins/auth/actions" +import { Map } from "immutable" +import { + authorizeRequest, + authorizeAccessCodeWithFormParams, + authorizeWithPersistOption, + authorizeOauth2WithPersistOption, + logoutWithPersistOption, + persistAuthorizationIfNeeded +} from "corePlugins/auth/actions" describe("auth plugin - actions", () => { @@ -184,4 +191,123 @@ describe("auth plugin - actions", () => { expect(actualArgument.body).toContain("grant_type=authorization_code") }) }) + + describe("persistAuthorization", () => { + describe("wrapped functions with persist option", () => { + it("should wrap `authorize` action and persist data if needed", () => { + + // Given + const data = { + "api_key": {} + } + const system = { + getConfigs: () => ({}), + authActions: { + authorize: jest.fn(()=>{}), + persistAuthorizationIfNeeded: jest.fn(()=>{}) + } + } + + // When + authorizeWithPersistOption(data)(system) + + // Then + expect(system.authActions.authorize).toHaveBeenCalled() + expect(system.authActions.authorize).toHaveBeenCalledWith(data) + expect(system.authActions.persistAuthorizationIfNeeded).toHaveBeenCalled() + }) + + it("should wrap `oauth2Authorize` action and persist data if needed", () => { + + // Given + const data = { + "api_key": {} + } + const system = { + getConfigs: () => ({}), + authActions: { + authorizeOauth2: jest.fn(()=>{}), + persistAuthorizationIfNeeded: jest.fn(()=>{}) + } + } + + // When + authorizeOauth2WithPersistOption(data)(system) + + // Then + expect(system.authActions.authorizeOauth2).toHaveBeenCalled() + expect(system.authActions.authorizeOauth2).toHaveBeenCalledWith(data) + expect(system.authActions.persistAuthorizationIfNeeded).toHaveBeenCalled() + }) + + it("should wrap `logout` action and persist data if needed", () => { + + // Given + const data = { + "api_key": {} + } + const system = { + getConfigs: () => ({}), + authActions: { + logout: jest.fn(()=>{}), + persistAuthorizationIfNeeded: jest.fn(()=>{}) + } + } + + // When + logoutWithPersistOption(data)(system) + + // Then + expect(system.authActions.logout).toHaveBeenCalled() + expect(system.authActions.logout).toHaveBeenCalledWith(data) + expect(system.authActions.persistAuthorizationIfNeeded).toHaveBeenCalled() + }) + }) + + describe("persistAuthorizationIfNeeded", () => { + beforeEach(() => { + localStorage.clear() + }) + it("should skip if `persistAuthorization` is turned off", () => { + // Given + const system = { + getConfigs: () => ({ + persistAuthorization: false + }), + authSelectors: { + authorized: jest.fn(()=>{}) + } + } + + // When + persistAuthorizationIfNeeded()(system) + + // Then + expect(system.authSelectors.authorized).not.toHaveBeenCalled() + }) + it("should persist authorization data to localStorage", () => { + // Given + const data = { + "api_key": {} + } + const system = { + getConfigs: () => ({ + persistAuthorization: true + }), + authSelectors: { + authorized: jest.fn(()=>Map(data)) + } + } + jest.spyOn(Object.getPrototypeOf(window.localStorage), "setItem") + + // When + persistAuthorizationIfNeeded()(system) + + expect(localStorage.setItem).toHaveBeenCalled() + expect(localStorage.setItem).toHaveBeenCalledWith("authorized", JSON.stringify(data)) + + }) + }) + + }) }) diff --git a/test/unit/core/plugins/configs/actions.js b/test/unit/core/plugins/configs/actions.js index b47d29fd..757c410d 100644 --- a/test/unit/core/plugins/configs/actions.js +++ b/test/unit/core/plugins/configs/actions.js @@ -1,5 +1,5 @@ - import { downloadConfig } from "corePlugins/configs/spec-actions" +import { loaded } from "corePlugins/configs/actions" describe("configs plugin - actions", () => { @@ -23,4 +23,43 @@ describe("configs plugin - actions", () => { expect(fetchSpy).toHaveBeenCalledWith(req) }) }) + + describe("loaded hook", () => { + describe("authorization data restoration", () => { + beforeEach(() => { + localStorage.clear() + }) + it("retrieve `authorized` value from `localStorage`", () => { + const system = { + getConfigs: () => ({ + persistAuthorization: true + }), + authActions: { + + } + } + jest.spyOn(Object.getPrototypeOf(window.localStorage), "getItem") + loaded()(system) + expect(localStorage.getItem).toHaveBeenCalled() + expect(localStorage.getItem).toHaveBeenCalledWith("authorized") + }) + it("restore authorization data when a value exists", () => { + const system = { + getConfigs: () => ({ + persistAuthorization: true + }), + authActions: { + restoreAuthorization: jest.fn(() => {}) + } + } + const mockData = {"api_key": {}} + localStorage.setItem("authorized", JSON.stringify(mockData)) + loaded()(system) + expect(system.authActions.restoreAuthorization).toHaveBeenCalled() + expect(system.authActions.restoreAuthorization).toHaveBeenCalledWith({ + authorized: mockData + }) + }) + }) + }) })