From e5953fb02a91d4d335bebb9bd4598a90d8a98499 Mon Sep 17 00:00:00 2001 From: Yury Saukou Date: Thu, 14 Mar 2024 12:35:20 +0400 Subject: [PATCH 01/63] STCOR-769 Utilize the 'tenant' procured through the SSO login process (#1415) To support Single Sign-On (SSO) authorization in consortium mode, it's necessary to explicitly pass the tenant in the request header to load data. (cherry picked from commit d8db8b79589f7522a4ba0bc756350708139606f9) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a468d2f25..022b5a105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 10.2.0 IN PROGRESS +## [10.1.1](https://github.com/folio-org/stripes-core/tree/v10.1.1) (2024-03-25) +[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.0...v10.1.1) + * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Remove tag-based selectors from Login, ResetPassword, Forgot UserName/Password form CSS. Refs STCOR-712. * Provide `useUserTenantPermissions` hook. Refs STCOR-830. From 2bea8b0674497961635431fbc7d7e260244afd8e Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Fri, 8 Dec 2023 14:22:12 -0500 Subject: [PATCH 02/63] leverage keycloak (authn) and kong (discovery) endpoints There's a lot going on here, but fundamentally the changes are split into two main categories: * route authentication requests to/from keycloak * handle discovery dynamically via an API request AFTER authentication instead of reading a static module list from `stripes.config.js` --- CHANGELOG.md | 1 + src/App.js | 5 +- src/RootWithIntl.js | 15 ++ src/components/About/About.css | 4 + src/components/About/About.js | 100 +++++--- .../ForgotPassword/ForgotPasswordCtrl.js | 7 +- .../ForgotUserName/ForgotUserNameCtrl.js | 7 +- src/components/Login/Login.js | 102 +++++--- src/components/Login/LoginForm.js | 228 ++++++++++++++++++ src/components/Login/index.js | 2 +- src/components/OIDCLanding.js | 111 +++++++++ src/components/OIDCRedirect.js | 32 +++ .../PreLoginLanding/PreLoginLanding.js | 71 ++++++ src/components/PreLoginLanding/index.css | 13 + src/components/PreLoginLanding/index.js | 1 + src/components/Redirect/Redirect.js | 27 +++ src/components/Redirect/Redirect.test.js | 45 ++++ src/components/Redirect/index.js | 1 + src/components/TitleManager/TitleManager.js | 14 +- src/components/index.js | 2 + src/discoverServices.js | 195 +++++++++++---- src/loginServices.js | 94 +++++--- src/okapiActions.js | 8 + src/okapiReducer.js | 4 + translations/stripes-core/en.json | 8 +- translations/stripes-core/en_US.json | 10 +- 26 files changed, 945 insertions(+), 162 deletions(-) create mode 100644 src/components/Login/LoginForm.js create mode 100644 src/components/OIDCLanding.js create mode 100644 src/components/OIDCRedirect.js create mode 100644 src/components/PreLoginLanding/PreLoginLanding.js create mode 100644 src/components/PreLoginLanding/index.css create mode 100644 src/components/PreLoginLanding/index.js create mode 100644 src/components/Redirect/Redirect.js create mode 100644 src/components/Redirect/Redirect.test.js create mode 100644 src/components/Redirect/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 022b5a105..54c511384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Provide `useUserTenantPermissions` hook. Refs STCOR-830. * Load DayJS locale data as part of `loginServices`. STCOR-771. * Turn on ``; ignore it with `stripes.config.js` `disableStrictMode: true`. Refs STCOR-841. +* Use keycloak URLs in place of users-bl for tenant-switch. Refs US1153537. * Make branding optional. Refs STCOR-847. * Idle-session timeout and "Keep working?" modal. Refs STCOR-776. * Implement password validation for Login Page. Refs STCOR-741. diff --git a/src/App.js b/src/App.js index ee22c1754..4ce6130fc 100644 --- a/src/App.js +++ b/src/App.js @@ -33,8 +33,11 @@ export default class StripesCore extends Component { constructor(props) { super(props); + const storedTenant = localStorage.getItem('tenant'); + const parsedTenant = storedTenant ? JSON.parse(storedTenant) : undefined; + const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) - ? okapiConfig : { withoutOkapi: true }; + ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId } : { withoutOkapi: true }; const initialState = merge({}, { okapi }, props.initialState); diff --git a/src/RootWithIntl.js b/src/RootWithIntl.js index 8658fd941..d95c56674 100644 --- a/src/RootWithIntl.js +++ b/src/RootWithIntl.js @@ -20,6 +20,8 @@ import { ModuleTranslator, TitledRoute, Front, + OIDCRedirect, + OIDCLanding, SSOLanding, SSORedirect, Settings, @@ -88,6 +90,12 @@ const RootWithIntl = ({ stripes, token = '', isAuthenticated = false, disableAut key="sso-landing" component={} /> + } + /> } key="sso-landing" /> + } + key="oidc-landing" + /> { ); } - const modules = _.get(props.stripes, ['discovery', 'modules']) || {}; + const applications = + _.get(props.stripes, ['discovery', 'applications']) || {}; const interfaces = _.get(props.stripes, ['discovery', 'interfaces']) || {}; const isLoadingFinished = _.get(props.stripes, ['discovery', 'isFinished']); - const nm = Object.keys(modules).length; - const ni = Object.keys(interfaces).length; - const ConnectedAboutEnabledModules = props.stripes.connect(AboutEnabledModules); + const na = Object.keys(applications).length; + const unknownMsg = ; - const numModulesMsg = ; - const numInterfacesMsg = ; + const numApplicationsMsg = ( + + ); + + const renderInterfaces = (list) => { + return ( +
  • {item.name}
  • } + /> + ); + }; + const renderModules = (list) => { + return ( + { + return ( +
  • + + {item.name} + + {renderInterfaces(item.interfaces)} +
  • + ); + }} + /> + ); + }; return ( { )}
    +
    + + + + {numApplicationsMsg} + {Object.values(applications) + .map((app) => { + return ( +
      +
    • + {app.name} + {renderModules(app.modules)} +
    • +
    + ); + })} +
    +
    @@ -165,16 +216,14 @@ const About = (props) => { ]} itemFormatter={item => (
  • {item.value}
  • )} /> +
    -
    - {Object.keys(props.modules).map(key => listModules(key, props.modules[key]))} -
    -
    -
    - Okapi + + + (
  • {item}
  • )} @@ -185,31 +234,6 @@ const About = (props) => { ]} />
    - {numModulesMsg} - - - - - {chunks} - }} - /> -
    - {numInterfacesMsg} - ( -
  • - {`${key} ${interfaces[key]}`} -
  • - )} - /> -
    -
    diff --git a/src/components/ForgotPassword/ForgotPasswordCtrl.js b/src/components/ForgotPassword/ForgotPasswordCtrl.js index fbe46e7e0..7223a007f 100644 --- a/src/components/ForgotPassword/ForgotPasswordCtrl.js +++ b/src/components/ForgotPassword/ForgotPasswordCtrl.js @@ -33,7 +33,12 @@ class ForgotPasswordCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/password', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/password'; + } + return 'bl-users/forgotten/password'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/ForgotUserName/ForgotUserNameCtrl.js b/src/components/ForgotUserName/ForgotUserNameCtrl.js index 44ddc5717..007b09246 100644 --- a/src/components/ForgotUserName/ForgotUserNameCtrl.js +++ b/src/components/ForgotUserName/ForgotUserNameCtrl.js @@ -34,7 +34,12 @@ class ForgotUserNameCtrl extends Component { static manifest = Object.freeze({ searchUsername: { type: 'okapi', - path: 'bl-users/forgotten/username', + path: (queryParams, pathComponents, resourceData, config, props) => { + if (props.stripes.okapi.authnUrl) { + return 'users-keycloak/forgotten/username'; + } + return 'bl-users/forgotten/username'; + }, headers: { 'accept': '*/*', }, diff --git a/src/components/Login/Login.js b/src/components/Login/Login.js index 63e91b612..56c045597 100644 --- a/src/components/Login/Login.js +++ b/src/components/Login/Login.js @@ -1,45 +1,72 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { Field, Form } from 'react-final-form'; - -import { branding } from 'stripes-config'; - +import { connect as reduxConnect } from 'react-redux'; import { - TextField, - Button, - Row, - Col, - Headline, -} from '@folio/stripes-components'; + withRouter, + matchPath, +} from 'react-router-dom'; -import SSOLogin from '../SSOLogin'; -import OrganizationLogo from '../OrganizationLogo'; -import AuthErrorsContainer from '../AuthErrorsContainer'; -import FieldLabel from '../CreateResetPassword/components/FieldLabel'; - -import styles from './Login.css'; +import { ConnectContext } from '@folio/stripes-connect'; +import { + requestLogin, + requestSSOLogin, +} from '../../loginServices'; +import { setAuthError } from '../../okapiActions'; -class Login extends Component { +class LoginCtrl extends Component { static propTypes = { - ssoActive: PropTypes.bool, - authErrors: PropTypes.arrayOf(PropTypes.object), - onSubmit: PropTypes.func.isRequired, - handleSSOLogin: PropTypes.func.isRequired, + authFailure: PropTypes.arrayOf(PropTypes.object), + ssoEnabled: PropTypes.bool, + autoLogin: PropTypes.shape({ + username: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + }), + clearAuthErrors: PropTypes.func.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + }).isRequired, }; - static defaultProps = { - authErrors: [], - ssoActive: false, - }; + static contextType = ConnectContext; + + constructor(props) { + super(props); + this.sys = require('stripes-config'); // eslint-disable-line global-require + this.authnUrl = this.sys.okapi.authnUrl; + this.okapiUrl = this.sys.okapi.url; + this.tenant = this.sys.okapi.tenant; + if (props.autoLogin && props.autoLogin.username) { + this.handleSubmit(props.autoLogin); + } + } + + componentWillUnmount() { + this.props.clearAuthErrors(); + } + + handleSuccessfulLogin = () => { + if (matchPath(this.props.location.pathname, '/login')) { + this.props.history.push('/'); + } + } + + handleSubmit = (data) => { + return requestLogin({ okapi: this.sys.okapi }, this.context.store, this.tenant, data) + .then(this.handleSuccessfulLogin) + .catch(e => { + console.error(e); // eslint-disable-line no-console + }); + } + + handleSSOLogin = () => { + requestSSOLogin(this.okapiUrl, this.tenant); + } render() { - const { - authErrors, - handleSSOLogin, - ssoActive, - onSubmit, - } = this.props; + const { authFailure, ssoEnabled } = this.props; const cookieMessage = navigator.cookieEnabled ? '' : @@ -56,6 +83,7 @@ class Login extends Component { ); return ( +<<<<<<< HEAD
    ({ + authFailure: state.okapi.authFailure, + ssoEnabled: state.okapi.ssoEnabled, +}); +const mapDispatchToProps = dispatch => ({ + clearAuthErrors: () => dispatch(setAuthError([])), +}); + +export default reduxConnect(mapStateToProps, mapDispatchToProps)(withRouter(LoginCtrl)); diff --git a/src/components/Login/LoginForm.js b/src/components/Login/LoginForm.js new file mode 100644 index 000000000..2dcab4c46 --- /dev/null +++ b/src/components/Login/LoginForm.js @@ -0,0 +1,228 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field, Form } from 'react-final-form'; + +import { branding } from 'stripes-config'; + +import { + TextField, + Button, + Row, + Col, + Headline, +} from '@folio/stripes-components'; + +import SSOLogin from '../SSOLogin'; +import OrganizationLogo from '../OrganizationLogo'; +import AuthErrorsContainer from '../AuthErrorsContainer'; +import FieldLabel from '../CreateResetPassword/components/FieldLabel'; + +import styles from './Login.css'; + +class LoginForm extends Component { + static propTypes = { + ssoActive: PropTypes.bool, + authErrors: PropTypes.arrayOf(PropTypes.object), + onSubmit: PropTypes.func.isRequired, + handleSSOLogin: PropTypes.func.isRequired, + }; + + static defaultProps = { + authErrors: [], + ssoActive: false, + }; + + render() { + const { + authErrors, + handleSSOLogin, + ssoActive, + onSubmit, + } = this.props; + + return ( + { + const { username } = values; + const submissionStatus = submitting || submitSucceeded; + const buttonDisabled = submissionStatus || !(username); + const buttonLabel = submissionStatus ? 'loggingIn' : 'login'; + return ( +
    +
    +
    + + + + + + + handleSubmit(data).then(() => form.change('password', undefined))} + > + + +
    + {ssoActive && } +
    + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + + +
    + + +
    + +
    + +
    + + + + + + + + + + + + + + +
    + +
    + +
    + + {ssoActive &&
    } + +
    +
    +
    + ); + }} + /> + ); + } +} + +export default LoginForm; diff --git a/src/components/Login/index.js b/src/components/Login/index.js index 44e798405..2a741cdbd 100644 --- a/src/components/Login/index.js +++ b/src/components/Login/index.js @@ -1 +1 @@ -export { default } from './LoginCtrl'; +export { default } from './Login'; diff --git a/src/components/OIDCLanding.js b/src/components/OIDCLanding.js new file mode 100644 index 000000000..4f1a716ef --- /dev/null +++ b/src/components/OIDCLanding.js @@ -0,0 +1,111 @@ +import { debounce } from 'lodash'; +import React, { useEffect, useRef } from 'react'; +import { useLocation, Redirect } from 'react-router-dom'; +import queryString from 'query-string'; +import { useStore } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; + +import { Loading } from '@folio/stripes-components'; + +import { requestUserWithPerms } from '../loginServices'; + +import css from './Front.css'; +import { useStripes } from '../StripesContext'; + +const requestUserWithPermsDeb = debounce(requestUserWithPerms, 5000, { leading: true, trailing: false }); + +/** + * OIDCLanding: un-authenticated route handler for /sso-landing. + * + * Reads one-time-code from URL params, exchanging it for an access_token + * and then leveraging that to retrieve a user via requestUserWithPerms, + * eventually dispatching session and Okapi-ready, resulting in a + * re-render with a token present, i.e., authenticated. + * + * @see RootWithIntl + */ +const OIDCLanding = () => { + const location = useLocation(); + const store = useStore(); + const samlError = useRef(); + const { okapi } = useStripes(); + + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + /** + * retrieve the OTP + * @returns {string} + */ + const getOtp = () => { + return getParams()?.code; + }; + + const otp = getOtp(); + + /** + * Exchange the otp for an access token, then use it to retrieve + * the user + * + * See https://ebscoinddev.atlassian.net/wiki/spaces/TEUR/pages/12419306/mod-login-keycloak#mod-login-keycloak-APIs + * for additional details. May not be necessary for SAML-specific pages + * to exist since the workflow is the same for SSO. We can just inspect + * the response for SSO-y values or SAML-y values and act accordingly. + */ + useEffect(() => { + if (otp) { + fetch(`${okapi.url}/authn/token?code=${otp}&redirect-uri=${window.location.protocol}//${window.location.host}/oidc-landing`, { + headers: { 'X-Okapi-tenant': okapi.tenant, 'Content-Type': 'application/json' }, + }) + .then((resp) => { + if (resp.ok) { + return resp.json().then((json) => { + return requestUserWithPermsDeb(okapi.url, store, okapi.tenant, json.okapiToken); + }); + } else { + return resp.json().then((error) => { + throw error; + }); + } + }) + .catch(e => { + console.error('@@ Oh, snap, OTP exchange failed!', e); + samlError.current = e; + }); + } + }, [otp, store]); + + if (!otp) { + return ( +
    +
    + +
    +
    + + {JSON.stringify(samlError.current, null, 2)} + +
    + +
    + ); + } + + return ( +
    +
    + +
    +
    +
    +          {JSON.stringify(samlError.current, null, 2)}
    +        
    +
    +
    + ); +}; + +export default OIDCLanding; diff --git a/src/components/OIDCRedirect.js b/src/components/OIDCRedirect.js new file mode 100644 index 000000000..3e6585de2 --- /dev/null +++ b/src/components/OIDCRedirect.js @@ -0,0 +1,32 @@ +import { withRouter, Redirect, useLocation } from 'react-router'; +import queryString from 'query-string'; + +/** + * OIDCRedirect authenticated route handler for /oidc-landing. + * + * Reads `fwd` from URL params and redirects. + * + * @see RootWithIntl + * + * @returns {Redirect} + */ +const OIDCRedirect = () => { + const location = useLocation(); + + const getParams = () => { + const search = location.search; + if (!search) return undefined; + return queryString.parse(search) || {}; + }; + + const getUrl = () => { + const params = getParams(); + return params?.fwd ?? ''; + }; + + return ( + + ); +}; + +export default withRouter(OIDCRedirect); diff --git a/src/components/PreLoginLanding/PreLoginLanding.js b/src/components/PreLoginLanding/PreLoginLanding.js new file mode 100644 index 000000000..eb47719f1 --- /dev/null +++ b/src/components/PreLoginLanding/PreLoginLanding.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { Button, Select, Col, Row } from '@folio/stripes-components'; +import { useIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import { OrganizationLogo } from '../index'; +import styles from './index.css'; +import { useStripes } from '../../StripesContext'; + +function PreLoginLanding({ onSelectTenant }) { + const intl = useIntl(); + const { okapi, config: { tenantOptions = {} } } = useStripes(); + + const redirectUri = `${window.location.protocol}//${window.location.host}/oidc-landing`; + const options = Object.keys(tenantOptions).map(tenantName => ({ value: tenantName, label: tenantName })); + + const getLoginUrl = () => { + if (!okapi.tenant) return ''; + if (okapi.authnUrl) { + return `${okapi.authnUrl}/realms/${okapi.tenant}/protocol/openid-connect/auth?client_id=${okapi.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid`; + } + return ''; + }; + + const handleChangeTenant = (e) => { + const tenantName = e.target.value; + if (tenantName === '') { + onSelectTenant('', ''); + return; + } + const clientId = tenantOptions[tenantName].clientId; + onSelectTenant(tenantName, clientId); + }; + + return ( +
    +
    +
    + + + + + + + +