From 1e2010e0cd82e1f51d229ecf8a27124153b0ff66 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 13:48:45 +0200 Subject: [PATCH 01/31] [Fix] Add .gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a4341309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +*.iml +node_modules +build +coverage +junit.xml +.DS_store From 1b394d529e5aae6438c1150d09ed8e24a642985d Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 13:49:32 +0200 Subject: [PATCH 02/31] [Fix] Ignore prop-types eslint rule. It caused every component to be marked as erroneous. --- .eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc b/.eslintrc index 4449ff52..ce642721 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,6 +22,6 @@ "plugin:react/recommended" ], "rules": { - + "react/prop-types": "off" } } \ No newline at end of file From 778b612db16407747d37954fbcc3f528a33e7f44 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 13:50:27 +0200 Subject: [PATCH 03/31] [OIDC] Add basic OIDC infrastructure code. --- js/actions/index.js | 14 +++ js/components/misc/oidc/IfInternalAuth.js | 11 ++ js/components/misc/oidc/OidcAuthWrapper.js | 120 +++++++++++++++++++++ js/constants/DefaultConstants.js | 6 +- js/oidc-signin-callback.html | 15 +++ js/oidc-silent-callback.html | 5 + js/utils/OidcUtils.js | 63 +++++++++++ js/utils/SecurityUtils.js | 11 ++ package-lock.json | 43 ++++++-- package.json | 1 + 10 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 js/components/misc/oidc/IfInternalAuth.js create mode 100644 js/components/misc/oidc/OidcAuthWrapper.js create mode 100644 js/oidc-signin-callback.html create mode 100644 js/oidc-silent-callback.html create mode 100644 js/utils/OidcUtils.js create mode 100644 js/utils/SecurityUtils.js diff --git a/js/actions/index.js b/js/actions/index.js index e9098776..d986b2eb 100644 --- a/js/actions/index.js +++ b/js/actions/index.js @@ -1,12 +1,26 @@ import axios from 'axios'; import Routes from "../constants/RoutesConstants"; import {transitionTo} from "../utils/Routing"; +import {HttpHeaders} from "../constants/DefaultConstants"; +import {getOidcToken} from "../utils/SecurityUtils"; +import {isUsingOidcAuth} from "../utils/OidcUtils"; // Axios instance for communicating with Backend export let axiosBackend = axios.create({ withCredentials: true }); +axiosBackend.interceptors.request.use((reqConfig) => { + if (!isUsingOidcAuth()) { + return reqConfig; + } + if (!reqConfig.headers) { + reqConfig.headers = {}; + } + reqConfig.headers[HttpHeaders.AUTHORIZATION] = getOidcToken(); + return reqConfig; +}); + axiosBackend.interceptors.response.use( response => response, error => { diff --git a/js/components/misc/oidc/IfInternalAuth.js b/js/components/misc/oidc/IfInternalAuth.js new file mode 100644 index 00000000..64c23bd8 --- /dev/null +++ b/js/components/misc/oidc/IfInternalAuth.js @@ -0,0 +1,11 @@ +import React from "react"; +import {isUsingOidcAuth} from "../../../utils/OidcUtils"; + +const IfInternalAuth = ({ children }) => { + if (isUsingOidcAuth()) { + return null; + } + return <>{children}; +}; + +export default IfInternalAuth; diff --git a/js/components/misc/oidc/OidcAuthWrapper.js b/js/components/misc/oidc/OidcAuthWrapper.js new file mode 100644 index 00000000..e0db02c2 --- /dev/null +++ b/js/components/misc/oidc/OidcAuthWrapper.js @@ -0,0 +1,120 @@ +import React, {useCallback, useEffect, useState,} from "react"; +import {UserManager} from "oidc-client"; +import {generateRedirectUri, getOidcConfig} from "../../../utils/OidcUtils"; + +// Taken from https://github.com/datagov-cz/assembly-line-shared but using a different config processing mechanism + +const useThrow = () => { + const [, setState] = useState(); + return useCallback( + (error) => + setState(() => { + throw error; + }), + [setState] + ); +}; + +// Singleton UserManager instance +let userManager; +const getUserManager = () => { + if (!userManager) { + userManager = new UserManager(getOidcConfig()); + } + return userManager; +}; + +/** + * Context provider for user data and logout action trigger + */ +export const AuthContext = React.createContext(null); + +const OidcAuthWrapper = ({ + children, + location = window.location, + history = window.history, +}) => { + const userManager = getUserManager(); + const throwError = useThrow(); + const [user, setUser] = useState(null); + + useEffect(() => { + const getUser = async () => { + try { + // Try to get user information + const user = await userManager.getUser(); + + if (user && user.access_token && !user.expired) { + // User authenticated + // NOTE: the oidc-client-js library never returns null if the user is not authenticated + // Checking for existence of BOTH access_token and expired field seems OK + // Checking only for expired field is not enough + setUser(user); + } else { + // User not authenticated -> trigger auth flow + await userManager.signinRedirect({ + redirect_uri: generateRedirectUri(location.href), + }); + } + } catch (error) { + throwError(error); + } + }; + getUser(); + }, [location, history, throwError, setUser, userManager]); + + useEffect(() => { + // Refreshing react state when new state is available in e.g. session storage + const updateUserData = async () => { + try { + const user = await userManager.getUser(); + setUser(user); + } catch (error) { + throwError(error); + } + }; + + userManager.events.addUserLoaded(updateUserData); + + // Unsubscribe on component unmount + return () => userManager.events.removeUserLoaded(updateUserData); + }, [throwError, setUser, userManager]); + + useEffect(() => { + // Force log in if session cannot be renewed on background + const handleSilentRenewError = async () => { + try { + await userManager.signinRedirect({ + redirect_uri: generateRedirectUri(location.href), + }); + } catch (error) { + throwError(error); + } + }; + + userManager.events.addSilentRenewError(handleSilentRenewError); + + // Unsubscribe on component unmount + return () => + userManager.events.removeSilentRenewError(handleSilentRenewError); + }, [location, throwError, setUser, userManager]); + + const logout = useCallback(() => { + const handleLogout = async () => { + await userManager.signoutRedirect(); + }; + handleLogout(); + }, [userManager]); + + if (!user) { + return null; + } + + return ( + + {children} + + ); +}; + +export default OidcAuthWrapper; diff --git a/js/constants/DefaultConstants.js b/js/constants/DefaultConstants.js index b019b760..c601b600 100644 --- a/js/constants/DefaultConstants.js +++ b/js/constants/DefaultConstants.js @@ -106,4 +106,8 @@ export const STATISTICS_TYPE = { NUMBER_OF_PROCESSED_RECORDS: 'statistics.number-of-processed-records' }; -export const SCRIPT_ERROR = 'SCRIPT_ERROR'; \ No newline at end of file +export const SCRIPT_ERROR = 'SCRIPT_ERROR'; + +export const HttpHeaders = { + AUTHORIZATION: "Authorization" +} diff --git a/js/oidc-signin-callback.html b/js/oidc-signin-callback.html new file mode 100644 index 00000000..07429ccb --- /dev/null +++ b/js/oidc-signin-callback.html @@ -0,0 +1,15 @@ + + diff --git a/js/oidc-silent-callback.html b/js/oidc-silent-callback.html new file mode 100644 index 00000000..7b506781 --- /dev/null +++ b/js/oidc-silent-callback.html @@ -0,0 +1,5 @@ + + diff --git a/js/utils/OidcUtils.js b/js/utils/OidcUtils.js new file mode 100644 index 00000000..8d0618da --- /dev/null +++ b/js/utils/OidcUtils.js @@ -0,0 +1,63 @@ +// Taken from https://github.com/datagov-cz/assembly-line-shared but using a different config processing mechanism + +import {getEnv} from "../../config"; +import Routes from "../constants/RoutesConstants"; + +/** + * Base64 encoding helper + */ +const encodeBase64 = (uri) => { + return window.btoa(uri); +}; + +/** + * Forward URI encoding helper + */ +const encodeForwardUri = (uri) => { + // Since base64 produces equal signs on the end, it needs to be further encoded + return encodeURI(encodeBase64(uri)); +}; + +export const getOidcConfig = () => { + const clientId = getEnv("AUTH_CLIENT_ID"); + const baseUrl = resolveUrl(); + return { + authority: getEnv("AUTH_SERVER_URL"), + client_id: clientId, + redirect_uri: `${baseUrl}/oidc-signin-callback.html?forward_uri=${encodeForwardUri( + baseUrl + )}`, + silent_redirect_uri: `${baseUrl}/oidc-silent-callback.html`, + post_logout_redirect_uri: `${baseUrl}#${Routes.logout.path}`, + response_type: "code", + loadUserInfo: true, + automaticSilentRenew: true, + revokeAccessTokenOnSignout: true, + }; +}; + +function resolveUrl() { + const loc = window.location; + return loc.protocol + "//" + loc.host + loc.pathname; +} + +/** + * Helper to generate redirect Uri + */ +export const generateRedirectUri = (forwardUri) => { + return `${resolveUrl()}/oidc-signin-callback.html?forward_uri=${encodeForwardUri( + forwardUri + )}`; +}; + +/** + * OIDC Session storage key name + */ +export const getOidcIdentityStorageKey = () => { + const oidcConfig = getOidcConfig(); + return `oidc.user:${oidcConfig.authority}:${oidcConfig.client_id}`; +}; + +export function isUsingOidcAuth() { + return getEnv("AUTHENTICATION", "") === "oidc"; +} diff --git a/js/utils/SecurityUtils.js b/js/utils/SecurityUtils.js new file mode 100644 index 00000000..a7bde319 --- /dev/null +++ b/js/utils/SecurityUtils.js @@ -0,0 +1,11 @@ +import {getOidcIdentityStorageKey} from "./OidcUtils"; + +export function getOidcToken() { + const identityData = sessionStorage.getItem(getOidcIdentityStorageKey()); + const identity = identityData ? JSON.parse(identityData) : null; + return `${identity?.token_type} ${identity?.access_token}`; +} + +export function clearToken() { + sessionStorage.removeItem(getOidcIdentityStorageKey()); +} diff --git a/package-lock.json b/package-lock.json index 64e70005..29d5a26c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "history": "^4.10.1", "jsonld-utils": "https://kbss.felk.cvut.cz/dist/jsonld-utils-0.0.11.tgz", "lodash": "^4.17.15", + "oidc-client": "^1.11.5", "platform": "^1.3.5", "prop-types": "^15.7.2", "react": "^17.0.2", @@ -3789,7 +3790,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -4871,7 +4871,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6325,6 +6324,11 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -15290,6 +15294,18 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "node_modules/oidc-client": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", + "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", + "dependencies": { + "acorn": "^7.4.1", + "base64-js": "^1.5.1", + "core-js": "^3.8.3", + "crypto-js": "^4.0.0", + "serialize-javascript": "^4.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -26090,8 +26106,7 @@ "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" }, "acorn-globals": { "version": "4.3.4", @@ -26925,8 +26940,7 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "batch": { "version": "0.6.1", @@ -28104,6 +28118,11 @@ "randomfill": "^1.0.3" } }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -34988,6 +35007,18 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "oidc-client": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", + "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", + "requires": { + "acorn": "^7.4.1", + "base64-js": "^1.5.1", + "core-js": "^3.8.3", + "crypto-js": "^4.0.0", + "serialize-javascript": "^4.0.0" + } + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/package.json b/package.json index 5f76dd2d..b464519f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "history": "^4.10.1", "jsonld-utils": "https://kbss.felk.cvut.cz/dist/jsonld-utils-0.0.11.tgz", "lodash": "^4.17.15", + "oidc-client": "^1.11.5", "platform": "^1.3.5", "prop-types": "^15.7.2", "react": "^17.0.2", From 45e9c335e2a8a374c0b6ec0151c8c13b6b344736 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 15:44:55 +0200 Subject: [PATCH 04/31] [Fix] Fix test setup. Make tests run again. --- .env.test | 8 ++++++++ .babelrc => babel.config.js | 2 +- jest.config.js | 1 + package.json | 2 +- tests/__tests__/components/Record.spec.js | 24 ++++++++++++++--------- tests/setup.js | 5 +++-- 6 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 .env.test rename .babelrc => babel.config.js (96%) diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..b2c8dc7b --- /dev/null +++ b/.env.test @@ -0,0 +1,8 @@ +RECORD_MANAGER_API_URL=http://localhost:8080/record-manager +RECORD_MANAGER_APP_TITLE=OFN Record Manager +RECORD_MANAGER_DEV_SERVER_PORT=3000 +RECORD_MANAGER_PROD_SERVER_PORT=8080 +RECORD_MANAGER_LANGUAGE=en +RECORD_MANAGER_NAVIGATOR_LANGUAGE=false +RECORD_MANAGER_BASENAME=/record-manager +RECORD_MANAGER_EXTENSIONS= \ No newline at end of file diff --git a/.babelrc b/babel.config.js similarity index 96% rename from .babelrc rename to babel.config.js index 6a5b9c87..5bce0308 100644 --- a/.babelrc +++ b/babel.config.js @@ -1,4 +1,4 @@ -{ +module.exports = { "plugins": [ "lodash", "@babel/plugin-proposal-class-properties", diff --git a/jest.config.js b/jest.config.js index 2d7585b3..46d50275 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { transform: { '^.+\\.(js|jsx)$': 'babel-jest' }, + transformIgnorePatterns: ["node_modules/(?!@kbss-cvut)/"], reporters: ['default'], "moduleNameMapper": { "\\.(css)$": "/tests/__mocks__/styleMock.js" diff --git a/package.json b/package.json index b464519f..9d382856 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bowser": "^2.9.0", "classnames": "^2.3.1", "history": "^4.10.1", - "jsonld-utils": "https://kbss.felk.cvut.cz/dist/jsonld-utils-0.0.11.tgz", + "jsonld-utils": "https://kbss.felk.cvut.c/s-forms/dist/*.jsz/dist/jsonld-utils-0.0.11.tgz", "lodash": "^4.17.15", "oidc-client": "^1.11.5", "platform": "^1.3.5", diff --git a/tests/__tests__/components/Record.spec.js b/tests/__tests__/components/Record.spec.js index 22aa4d71..3c84f376 100644 --- a/tests/__tests__/components/Record.spec.js +++ b/tests/__tests__/components/Record.spec.js @@ -3,7 +3,7 @@ import React from 'react'; import {IntlProvider} from 'react-intl'; import TestUtils from 'react-dom/test-utils'; -import {ACTION_STATUS} from "../../../js/constants/DefaultConstants"; +import {ACTION_STATUS, ROLE} from "../../../js/constants/DefaultConstants"; import Record from "../../../js/components/record/Record"; import * as RecordState from "../../../js/model/RecordState"; import enLang from '../../../js/i18n/en'; @@ -16,6 +16,7 @@ describe('Record', function () { showAlert, recordLoaded, formgen = {}, + currentUser, handlers = { onSave: jest.fn(), onCancel: jest.fn(), @@ -41,6 +42,11 @@ describe('Record', function () { isNew: true, state: RecordState.createInitialState() }; + currentUser = { + firstName: "Test", + lastName: "User", + role: ROLE.DOCTOR + }; }); record = { @@ -60,7 +66,7 @@ describe('Record', function () { const tree = TestUtils.renderIntoDocument( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const result = TestUtils.findRenderedDOMComponentWithClass(tree, 'loader-spin'); expect(result).not.toBeNull(); @@ -77,17 +83,17 @@ describe('Record', function () { const tree = TestUtils.renderIntoDocument( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const alert = TestUtils.scryRenderedDOMComponentsWithClass(tree, "alert-danger"); expect(alert).not.toBeNull(); }); - xit("renders record's form empty", function () { + it("renders record's form empty", function () { const tree = mount( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const result = tree.find('input'); @@ -106,7 +112,7 @@ describe('Record', function () { const tree = mount( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); let buttons = tree.find("Button"); @@ -122,7 +128,7 @@ describe('Record', function () { const tree = TestUtils.renderIntoDocument( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const alert = TestUtils.scryRenderedDOMComponentsWithClass(tree, "alert-success"); @@ -141,7 +147,7 @@ describe('Record', function () { const tree = TestUtils.renderIntoDocument( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const alert = TestUtils.scryRenderedDOMComponentsWithClass(tree, "alert-danger"); @@ -156,7 +162,7 @@ describe('Record', function () { const tree = TestUtils.renderIntoDocument( + recordSaved={recordSaved} showAlert={showAlert} formgen={formgen} currentUser={currentUser} formTemplatesLoaded={{}}/> ); const loader = TestUtils.findRenderedDOMComponentWithClass(tree, "loader"); expect(loader).not.toBeNull(); diff --git a/tests/setup.js b/tests/setup.js index 5885eb67..be659926 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,10 +1,11 @@ import enzyme, {shallow, mount} from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; enzyme.configure({adapter: new Adapter()}); require('dotenv-safe').config({ - allowEmptyValues: [ 'RECORD_MANAGER_BASENAME' ], + allowEmptyValues: true, + path: './.env.test', sample: './.env.example', }) global.shallow = shallow; From 01dbd8c730ee699c0c99359958cbf31296e24dfa Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 16:00:03 +0200 Subject: [PATCH 05/31] [OIDC] Extend .env.example with authentication-related parameters. --- .env.example | 8 +++++++- .env.test | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 9eb86221..9fb130de 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,10 @@ RECORD_MANAGER_NAVIGATOR_LANGUAGE=true # Context path added to URL or "" in case application path should not be modified. RECORD_MANAGER_BASENAME=/record-manager # List of extensions seperated by comma, currently supports only values: "kodi" -RECORD_MANAGER_EXTENSIONS=kodi \ No newline at end of file +RECORD_MANAGER_EXTENSIONS=kodi +# Authentication method - use "internal" for internal authentication or "oidc" for an external auth service compatible with OIDC +AUTHENTICATION=internal +# Authentication server URL, applicable when AUTHENTICATION=oidc +AUTH_SERVER_URL= +# Client ID of this application in the OIDC authentication server +AUTH_CLIENT_ID=record-manager-ui diff --git a/.env.test b/.env.test index b2c8dc7b..e5f888ce 100644 --- a/.env.test +++ b/.env.test @@ -5,4 +5,7 @@ RECORD_MANAGER_PROD_SERVER_PORT=8080 RECORD_MANAGER_LANGUAGE=en RECORD_MANAGER_NAVIGATOR_LANGUAGE=false RECORD_MANAGER_BASENAME=/record-manager -RECORD_MANAGER_EXTENSIONS= \ No newline at end of file +RECORD_MANAGER_EXTENSIONS= +AUTHENTICATION=internal +AUTH_SERVER_URL= +AUTH_CLIENT_ID= From 1cb2d94ed52aefc4086a81bf6387f767d09c81c5 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 20 Oct 2023 18:25:03 +0200 Subject: [PATCH 06/31] [OIDC] Working on integration with OIDC authentication service. --- .env.example | 6 +++--- .env.test | 6 +++--- js/App.js | 20 +++++++++++++------- package.json | 2 +- webpack.config.js | 9 +++++++++ 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 9fb130de..b393ab3f 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,8 @@ RECORD_MANAGER_BASENAME=/record-manager # List of extensions seperated by comma, currently supports only values: "kodi" RECORD_MANAGER_EXTENSIONS=kodi # Authentication method - use "internal" for internal authentication or "oidc" for an external auth service compatible with OIDC -AUTHENTICATION=internal +RECORD_MANAGER_AUTHENTICATION=internal # Authentication server URL, applicable when AUTHENTICATION=oidc -AUTH_SERVER_URL= +RECORD_MANAGER_AUTH_SERVER_URL= # Client ID of this application in the OIDC authentication server -AUTH_CLIENT_ID=record-manager-ui +RECORD_MANAGER_AUTH_CLIENT_ID=record-manager-ui diff --git a/.env.test b/.env.test index e5f888ce..e5ae91d5 100644 --- a/.env.test +++ b/.env.test @@ -6,6 +6,6 @@ RECORD_MANAGER_LANGUAGE=en RECORD_MANAGER_NAVIGATOR_LANGUAGE=false RECORD_MANAGER_BASENAME=/record-manager RECORD_MANAGER_EXTENSIONS= -AUTHENTICATION=internal -AUTH_SERVER_URL= -AUTH_CLIENT_ID= +RECORD_MANAGER_AUTHENTICATION=internal +RECORD_MANAGER_AUTH_SERVER_URL= +RECORD_MANAGER_AUTH_CLIENT_ID= diff --git a/js/App.js b/js/App.js index 75ee10c6..4e85356f 100644 --- a/js/App.js +++ b/js/App.js @@ -1,5 +1,3 @@ -'use strict'; - import React from "react"; import {IntlProvider} from "react-intl"; import {Route, Router} from "react-router-dom"; @@ -7,14 +5,22 @@ import MainView from "./components/MainView"; import {connect} from "react-redux"; import {history} from "./utils/Routing"; import {BASENAME} from "../config"; +import OidcAuthWrapper from "./components/misc/oidc/OidcAuthWrapper"; +import {isUsingOidcAuth} from "./utils/OidcUtils"; -const App = (props) => ( - +const App = (props) => { + return - + - -); + ; +} + +const OidcMainView = () => { + return + + ; +} export default connect((state) => { return {intl: state.intl} diff --git a/package.json b/package.json index 9d382856..b464519f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bowser": "^2.9.0", "classnames": "^2.3.1", "history": "^4.10.1", - "jsonld-utils": "https://kbss.felk.cvut.c/s-forms/dist/*.jsz/dist/jsonld-utils-0.0.11.tgz", + "jsonld-utils": "https://kbss.felk.cvut.cz/dist/jsonld-utils-0.0.11.tgz", "lodash": "^4.17.15", "oidc-client": "^1.11.5", "platform": "^1.3.5", diff --git a/webpack.config.js b/webpack.config.js index 399ee39c..2de89831 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -107,11 +107,20 @@ module.exports = ( year: new Date().getFullYear(), title: appTitle, template: 'index.html', + filename: 'index.html', inject: true, minify: true, basename: (isStatic || isForceBasename) ? basename : "", appInfo: appInfo }), + new HtmlWebpackPlugin({ + template: 'oidc-signin-callback.html', + filename: 'oidc-signin-callback.html' + }), + new HtmlWebpackPlugin({ + template: 'oidc-silent-callback.html', + filename: 'oidc-silent-callback.html' + }), new InlineManifestWebpackPlugin(), new webpack.DefinePlugin({ From 74876f3a97051b9dd4cac85b7e64a2f583329a6a Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 26 Oct 2023 10:33:30 +0200 Subject: [PATCH 07/31] [OIDC] Implement React-based OIDC redirect components. Fix Webpack path configuration. --- js/App.js | 4 ++++ js/components/misc/oidc/OidcAuthWrapper.js | 12 +---------- js/components/pages/OidcSignInCallback.js | 23 ++++++++++++++++++++++ js/components/pages/OidcSilentCallback.js | 16 +++++++++++++++ js/oidc-signin-callback.html | 15 -------------- js/oidc-silent-callback.html | 5 ----- js/utils/OidcUtils.js | 16 ++++++++++++--- tests/jasmine.json | 12 ----------- webpack.config.js | 16 +++++---------- 9 files changed, 62 insertions(+), 57 deletions(-) create mode 100644 js/components/pages/OidcSignInCallback.js create mode 100644 js/components/pages/OidcSilentCallback.js delete mode 100644 js/oidc-signin-callback.html delete mode 100644 js/oidc-silent-callback.html delete mode 100644 tests/jasmine.json diff --git a/js/App.js b/js/App.js index 4e85356f..b460556c 100644 --- a/js/App.js +++ b/js/App.js @@ -6,11 +6,15 @@ import {connect} from "react-redux"; import {history} from "./utils/Routing"; import {BASENAME} from "../config"; import OidcAuthWrapper from "./components/misc/oidc/OidcAuthWrapper"; +import OidcSignInCallback from "./components/pages/OidcSignInCallback"; +import OidcSilentCallback from "./components/pages/OidcSilentCallback"; import {isUsingOidcAuth} from "./utils/OidcUtils"; const App = (props) => { return + + ; diff --git a/js/components/misc/oidc/OidcAuthWrapper.js b/js/components/misc/oidc/OidcAuthWrapper.js index e0db02c2..06045b9c 100644 --- a/js/components/misc/oidc/OidcAuthWrapper.js +++ b/js/components/misc/oidc/OidcAuthWrapper.js @@ -1,6 +1,5 @@ import React, {useCallback, useEffect, useState,} from "react"; -import {UserManager} from "oidc-client"; -import {generateRedirectUri, getOidcConfig} from "../../../utils/OidcUtils"; +import {generateRedirectUri, getUserManager} from "../../../utils/OidcUtils"; // Taken from https://github.com/datagov-cz/assembly-line-shared but using a different config processing mechanism @@ -15,15 +14,6 @@ const useThrow = () => { ); }; -// Singleton UserManager instance -let userManager; -const getUserManager = () => { - if (!userManager) { - userManager = new UserManager(getOidcConfig()); - } - return userManager; -}; - /** * Context provider for user data and logout action trigger */ diff --git a/js/components/pages/OidcSignInCallback.js b/js/components/pages/OidcSignInCallback.js new file mode 100644 index 00000000..631e8ede --- /dev/null +++ b/js/components/pages/OidcSignInCallback.js @@ -0,0 +1,23 @@ +import React from "react"; +import {getUserManager} from "../../utils/OidcUtils"; + +export default class AuthenticationCallback extends React.Component { + constructor(props) { + super(props); + } + + componentDidMount() { + const searchParams = new URLSearchParams(location.search); + if (!searchParams.has("forward_uri")) { + throw Error("Missing parameter forward_uri"); + } + const forwardUri = window.atob(searchParams.get("forward_uri")); + getUserManager().signinRedirectCallback().then(() => { + window.location.replace(forwardUri); + }); + } + + render() { + return

Redirecting...

; + } +} diff --git a/js/components/pages/OidcSilentCallback.js b/js/components/pages/OidcSilentCallback.js new file mode 100644 index 00000000..9b5d8a59 --- /dev/null +++ b/js/components/pages/OidcSilentCallback.js @@ -0,0 +1,16 @@ +import React from "react"; +import {getUserManager} from "../../utils/OidcUtils"; + +export default class AuthenticationSilentCallback extends React.Component { + constructor(props) { + super(props); + } + + componentDidMount() { + getUserManager().signinSilentCallback(); + } + + render() { + return

; + } +} diff --git a/js/oidc-signin-callback.html b/js/oidc-signin-callback.html deleted file mode 100644 index 07429ccb..00000000 --- a/js/oidc-signin-callback.html +++ /dev/null @@ -1,15 +0,0 @@ - - diff --git a/js/oidc-silent-callback.html b/js/oidc-silent-callback.html deleted file mode 100644 index 7b506781..00000000 --- a/js/oidc-silent-callback.html +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/js/utils/OidcUtils.js b/js/utils/OidcUtils.js index 8d0618da..659004f0 100644 --- a/js/utils/OidcUtils.js +++ b/js/utils/OidcUtils.js @@ -2,6 +2,16 @@ import {getEnv} from "../../config"; import Routes from "../constants/RoutesConstants"; +import {UserManager} from "oidc-client"; + +// Singleton UserManager instance +let userManager; +export const getUserManager = () => { + if (!userManager) { + userManager = new UserManager(getOidcConfig()); + } + return userManager; +}; /** * Base64 encoding helper @@ -24,10 +34,10 @@ export const getOidcConfig = () => { return { authority: getEnv("AUTH_SERVER_URL"), client_id: clientId, - redirect_uri: `${baseUrl}/oidc-signin-callback.html?forward_uri=${encodeForwardUri( + redirect_uri: `${baseUrl}/oidc-signin-callback?forward_uri=${encodeForwardUri( baseUrl )}`, - silent_redirect_uri: `${baseUrl}/oidc-silent-callback.html`, + silent_redirect_uri: `${baseUrl}/oidc-silent-callback`, post_logout_redirect_uri: `${baseUrl}#${Routes.logout.path}`, response_type: "code", loadUserInfo: true, @@ -45,7 +55,7 @@ function resolveUrl() { * Helper to generate redirect Uri */ export const generateRedirectUri = (forwardUri) => { - return `${resolveUrl()}/oidc-signin-callback.html?forward_uri=${encodeForwardUri( + return `${resolveUrl()}/oidc-signin-callback?forward_uri=${encodeForwardUri( forwardUri )}`; }; diff --git a/tests/jasmine.json b/tests/jasmine.json deleted file mode 100644 index 6ee85f01..00000000 --- a/tests/jasmine.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "spec_dir": "tests", - "spec_files": [ - "__tests__/*/*.js", - "__tests__/*.js" - ], - "helpers": [ - "../node_modules/@babel/register/lib/node.js", - "./setup.js", - "./setup-jasmine-env.js" - ] -} diff --git a/webpack.config.js b/webpack.config.js index 2de89831..5839e251 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -24,7 +24,10 @@ module.exports = ( const {ifProd, ifNotProd} = getIfUtils(env); const isStatic = process.env.STATIC const isForceBasename = process.env.FORCE_BASENAME; - const basename = process.env.RECORD_MANAGER_BASENAME; + let basename = process.env.RECORD_MANAGER_BASENAME || ""; + if (basename.charAt(basename.length - 1) === "/") { + basename = basename.substring(0, basename.length - 1); + } const version = process.env.npm_package_version; const appInfo = process.env.RECORD_MANAGER_APP_INFO; @@ -36,7 +39,7 @@ module.exports = ( filename: ifProd('bundle.[name].[chunkhash].js', 'bundle.[name].js'), chunkFilename: '[name].[chunkhash].js', path: isStatic ? resolve(`../../../target/record-manager-${version}/`) : resolve('build/'), - publicPath: (isStatic || isForceBasename) ? basename : "", + publicPath: (isStatic || isForceBasename) ? `${basename}/` : "", }, resolve: { extensions: ['.js', '.jsx', '.json'] @@ -107,20 +110,11 @@ module.exports = ( year: new Date().getFullYear(), title: appTitle, template: 'index.html', - filename: 'index.html', inject: true, minify: true, basename: (isStatic || isForceBasename) ? basename : "", appInfo: appInfo }), - new HtmlWebpackPlugin({ - template: 'oidc-signin-callback.html', - filename: 'oidc-signin-callback.html' - }), - new HtmlWebpackPlugin({ - template: 'oidc-silent-callback.html', - filename: 'oidc-silent-callback.html' - }), new InlineManifestWebpackPlugin(), new webpack.DefinePlugin({ From f89b2d3c49afc16b3e77ea62878358bf674140f4 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 26 Oct 2023 13:35:34 +0200 Subject: [PATCH 08/31] [OIDC] Ensure path resolution works in OidcMainView. --- js/components/MainView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/components/MainView.js b/js/components/MainView.js index 041559f9..20c8efca 100644 --- a/js/components/MainView.js +++ b/js/components/MainView.js @@ -13,7 +13,7 @@ import {ACTION_STATUS, ROLE} from "../constants/DefaultConstants"; import {loadUserProfile} from "../actions/AuthActions"; import * as Constants from "../constants/DefaultConstants"; import {LoaderMask} from "./Loader"; -import {NavLink} from 'react-router-dom'; +import {NavLink, withRouter} from 'react-router-dom'; class MainView extends React.Component { constructor(props) { @@ -127,7 +127,7 @@ class MainView extends React.Component { } } -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(withI18n(MainView))); +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(withI18n(withRouter(MainView)))); function mapStateToProps(state) { return { From a88b5e41e761580c4101340c3b5cb82dda7f0eb8 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 26 Oct 2023 13:48:23 +0200 Subject: [PATCH 09/31] [OIDC] Move user profile load into MainView to prevent reloads on OIDC authentication. --- js/components/MainView.js | 3 ++- js/index.js | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/js/components/MainView.js b/js/components/MainView.js index 20c8efca..7a4804b7 100644 --- a/js/components/MainView.js +++ b/js/components/MainView.js @@ -22,6 +22,7 @@ class MainView extends React.Component { } componentDidMount() { + this.props.loadUserProfile(); I18nStore.setIntl(this.props.intl); } @@ -53,7 +54,7 @@ class MainView extends React.Component { return (

{unauthRoutes}
); } const user = this.props.user; - const name = user.firstName.substr(0, 1) + '. ' + user.lastName; + const name = user.firstName.substring(0, 1) + '. ' + user.lastName; const path = this.props.location.pathname; return ( diff --git a/js/index.js b/js/index.js index ce2cd283..ac03e693 100644 --- a/js/index.js +++ b/js/index.js @@ -7,7 +7,6 @@ import reduxThunk from "redux-thunk"; import {applyMiddleware, createStore} from 'redux'; import {composeWithDevTools} from 'redux-devtools-extension/developmentOnly'; import rootReducer from "./reducers"; -import {loadUserProfile} from "./actions/AuthActions"; import {errorLogger, historyLogger} from "./utils/HistoryLogger"; import App from './App'; @@ -24,8 +23,6 @@ window.onerror = (msg, source, line) => { return false; }; -store.dispatch(loadUserProfile()); - render( From cfc82503eb5e3699ce5554ef55c1e3096640ad62 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 26 Oct 2023 14:34:06 +0200 Subject: [PATCH 10/31] [Ref] Simplify MainView component authorization. Remove obsolete web.xml. --- WEB-INF/web.xml | 13 --------- js/components/MainView.js | 46 +++++++++++++++++--------------- package-lock.json | 55 ++++++++++++++++++++++++++++++++------- package.json | 1 + 4 files changed, 71 insertions(+), 44 deletions(-) delete mode 100644 WEB-INF/web.xml diff --git a/WEB-INF/web.xml b/WEB-INF/web.xml deleted file mode 100644 index bbea30b5..00000000 --- a/WEB-INF/web.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - Record Manager - - - 404 - /index.html - - - diff --git a/js/components/MainView.js b/js/components/MainView.js index 7a4804b7..1ac99916 100644 --- a/js/components/MainView.js +++ b/js/components/MainView.js @@ -14,6 +14,7 @@ import {loadUserProfile} from "../actions/AuthActions"; import * as Constants from "../constants/DefaultConstants"; import {LoaderMask} from "./Loader"; import {NavLink, withRouter} from 'react-router-dom'; +import {IfGranted} from "react-authorization"; class MainView extends React.Component { constructor(props) { @@ -29,11 +30,12 @@ class MainView extends React.Component { _renderUsers() { const path = this.props.location.pathname; - return this.props.user.role === ROLE.ADMIN ? + return path.startsWith(Routes.users.path)} className="nav-link">{this.i18n('main.users-nav')} - : null + + ; } removeUnsupportedBrowserWarning() { @@ -86,25 +88,27 @@ class MainView extends React.Component { : null } - {user.role === ROLE.ADMIN && - path.startsWith(Routes.records.path)} - to={Routes.records.path}>{this.i18n('main.records-nav')} - - } - {user.role === ROLE.ADMIN && - - path.startsWith(Routes.statistics.path)} - to={Routes.statistics.path}>{this.i18n('statistics.panel-title')} - - } - {user.role === ROLE.ADMIN && - - path.startsWith(Routes.historyActions.path)} - to={Routes.historyActions.path}>{this.i18n('main.history')} - } + + + path.startsWith(Routes.records.path)} + to={Routes.records.path}>{this.i18n('main.records-nav')} + + + + + path.startsWith(Routes.statistics.path)} + to={Routes.statistics.path}>{this.i18n('statistics.panel-title')} + + + + + path.startsWith(Routes.historyActions.path)} + to={Routes.historyActions.path}>{this.i18n('main.history')} + +