diff --git a/client/package-lock.json b/client/package-lock.json index 33a2ea2b..63bb66c1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "node-sass": "^8.0.0", + "oidc-client-ts": "^2.3.0", "photoswipe": "^5.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -30,7 +31,6 @@ "react-i18next": "^12.0.0", "react-input-mask": "^2.0.4", "react-markdown": "^8.0.3", - "react-oidc-context": "^2.2.2", "react-photoswipe-gallery": "^2.2.2", "react-redux": "^8.0.5", "react-router-dom": "^6.4.3", @@ -7193,8 +7193,7 @@ "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==", - "peer": true + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, "node_modules/crypto-random-string": { "version": "2.0.0", @@ -17043,10 +17042,9 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, "node_modules/oidc-client-ts": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz", - "integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==", - "peer": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz", + "integrity": "sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA==", "dependencies": { "crypto-js": "^4.1.1", "jwt-decode": "^3.1.2" @@ -19296,18 +19294,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, - "node_modules/react-oidc-context": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz", - "integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==", - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "oidc-client-ts": "^2.2.1", - "react": ">=16.8.0" - } - }, "node_modules/react-onclickoutside": { "version": "6.12.2", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz", @@ -28802,8 +28788,7 @@ "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==", - "peer": true + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" }, "crypto-random-string": { "version": "2.0.0", @@ -35972,10 +35957,9 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" }, "oidc-client-ts": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz", - "integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==", - "peer": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.3.0.tgz", + "integrity": "sha512-7RUKU+TJFQo+4X9R50IGJAIDF18uRBaFXyZn4VVCfwmwbSUhKcdDnw4zgeut3uEXkiD3NqURq+d88sDPxjf1FA==", "requires": { "crypto-js": "^4.1.1", "jwt-decode": "^3.1.2" @@ -37419,12 +37403,6 @@ } } }, - "react-oidc-context": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz", - "integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==", - "requires": {} - }, "react-onclickoutside": { "version": "6.12.2", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz", diff --git a/client/package.json b/client/package.json index ad8303bb..1a970362 100755 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,7 @@ "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "node-sass": "^8.0.0", + "oidc-client-ts": "^2.3.0", "photoswipe": "^5.3.3", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -77,7 +78,6 @@ "react-i18next": "^12.0.0", "react-input-mask": "^2.0.4", "react-markdown": "^8.0.3", - "react-oidc-context": "^2.2.2", "react-photoswipe-gallery": "^2.2.2", "react-redux": "^8.0.5", "react-router-dom": "^6.4.3", diff --git a/client/src/actions/core.js b/client/src/actions/core.js index bed09c3d..81dbbba0 100644 --- a/client/src/actions/core.js +++ b/client/src/actions/core.js @@ -1,6 +1,7 @@ import ActionTypes from '../constants/ActionTypes'; const initializeCore = ( + config, user, board, users, @@ -20,6 +21,7 @@ const initializeCore = ( ) => ({ type: ActionTypes.CORE_INITIALIZE, payload: { + config, user, board, users, diff --git a/client/src/actions/login.js b/client/src/actions/login.js index 8ffcded1..d7304c27 100644 --- a/client/src/actions/login.js +++ b/client/src/actions/login.js @@ -1,5 +1,12 @@ import ActionTypes from '../constants/ActionTypes'; +const initializeLogin = (config) => ({ + type: ActionTypes.LOGIN_INITIALIZE, + payload: { + config, + }, +}); + const authenticate = (data) => ({ type: ActionTypes.AUTHENTICATE, payload: { @@ -21,12 +28,33 @@ authenticate.failure = (error) => ({ }, }); +const authenticateWithOidc = () => ({ + type: ActionTypes.WITH_OIDC_AUTHENTICATE, + payload: {}, +}); + +authenticateWithOidc.success = (accessToken) => ({ + type: ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS, + payload: { + accessToken, + }, +}); + +authenticateWithOidc.failure = (error) => ({ + type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE, + payload: { + error, + }, +}); + const clearAuthenticateError = () => ({ type: ActionTypes.AUTHENTICATE_ERROR_CLEAR, payload: {}, }); export default { + initializeLogin, authenticate, + authenticateWithOidc, clearAuthenticateError, }; diff --git a/client/src/api/access-tokens.js b/client/src/api/access-tokens.js index 0a1dcf94..2c538272 100755 --- a/client/src/api/access-tokens.js +++ b/client/src/api/access-tokens.js @@ -4,14 +4,15 @@ import socket from './socket'; /* Actions */ const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); -const exchangeOidcToken = (accessToken, headers) => - http.post('/access-tokens/exchange', { token: accessToken }, headers); + +const exchangeToAccessToken = (data, headers) => + http.post('/access-tokens/exchange', data, headers); const deleteCurrentAccessToken = (headers) => socket.delete('/access-tokens/me', undefined, headers); export default { createAccessToken, + exchangeToAccessToken, deleteCurrentAccessToken, - exchangeOidcToken, }; diff --git a/client/src/api/http.js b/client/src/api/http.js index 646528da..2119de2b 100755 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -5,13 +5,15 @@ import Config from '../constants/Config'; const http = {}; // TODO: add all methods -['POST'].forEach((method) => { +['GET', 'POST'].forEach((method) => { http[method.toLowerCase()] = (url, data, headers) => { - const formData = Object.keys(data).reduce((result, key) => { - result.append(key, data[key]); + const formData = + data && + Object.keys(data).reduce((result, key) => { + result.append(key, data[key]); - return result; - }, new FormData()); + return result; + }, new FormData()); return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { method, diff --git a/client/src/api/index.js b/client/src/api/index.js index fd9db7c0..dc4ed4d7 100755 --- a/client/src/api/index.js +++ b/client/src/api/index.js @@ -1,5 +1,6 @@ import http from './http'; import socket from './socket'; +import root from './root'; import accessTokens from './access-tokens'; import users from './users'; import projects from './projects'; @@ -20,6 +21,7 @@ import notifications from './notifications'; export { http, socket }; export default { + ...root, ...accessTokens, ...users, ...projects, diff --git a/client/src/api/root.js b/client/src/api/root.js new file mode 100644 index 00000000..4dfde4c0 --- /dev/null +++ b/client/src/api/root.js @@ -0,0 +1,9 @@ +import http from './http'; + +/* Actions */ + +const getConfig = (headers) => http.get('/config', undefined, headers); + +export default { + getConfig, +}; diff --git a/client/src/components/Header/Header.jsx b/client/src/components/Header/Header.jsx index 0bb246e0..443d3a88 100755 --- a/client/src/components/Header/Header.jsx +++ b/client/src/components/Header/Header.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; import { Button, Icon, Menu } from 'semantic-ui-react'; -import { useAuth } from 'react-oidc-context'; import { usePopup } from '../../lib/popup'; import Paths from '../../constants/Paths'; @@ -30,7 +29,6 @@ const Header = React.memo( onUserSettingsClick, onLogout, }) => { - const auth = useAuth(); const handleProjectSettingsClick = useCallback(() => { if (canEditProject) { onProjectSettingsClick(); @@ -40,11 +38,6 @@ const Header = React.memo( const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS); const UserPopup = usePopup(UserStep, POPUP_PROPS); - const onFullLogout = () => { - auth.signoutSilent(); - onLogout(); - }; - return (
{!project && ( @@ -95,7 +88,7 @@ const Header = React.memo( {user.name} diff --git a/client/src/components/Login/Login.jsx b/client/src/components/Login/Login.jsx index ce09c3d4..2b380a8e 100755 --- a/client/src/components/Login/Login.jsx +++ b/client/src/components/Login/Login.jsx @@ -1,10 +1,9 @@ import isEmail from 'validator/lib/isEmail'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useAuth } from 'react-oidc-context'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { Form, Grid, Header, Message } from 'semantic-ui-react'; +import { Button, Form, Grid, Header, Message } from 'semantic-ui-react'; import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks'; import { Input } from '../../lib/custom-ui'; @@ -29,6 +28,21 @@ const createMessage = (error) => { type: 'error', content: 'common.invalidPassword', }; + case 'Use single sign-on': + return { + type: 'error', + content: 'common.useSingleSignOn', + }; + case 'Email already in use': + return { + type: 'error', + content: 'common.emailAlreadyInUse', + }; + case 'Username already in use': + return { + type: 'error', + content: 'common.usernameAlreadyInUse', + }; case 'Failed to fetch': return { type: 'warning', @@ -48,8 +62,16 @@ const createMessage = (error) => { }; const Login = React.memo( - ({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => { - const auth = useAuth(); + ({ + defaultData, + isSubmitting, + isSubmittingWithOidc, + error, + withOidc, + onAuthenticate, + onAuthenticateWithOidc, + onMessageDismiss, + }) => { const [t] = useTranslation(); const wasSubmitting = usePrevious(isSubmitting); @@ -170,12 +192,19 @@ const Login = React.memo( content={t('action.logIn')} floated="right" loading={isSubmitting} - disabled={isSubmitting} + disabled={isSubmitting || isSubmittingWithOidc} /> - auth.signinRedirect()}> - Log in with SSO - + {withOidc && ( + + )}
@@ -206,10 +235,15 @@ const Login = React.memo( ); Login.propTypes = { - defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + /* eslint-disable react/forbid-prop-types */ + defaultData: PropTypes.object.isRequired, + /* eslint-enable react/forbid-prop-types */ isSubmitting: PropTypes.bool.isRequired, + isSubmittingWithOidc: PropTypes.bool.isRequired, error: PropTypes.object, // eslint-disable-line react/forbid-prop-types + withOidc: PropTypes.bool.isRequired, onAuthenticate: PropTypes.func.isRequired, + onAuthenticateWithOidc: PropTypes.func.isRequired, onMessageDismiss: PropTypes.func.isRequired, }; diff --git a/client/src/components/LoginWrapper.jsx b/client/src/components/LoginWrapper.jsx new file mode 100644 index 00000000..f437fdf8 --- /dev/null +++ b/client/src/components/LoginWrapper.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Loader } from 'semantic-ui-react'; + +import LoginContainer from '../containers/LoginContainer'; + +const LoginWrapper = React.memo(({ isInitializing }) => { + if (isInitializing) { + return ; + } + + return ; +}); + +LoginWrapper.propTypes = { + isInitializing: PropTypes.bool.isRequired, +}; + +export default LoginWrapper; diff --git a/client/src/components/OIDC/OidcLogin.jsx b/client/src/components/OIDC/OidcLogin.jsx deleted file mode 100644 index 9ccd5e8f..00000000 --- a/client/src/components/OIDC/OidcLogin.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useAuth } from 'react-oidc-context'; -import PropTypes from 'prop-types'; -import React from 'react'; - -let isLoggingIn = true; -const OidcLogin = React.memo(({ onAuthenticate }) => { - const auth = useAuth(); - if (isLoggingIn && auth.user) { - isLoggingIn = false; - const { user } = auth; - onAuthenticate(user); - } -}); - -OidcLogin.propTypes = { - onAuthenticate: PropTypes.func.isRequired, -}; - -export default OidcLogin; diff --git a/client/src/components/OIDC/index.js b/client/src/components/OIDC/index.js deleted file mode 100644 index ef357747..00000000 --- a/client/src/components/OIDC/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import OidcLogin from './OidcLogin'; - -export default OidcLogin; diff --git a/client/src/components/Root.jsx b/client/src/components/Root.jsx index d59b47d3..630a20d0 100755 --- a/client/src/components/Root.jsx +++ b/client/src/components/Root.jsx @@ -1,12 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { AuthProvider } from 'react-oidc-context'; import { Provider } from 'react-redux'; import { Route, Routes } from 'react-router-dom'; import { ReduxRouter } from '../lib/redux-router'; import Paths from '../constants/Paths'; -import LoginContainer from '../containers/LoginContainer'; +import LoginWrapperContainer from '../containers/LoginWrapperContainer'; import CoreContainer from '../containers/CoreContainer'; import NotFound from './NotFound'; @@ -15,40 +14,29 @@ import 'photoswipe/dist/photoswipe.css'; import 'easymde/dist/easymde.min.css'; import '../lib/custom-ui/styles.css'; import '../styles.module.scss'; -import OidcLoginContainer from '../containers/OidcLoginContainer'; -function Root({ store, history, config }) { +function Root({ store, history }) { return ( - { - window.history.replaceState({}, document.title, window.location.pathname); - }} - > - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } + Root.propTypes = { /* eslint-disable react/forbid-prop-types */ store: PropTypes.object.isRequired, history: PropTypes.object.isRequired, - config: PropTypes.object.isRequired, /* eslint-enable react/forbid-prop-types */ }; diff --git a/client/src/components/UserSettingsModal/UserSettingsModal.jsx b/client/src/components/UserSettingsModal/UserSettingsModal.jsx index 6cc0b826..fc8ac9c7 100644 --- a/client/src/components/UserSettingsModal/UserSettingsModal.jsx +++ b/client/src/components/UserSettingsModal/UserSettingsModal.jsx @@ -16,8 +16,8 @@ const UserSettingsModal = React.memo( phone, organization, language, - isLocked, subscribeToOwnCards, + isLocked, isAvatarUpdating, usernameUpdateForm, emailUpdateForm, @@ -106,8 +106,8 @@ UserSettingsModal.propTypes = { phone: PropTypes.string, organization: PropTypes.string, language: PropTypes.string, - isLocked: PropTypes.bool.isRequired, subscribeToOwnCards: PropTypes.bool.isRequired, + isLocked: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired, /* eslint-disable react/forbid-prop-types */ usernameUpdateForm: PropTypes.object.isRequired, diff --git a/client/src/components/UsersModal/Item/ActionsStep.jsx b/client/src/components/UsersModal/Item/ActionsStep.jsx index a97d13a8..88c089ac 100644 --- a/client/src/components/UsersModal/Item/ActionsStep.jsx +++ b/client/src/components/UsersModal/Item/ActionsStep.jsx @@ -153,13 +153,15 @@ const ActionsStep = React.memo( context: 'title', })} - - {t('action.deleteUser', { - context: 'title', - })} - )} + {!user.isLockedAdmin && ( + + {t('action.deleteUser', { + context: 'title', + })} + + )} diff --git a/client/src/components/UsersModal/Item/Item.jsx b/client/src/components/UsersModal/Item/Item.jsx index 0627baf6..da73c5c8 100755 --- a/client/src/components/UsersModal/Item/Item.jsx +++ b/client/src/components/UsersModal/Item/Item.jsx @@ -18,6 +18,7 @@ const Item = React.memo( phone, isAdmin, isLocked, + isLockedAdmin, emailUpdateForm, passwordUpdateForm, usernameUpdateForm, @@ -47,7 +48,7 @@ const Item = React.memo( {username || '-'} {email} - + { - const isCoreInitializing = selectors.selectIsCoreInitializing(state); + const isInitializing = selectors.selectIsInitializing(state); const isSocketDisconnected = selectors.selectIsSocketDisconnected(state); const currentModal = selectors.selectCurrentModal(state); const currentProject = selectors.selectCurrentProject(state); const currentBoard = selectors.selectCurrentBoard(state); return { + isInitializing, isSocketDisconnected, currentModal, currentProject, currentBoard, - isInitializing: isCoreInitializing, }; }; diff --git a/client/src/containers/LoginContainer.js b/client/src/containers/LoginContainer.js index af25d0d5..34f15fdd 100755 --- a/client/src/containers/LoginContainer.js +++ b/client/src/containers/LoginContainer.js @@ -1,23 +1,33 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import selectors from '../selectors'; import entryActions from '../entry-actions'; import Login from '../components/Login'; -const mapStateToProps = ({ - ui: { - authenticateForm: { data: defaultData, isSubmitting, error }, - }, -}) => ({ - defaultData, - isSubmitting, - error, -}); +const mapStateToProps = (state) => { + const oidcConfig = selectors.selectOidcConfig(state); + + const { + ui: { + authenticateForm: { data: defaultData, isSubmitting, isSubmittingWithOidc, error }, + }, + } = state; + + return { + defaultData, + isSubmitting, + isSubmittingWithOidc, + error, + withOidc: !!oidcConfig, + }; +}; const mapDispatchToProps = (dispatch) => bindActionCreators( { onAuthenticate: entryActions.authenticate, + onAuthenticateWithOidc: entryActions.authenticateWithOidc, onMessageDismiss: entryActions.clearAuthenticateError, }, dispatch, diff --git a/client/src/containers/LoginWrapperContainer.js b/client/src/containers/LoginWrapperContainer.js new file mode 100644 index 00000000..7bfa5923 --- /dev/null +++ b/client/src/containers/LoginWrapperContainer.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; + +import selectors from '../selectors'; +import LoginWrapper from '../components/LoginWrapper'; + +const mapStateToProps = (state) => { + const isInitializing = selectors.selectIsInitializing(state); + + return { + isInitializing, + }; +}; + +export default connect(mapStateToProps)(LoginWrapper); diff --git a/client/src/containers/OidcLoginContainer.js b/client/src/containers/OidcLoginContainer.js deleted file mode 100644 index 15083ad8..00000000 --- a/client/src/containers/OidcLoginContainer.js +++ /dev/null @@ -1,26 +0,0 @@ -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; - -import entryActions from '../entry-actions'; -import OidcLogin from '../components/OIDC'; - -const mapStateToProps = ({ - ui: { - authenticateForm: { data: defaultData, isSubmitting, error }, - }, -}) => ({ - defaultData, - isSubmitting, - error, -}); - -const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - onAuthenticate: entryActions.authenticate, - onMessageDismiss: entryActions.clearAuthenticateError, - }, - dispatch, - ); - -export default connect(mapStateToProps, mapDispatchToProps)(OidcLogin); diff --git a/client/src/containers/UserSettingsModalContainer.js b/client/src/containers/UserSettingsModalContainer.js index 1ce3277b..620632e0 100644 --- a/client/src/containers/UserSettingsModalContainer.js +++ b/client/src/containers/UserSettingsModalContainer.js @@ -14,8 +14,8 @@ const mapStateToProps = (state) => { phone, organization, language, - isLocked, subscribeToOwnCards, + isLocked, isAvatarUpdating, emailUpdateForm, passwordUpdateForm, @@ -30,8 +30,8 @@ const mapStateToProps = (state) => { phone, organization, language, - isLocked, subscribeToOwnCards, + isLocked, isAvatarUpdating, emailUpdateForm, passwordUpdateForm, diff --git a/client/src/entry-actions/core.js b/client/src/entry-actions/core.js index f76395c2..c1e03fff 100644 --- a/client/src/entry-actions/core.js +++ b/client/src/entry-actions/core.js @@ -1,16 +1,10 @@ import EntryActionTypes from '../constants/EntryActionTypes'; -const initializeCore = () => ({ - type: EntryActionTypes.CORE_INITIALIZE, - payload: {}, -}); - const logout = () => ({ type: EntryActionTypes.LOGOUT, payload: {}, }); export default { - initializeCore, logout, }; diff --git a/client/src/entry-actions/login.js b/client/src/entry-actions/login.js index db0d37dd..89769217 100755 --- a/client/src/entry-actions/login.js +++ b/client/src/entry-actions/login.js @@ -7,6 +7,11 @@ const authenticate = (data) => ({ }, }); +const authenticateWithOidc = () => ({ + type: EntryActionTypes.WITH_OIDC_AUTHENTICATE, + payload: {}, +}); + const clearAuthenticateError = () => ({ type: EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, payload: {}, @@ -14,5 +19,6 @@ const clearAuthenticateError = () => ({ export default { authenticate, + authenticateWithOidc, clearAuthenticateError, }; diff --git a/client/src/index.js b/client/src/index.js index ef5afbad..574cafee 100755 --- a/client/src/index.js +++ b/client/src/index.js @@ -1,15 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import Config from './constants/Config'; import store from './store'; import history from './history'; import Root from './components/Root'; + import './i18n'; -fetch(`${Config.SERVER_BASE_URL}/api/appconfig`).then((response) => { - response.json().then((config) => { - const root = ReactDOM.createRoot(document.getElementById('root')); - root.render(React.createElement(Root, { store, history, config })); - }); -}); +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(React.createElement(Root, { store, history })); diff --git a/client/src/locales/en/login.js b/client/src/locales/en/login.js index 598c28ea..5e7f10c3 100644 --- a/client/src/locales/en/login.js +++ b/client/src/locales/en/login.js @@ -11,10 +11,12 @@ export default { projectManagement: 'Project management', serverConnectionFailed: 'Server connection failed', unknownError: 'Unknown error, try again later', + useSingleSignOn: 'Use single sign-on', }, action: { logIn: 'Log in', + logInWithSSO: 'Log in with SSO', }, }, }; diff --git a/client/src/models/User.js b/client/src/models/User.js index 39a4330f..cce2a78a 100755 --- a/client/src/models/User.js +++ b/client/src/models/User.js @@ -43,16 +43,13 @@ export default class extends BaseModel { organization: attr(), language: attr(), subscribeToOwnCards: attr(), + isAdmin: attr(), + isLocked: attr(), + isLockedAdmin: attr(), + deletedAt: attr(), createdAt: attr({ getDefault: () => new Date(), }), - deletedAt: attr(), - isAdmin: attr({ - getDefault: () => false, - }), - isLocked: attr({ - getDefault: () => false, - }), isAvatarUpdating: attr({ getDefault: () => false, }), diff --git a/client/src/reducers/auth.js b/client/src/reducers/auth.js index 9ca6ae52..8af35d46 100755 --- a/client/src/reducers/auth.js +++ b/client/src/reducers/auth.js @@ -10,6 +10,7 @@ const initialState = { export default (state = initialState, { type, payload }) => { switch (type) { case ActionTypes.AUTHENTICATE__SUCCESS: + case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS: return { ...state, accessToken: payload.accessToken, diff --git a/client/src/reducers/core.js b/client/src/reducers/core.js index af67a3f1..94d73a52 100755 --- a/client/src/reducers/core.js +++ b/client/src/reducers/core.js @@ -4,7 +4,6 @@ import ActionTypes from '../constants/ActionTypes'; import ModalTypes from '../constants/ModalTypes'; const initialState = { - isInitializing: true, isLogouting: false, currentModal: null, }; @@ -18,11 +17,6 @@ export default (state = initialState, { type, payload }) => { ...state, currentModal: null, }; - case ActionTypes.CORE_INITIALIZE: - return { - ...state, - isInitializing: false, - }; case ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE: return { ...state, diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 6d90bbac..83d62e55 100755 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -3,6 +3,7 @@ import { combineReducers } from 'redux'; import router from './router'; import socket from './socket'; import orm from './orm'; +import root from './root'; import auth from './auth'; import core from './core'; import ui from './ui'; @@ -11,6 +12,7 @@ export default combineReducers({ router, socket, orm, + root, auth, core, ui, diff --git a/client/src/reducers/root.js b/client/src/reducers/root.js new file mode 100644 index 00000000..d11c5846 --- /dev/null +++ b/client/src/reducers/root.js @@ -0,0 +1,34 @@ +import ActionTypes from '../constants/ActionTypes'; + +const initialState = { + isInitializing: true, + config: null, +}; + +// eslint-disable-next-line default-param-last +export default (state = initialState, { type, payload }) => { + switch (type) { + case ActionTypes.LOGIN_INITIALIZE: + return { + ...state, + isInitializing: false, + config: payload.config, + }; + case ActionTypes.AUTHENTICATE__SUCCESS: + case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS: + return { + ...state, + isInitializing: true, + }; + case ActionTypes.CORE_INITIALIZE: + return { + ...state, + isInitializing: false, + ...(payload.config && { + config: payload.config, + }), + }; + default: + return state; + } +}; diff --git a/client/src/reducers/ui/authenticate-form.js b/client/src/reducers/ui/authenticate-form.js index 163c4dd4..4a0414c0 100644 --- a/client/src/reducers/ui/authenticate-form.js +++ b/client/src/reducers/ui/authenticate-form.js @@ -1,4 +1,7 @@ +import { LOCATION_CHANGE_HANDLE } from '../../lib/redux-router'; + import ActionTypes from '../../constants/ActionTypes'; +import Paths from '../../constants/Paths'; const initialState = { data: { @@ -6,12 +9,22 @@ const initialState = { password: '', }, isSubmitting: false, + isSubmittingWithOidc: false, error: null, }; // eslint-disable-next-line default-param-last export default (state = initialState, { type, payload }) => { switch (type) { + case LOCATION_CHANGE_HANDLE: + if (payload.location.pathname === Paths.OIDC_CALLBACK) { + return { + ...state, + isSubmittingWithOidc: true, + }; + } + + return state; case ActionTypes.AUTHENTICATE: return { ...state, @@ -22,6 +35,7 @@ export default (state = initialState, { type, payload }) => { isSubmitting: true, }; case ActionTypes.AUTHENTICATE__SUCCESS: + case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS: return initialState; case ActionTypes.AUTHENTICATE__FAILURE: return { @@ -29,6 +43,12 @@ export default (state = initialState, { type, payload }) => { isSubmitting: false, error: payload.error, }; + case ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE: + return { + ...state, + isSubmittingWithOidc: false, + error: payload.error, + }; case ActionTypes.AUTHENTICATE_ERROR_CLEAR: return { ...state, diff --git a/client/src/sagas/core/services/core.js b/client/src/sagas/core/services/core.js index edf4c01a..30389d6c 100644 --- a/client/src/sagas/core/services/core.js +++ b/client/src/sagas/core/services/core.js @@ -1,13 +1,22 @@ -import { call, put, take } from 'redux-saga/effects'; +import { apply, call, put, select, take } from 'redux-saga/effects'; import request from '../request'; import requests from '../requests'; +import selectors from '../../../selectors'; import actions from '../../../actions'; import api from '../../../api'; import i18n from '../../../i18n'; +import { createOidcManager } from '../../../utils/oidc-manager'; import { removeAccessToken } from '../../../utils/access-token-storage'; export function* initializeCore() { + const currentConfig = yield select(selectors.selectConfig); // TODO: add boolean selector? + + let config; + if (!currentConfig) { + ({ item: config } = yield call(api.getConfig)); // TODO: handle error + } + const { user, board, @@ -32,6 +41,7 @@ export function* initializeCore() { yield put( actions.initializeCore( + config, user, board, users, @@ -74,6 +84,16 @@ export function* logout(invalidateAccessToken = true) { } catch (error) {} // eslint-disable-line no-empty } + const oidcConfig = yield select(selectors.selectOidcConfig); + + if (oidcConfig) { + const oidcManager = createOidcManager(oidcConfig); + + try { + yield apply(oidcManager, oidcManager.logout); + } catch (error) {} // eslint-disable-line no-empty + } + yield put(actions.logout()); yield take(); } diff --git a/client/src/sagas/core/services/router.js b/client/src/sagas/core/services/router.js index 2585dc01..7b981e49 100644 --- a/client/src/sagas/core/services/router.js +++ b/client/src/sagas/core/services/router.js @@ -33,15 +33,16 @@ export function* handleLocationChange() { switch (pathsMatch.pattern.path) { case Paths.LOGIN: + case Paths.OIDC_CALLBACK: yield call(goToRoot); - break; + return; default: } - const isCoreInitializing = yield select(selectors.selectIsCoreInitializing); + const isInitializing = yield select(selectors.selectIsInitializing); - if (isCoreInitializing) { + if (isInitializing) { yield take(ActionTypes.CORE_INITIALIZE); } diff --git a/client/src/sagas/core/watchers/core.js b/client/src/sagas/core/watchers/core.js index 40485b86..cab47046 100644 --- a/client/src/sagas/core/watchers/core.js +++ b/client/src/sagas/core/watchers/core.js @@ -4,8 +4,5 @@ import services from '../services'; import EntryActionTypes from '../../../constants/EntryActionTypes'; export default function* coreWatchers() { - yield all([ - takeEvery(EntryActionTypes.CORE_INITIALIZE, () => services.initializeCore()), - takeEvery(EntryActionTypes.LOGOUT, () => services.logout()), - ]); + yield all([takeEvery(EntryActionTypes.LOGOUT, () => services.logout())]); } diff --git a/client/src/sagas/login/index.js b/client/src/sagas/login/index.js index d47b352d..f77fed9c 100755 --- a/client/src/sagas/login/index.js +++ b/client/src/sagas/login/index.js @@ -7,7 +7,9 @@ import ActionTypes from '../../constants/ActionTypes'; export default function* loginSaga() { const watcherTasks = yield all(watchers.map((watcher) => fork(watcher))); - yield take(ActionTypes.AUTHENTICATE__SUCCESS); + yield fork(services.initializeLogin); + + yield take([ActionTypes.AUTHENTICATE__SUCCESS, ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS]); yield cancel(watcherTasks); yield call(services.goToRoot); diff --git a/client/src/sagas/login/services/login.js b/client/src/sagas/login/services/login.js index 75bbd7f6..04c029ad 100644 --- a/client/src/sagas/login/services/login.js +++ b/client/src/sagas/login/services/login.js @@ -1,19 +1,25 @@ -import { call, put } from 'redux-saga/effects'; +import { apply, call, put, select } from 'redux-saga/effects'; +import { replace } from '../../../lib/redux-router'; +import selectors from '../../../selectors'; import actions from '../../../actions'; import api from '../../../api'; +import { createOidcManager } from '../../../utils/oidc-manager'; import { setAccessToken } from '../../../utils/access-token-storage'; +import Paths from '../../../constants/Paths'; + +export function* initializeLogin() { + const { item: config } = yield call(api.getConfig); // TODO: handle error + + yield put(actions.initializeLogin(config)); +} export function* authenticate(data) { yield put(actions.authenticate(data)); - let accessToken = data.access_token; + let accessToken; try { - if (accessToken) { - ({ item: accessToken } = yield call(api.exchangeOidcToken, accessToken)); - } else { - ({ item: accessToken } = yield call(api.createAccessToken, data)); - } + ({ item: accessToken } = yield call(api.createAccessToken, data)); } catch (error) { yield put(actions.authenticate.failure(error)); return; @@ -23,11 +29,50 @@ export function* authenticate(data) { yield put(actions.authenticate.success(accessToken)); } +export function* authenticateWithOidc() { + const oidcConfig = yield select(selectors.selectOidcConfig); + const oidcManager = createOidcManager(oidcConfig); + + yield apply(oidcManager, oidcManager.login); +} + +export function* authenticateWithOidcCallback() { + const oidcConfig = yield select(selectors.selectOidcConfig); + const oidcManager = createOidcManager(oidcConfig); + + let oidcToken; + try { + ({ access_token: oidcToken } = yield apply(oidcManager, oidcManager.loginCallback)); + } catch (error) { + yield put(actions.authenticateWithOidc.failure(error)); + } + + yield put(replace(Paths.LOGIN)); + + if (oidcToken) { + let accessToken; + try { + ({ item: accessToken } = yield call(api.exchangeToAccessToken, { + token: oidcToken, + })); + } catch (error) { + yield put(actions.authenticateWithOidc.failure(error)); + return; + } + + yield call(setAccessToken, accessToken); + yield put(actions.authenticateWithOidc.success(accessToken)); + } +} + export function* clearAuthenticateError() { yield put(actions.clearAuthenticateError()); } export default { + initializeLogin, authenticate, + authenticateWithOidc, + authenticateWithOidcCallback, clearAuthenticateError, }; diff --git a/client/src/sagas/login/services/router.js b/client/src/sagas/login/services/router.js index 18ecd813..45656728 100644 --- a/client/src/sagas/login/services/router.js +++ b/client/src/sagas/login/services/router.js @@ -1,7 +1,9 @@ -import { call, put, select } from 'redux-saga/effects'; +import { call, put, select, take } from 'redux-saga/effects'; import { push } from '../../../lib/redux-router'; +import { authenticateWithOidcCallback } from './login'; import selectors from '../../../selectors'; +import ActionTypes from '../../../constants/ActionTypes'; import Paths from '../../../constants/Paths'; export function* goToLogin() { @@ -27,6 +29,19 @@ export function* handleLocationChange() { yield call(goToLogin); break; + case Paths.OIDC_CALLBACK: { + const isInitializing = yield select(selectors.selectIsInitializing); + + if (isInitializing) { + yield take(ActionTypes.LOGIN_INITIALIZE); + } + + // TODO: check if OIDC is enabled + + yield call(authenticateWithOidcCallback); + + break; + } default: } } diff --git a/client/src/sagas/login/watchers/login.js b/client/src/sagas/login/watchers/login.js index 9741b981..84aef934 100644 --- a/client/src/sagas/login/watchers/login.js +++ b/client/src/sagas/login/watchers/login.js @@ -8,6 +8,7 @@ export default function* loginWatchers() { takeEvery(EntryActionTypes.AUTHENTICATE, ({ payload: { data } }) => services.authenticate(data), ), + takeEvery(EntryActionTypes.WITH_OIDC_AUTHENTICATE, () => services.authenticateWithOidc()), takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()), ]); } diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js index 0ae4663b..df7d42d5 100755 --- a/client/src/selectors/core.js +++ b/client/src/selectors/core.js @@ -6,8 +6,6 @@ import Config from '../constants/Config'; export const selectAccessToken = ({ auth: { accessToken } }) => accessToken; -export const selectIsCoreInitializing = ({ core: { isInitializing } }) => isInitializing; - export const selectIsLogouting = ({ core: { isLogouting } }) => isLogouting; const nextPosition = (items, index, excludedId) => { @@ -115,7 +113,6 @@ export const selectNextTaskPosition = createSelector( export default { selectAccessToken, - selectIsCoreInitializing, selectIsLogouting, selectNextBoardPosition, selectNextLabelPosition, diff --git a/client/src/selectors/index.js b/client/src/selectors/index.js index 7cfb1891..a674f062 100755 --- a/client/src/selectors/index.js +++ b/client/src/selectors/index.js @@ -1,5 +1,6 @@ import router from './router'; import socket from './socket'; +import root from './root'; import core from './core'; import modals from './modals'; import users from './users'; @@ -16,6 +17,7 @@ import attachments from './attachments'; export default { ...router, ...socket, + ...root, ...core, ...modals, ...users, diff --git a/client/src/selectors/root.js b/client/src/selectors/root.js new file mode 100644 index 00000000..2e485b49 --- /dev/null +++ b/client/src/selectors/root.js @@ -0,0 +1,11 @@ +export const selectIsInitializing = ({ root: { isInitializing } }) => isInitializing; + +export const selectConfig = ({ root: { config } }) => config; + +export const selectOidcConfig = (state) => selectConfig(state).oidc; + +export default { + selectIsInitializing, + selectConfig, + selectOidcConfig, +}; diff --git a/client/src/utils/oidc-manager.js b/client/src/utils/oidc-manager.js new file mode 100644 index 00000000..c7812052 --- /dev/null +++ b/client/src/utils/oidc-manager.js @@ -0,0 +1,27 @@ +import { UserManager } from 'oidc-client-ts'; + +export default class OidcManager { + constructor(config) { + this.userManager = new UserManager({ + ...config, + authority: config.issuer, + client_id: config.clientId, + redirect_uri: config.redirectUri, + scope: config.scopes, + }); + } + + login() { + return this.userManager.signinRedirect(); + } + + loginCallback() { + return this.userManager.signinRedirectCallback(); + } + + logout() { + return this.userManager.signoutSilent(); + } +} + +export const createOidcManager = (config) => new OidcManager(config); diff --git a/docker-compose.yml b/docker-compose.yml index c77790de..e0d030a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,16 @@ services: # Configure knex to accept SSL certificates # - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false + + # - OIDC_ISSUER= + # - OIDC_CLIENT_ID= + # - OIDC_REDIRECT_URI=http://localhost:3000/oidc-callback + # - OIDC_SCOPES=openid email profile + # - OIDC_JWKS_URI= + # - OIDC_AUDIENCE= + # - OIDC_ADMIN_ROLES=admin + # - OIDC_ROLES_ATTRIBUTE=groups + # - OIDC_SKIP_USER_INFO=true depends_on: - postgres diff --git a/server/.env.sample b/server/.env.sample index 36de1985..76b30480 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -24,6 +24,16 @@ DEFAULT_ADMIN_USERNAME=demo # Configure knex to accept SSL certificates # KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false +# OIDC_ISSUER= +# OIDC_CLIENT_ID= +# OIDC_REDIRECT_URI=http://localhost:1337/oidc-callback +# OIDC_SCOPES=openid email profile +# OIDC_JWKS_URI= +# OIDC_AUDIENCE= +# OIDC_ADMIN_ROLES=admin +# OIDC_ROLES_ATTRIBUTE=groups +# OIDC_SKIP_USER_INFO=true + ## Do not edit this TZ=UTC diff --git a/server/api/controllers/access-tokens/create.js b/server/api/controllers/access-tokens/create.js index f98add91..0136a6c1 100755 --- a/server/api/controllers/access-tokens/create.js +++ b/server/api/controllers/access-tokens/create.js @@ -10,6 +10,9 @@ const Errors = { INVALID_PASSWORD: { invalidPassword: 'Invalid password', }, + USE_SINGLE_SIGN_ON: { + useSingleSignOn: 'Use single sign-on', + }, }; const emailOrUsernameValidator = (value) => @@ -37,6 +40,9 @@ module.exports = { invalidPassword: { responseType: 'unauthorized', }, + useSingleSignOn: { + responseType: 'forbidden', + }, }, async fn(inputs) { @@ -44,6 +50,10 @@ module.exports = { const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); + if (user.isSso) { + throw Errors.USE_SINGLE_SIGN_ON; + } + if (!user) { sails.log.warn( `Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`, diff --git a/server/api/controllers/access-tokens/exchange.js b/server/api/controllers/access-tokens/exchange.js index 04ba58d8..bd77c2cb 100644 --- a/server/api/controllers/access-tokens/exchange.js +++ b/server/api/controllers/access-tokens/exchange.js @@ -1,88 +1,20 @@ -const jwt = require('jsonwebtoken'); -const jwksClient = require('jwks-rsa'); -const openidClient = require('openid-client'); const { getRemoteAddress } = require('../../../utils/remoteAddress'); const Errors = { INVALID_TOKEN: { - invalidToken: 'Access Token is invalid', + invalidToken: 'Invalid token', + }, + EMAIL_ALREADY_IN_USE: { + emailAlreadyInUse: 'Email already in use', + }, + USERNAME_ALREADY_IN_USE: { + usernameAlreadyInUse: 'Username already in use', }, MISSING_VALUES: { - missingValues: - 'Unable to retrieve required values. Verify the access token or UserInfo endpoint has email, username and name claims', + missingValues: 'Unable to retrieve required values (email, name)', }, }; -const jwks = jwksClient({ - jwksUri: sails.config.custom.oidcJwksUri, - requestHeaders: {}, // Optional - timeout: 30000, // Defaults to 30s -}); - -const getJwtVerificationOptions = () => { - const options = {}; - if (sails.config.custom.oidcIssuer) { - options.issuer = sails.config.custom.oidcIssuer; - } - if (sails.config.custom.oidcAudience) { - options.audience = sails.config.custom.oidcAudience; - } - return options; -}; - -const validateAndDecodeToken = async (accessToken, options) => { - const keys = await jwks.getSigningKeys(); - let validToken = {}; - - const isTokenValid = keys.some((signingKey) => { - try { - const key = signingKey.getPublicKey(); - validToken = jwt.verify(accessToken, key, options); - return 'true'; - } catch (error) { - sails.log.error(error); - } - return false; - }); - - if (!isTokenValid) { - const tokenForLogging = jwt.decode(accessToken); - const remoteAddress = getRemoteAddress(this.req); - - sails.log.warn( - `invalid token: sub: "${tokenForLogging.sub}" issuer: "${tokenForLogging.iss}" audience: "${tokenForLogging.aud}" exp: ${tokenForLogging.exp} (IP: ${remoteAddress})`, - ); - throw Errors.INVALID_TOKEN; - } - return validToken; -}; - -const getUserInfo = async (accessToken, options) => { - if (sails.config.custom.oidcSkipUserInfo) { - return {}; - } - const issuer = await openidClient.Issuer.discover(options.issuer); - const oidcClient = new issuer.Client({ - client_id: 'irrelevant', - }); - const userInfo = await oidcClient.userinfo(accessToken); - return userInfo; -}; -const mergeUserData = (validToken, userInfo) => { - const oidcUser = { ...validToken, ...userInfo }; - return oidcUser; -}; -const getOrCreateUser = async (newUser) => { - const user = await User.findOne({ - where: { - username: newUser.username, - }, - }); - if (user) { - return user; - } - return User.create(newUser).fetch(); -}; module.exports = { inputs: { token: { @@ -95,83 +27,41 @@ module.exports = { invalidToken: { responseType: 'unauthorized', }, + emailAlreadyInUse: { + responseType: 'conflict', + }, + usernameAlreadyInUse: { + responseType: 'conflict', + }, missingValues: { - responseType: 'unauthorized', + responseType: 'unprocessableEntity', }, }, async fn(inputs) { - const options = getJwtVerificationOptions(); - const validToken = await validateAndDecodeToken(inputs.token, options); - const userInfo = await getUserInfo(inputs.token, options); - const oidcUser = mergeUserData(validToken, userInfo); - - const now = new Date(); - let isAdmin = false; - if (sails.config.custom.oidcAdminRoles.includes('*')) isAdmin = true; - else if (Array.isArray(oidcUser[sails.config.custom.oidcRolesAttribute])) { - const userRoles = new Set(oidcUser[sails.config.custom.oidcRolesAttribute]); - isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1; - } - - const newUser = { - email: oidcUser.email, - isAdmin, - name: oidcUser.name, - username: oidcUser.preferred_username, - subscribeToOwnCards: false, - createdAt: now, - updatedAt: now, - locked: true, - }; - - if (!newUser.email || !newUser.username || !newUser.name) { - sails.log.error(Errors.MISSING_VALUES.missingValues); - throw Errors.MISSING_VALUES; - } - - const identityProviderUser = await IdentityProviderUser.findOne({ - where: { - issuer: oidcUser.iss, - sub: oidcUser.sub, - }, - }).populate('userId'); - - let user = identityProviderUser ? identityProviderUser.userId : {}; - if (!identityProviderUser) { - user = await getOrCreateUser(newUser); - await IdentityProviderUser.create({ - issuer: oidcUser.iss, - sub: oidcUser.sub, - userId: user.id, - }); - } - - const controlledFields = ['email', 'password', 'isAdmin', 'name', 'username']; - const updateFields = {}; - controlledFields.forEach((field) => { - if (user[field] !== newUser[field]) { - updateFields[field] = newUser[field]; - } - }); - - if (Object.keys(updateFields).length > 0) { - updateFields.updatedAt = now; - await User.updateOne({ id: user.id }).set(updateFields); - } - - const plankaToken = sails.helpers.utils.createToken(user.id); - const remoteAddress = getRemoteAddress(this.req); + + const user = await sails.helpers.users + .getOrCreateOneByOidcToken(inputs.token) + .intercept('invalidToken', () => { + sails.log.warn(`Invalid token! (IP: ${remoteAddress})`); + return Errors.INVALID_TOKEN; + }) + .intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE) + .intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE) + .intercept('missingValues', () => Errors.MISSING_VALUES); + + const accessToken = sails.helpers.utils.createToken(user.id); + await Session.create({ - accessToken: plankaToken, + accessToken, remoteAddress, userId: user.id, userAgent: this.req.headers['user-agent'], }); return { - item: plankaToken, + item: accessToken, }; }, }; diff --git a/server/api/controllers/appconfig/index.js b/server/api/controllers/appconfig/index.js deleted file mode 100644 index eef7be68..00000000 --- a/server/api/controllers/appconfig/index.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - async fn() { - const config = { - authority: sails.config.custom.oidcIssuer, - clientId: sails.config.custom.oidcClientId, - redirectUri: sails.config.custom.oidcredirectUri, - scopes: sails.config.custom.oidcScopes, - }; - return config; - }, -}; diff --git a/server/api/controllers/show-config.js b/server/api/controllers/show-config.js new file mode 100644 index 00000000..0273f93b --- /dev/null +++ b/server/api/controllers/show-config.js @@ -0,0 +1,16 @@ +module.exports = { + fn() { + return { + item: { + oidc: sails.config.custom.oidcIssuer + ? { + issuer: sails.config.custom.oidcIssuer, + clientId: sails.config.custom.oidcClientId, + redirectUri: sails.config.custom.oidcRedirectUri, + scopes: sails.config.custom.oidcScopes, + } + : null, + }, + }; + }, +}; diff --git a/server/api/controllers/users/delete.js b/server/api/controllers/users/delete.js index 46edb042..56a42f8b 100755 --- a/server/api/controllers/users/delete.js +++ b/server/api/controllers/users/delete.js @@ -1,4 +1,7 @@ const Errors = { + NOT_ENOUGH_RIGHTS: { + notEnoughRights: 'Not enough rights', + }, USER_NOT_FOUND: { userNotFound: 'User not found', }, @@ -14,6 +17,9 @@ module.exports = { }, exits: { + notEnoughRights: { + responseType: 'forbidden', + }, userNotFound: { responseType: 'notFound', }, @@ -27,7 +33,7 @@ module.exports = { } if (user.email === sails.config.custom.defaultAdminEmail) { - throw Errors.USER_NOT_FOUND; // Forbidden + throw Errors.NOT_ENOUGH_RIGHTS; } user = await sails.helpers.users.deleteOne.with({ diff --git a/server/api/controllers/users/update-email.js b/server/api/controllers/users/update-email.js index 182e0c10..f067e004 100644 --- a/server/api/controllers/users/update-email.js +++ b/server/api/controllers/users/update-email.js @@ -1,6 +1,9 @@ const bcrypt = require('bcrypt'); const Errors = { + NOT_ENOUGH_RIGHTS: { + notEnoughRights: 'Not enough rights', + }, USER_NOT_FOUND: { userNotFound: 'User not found', }, @@ -31,6 +34,9 @@ module.exports = { }, exits: { + notEnoughRights: { + responseType: 'forbidden', + }, userNotFound: { responseType: 'notFound', }, @@ -59,8 +65,8 @@ module.exports = { throw Errors.USER_NOT_FOUND; } - if (user.email === sails.config.custom.defaultAdminEmail) { - throw Errors.USER_NOT_FOUND; // Forbidden + if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) { + throw Errors.NOT_ENOUGH_RIGHTS; } if ( diff --git a/server/api/controllers/users/update-password.js b/server/api/controllers/users/update-password.js index c3d9c724..107c013e 100644 --- a/server/api/controllers/users/update-password.js +++ b/server/api/controllers/users/update-password.js @@ -4,6 +4,9 @@ const zxcvbn = require('zxcvbn'); const { getRemoteAddress } = require('../../../utils/remoteAddress'); const Errors = { + NOT_ENOUGH_RIGHTS: { + notEnoughRights: 'Not enough rights', + }, USER_NOT_FOUND: { userNotFound: 'User not found', }, @@ -33,6 +36,9 @@ module.exports = { }, exits: { + notEnoughRights: { + responseType: 'forbidden', + }, userNotFound: { responseType: 'notFound', }, @@ -58,8 +64,8 @@ module.exports = { throw Errors.USER_NOT_FOUND; } - if (user.email === sails.config.custom.defaultAdminEmail) { - throw Errors.USER_NOT_FOUND; // Forbidden + if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) { + throw Errors.NOT_ENOUGH_RIGHTS; } if ( diff --git a/server/api/controllers/users/update-username.js b/server/api/controllers/users/update-username.js index 03bb1d3c..58059460 100644 --- a/server/api/controllers/users/update-username.js +++ b/server/api/controllers/users/update-username.js @@ -1,6 +1,9 @@ const bcrypt = require('bcrypt'); const Errors = { + NOT_ENOUGH_RIGHTS: { + notEnoughRights: 'Not enough rights', + }, USER_NOT_FOUND: { userNotFound: 'User not found', }, @@ -33,6 +36,9 @@ module.exports = { }, exits: { + notEnoughRights: { + responseType: 'forbidden', + }, userNotFound: { responseType: 'notFound', }, @@ -61,8 +67,8 @@ module.exports = { throw Errors.USER_NOT_FOUND; } - if (user.email === sails.config.custom.defaultAdminEmail) { - throw Errors.USER_NOT_FOUND; // Forbidden + if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) { + throw Errors.NOT_ENOUGH_RIGHTS; } if ( diff --git a/server/api/controllers/users/update.js b/server/api/controllers/users/update.js index 7de8a730..c876efbd 100755 --- a/server/api/controllers/users/update.js +++ b/server/api/controllers/users/update.js @@ -72,6 +72,8 @@ module.exports = { delete inputs.isAdmin; delete inputs.name; /* eslint-enable no-param-reassign */ + } else if (user.isSso) { + delete inputs.name; // eslint-disable-line no-param-reassign } const values = { diff --git a/server/api/helpers/users/create-one.js b/server/api/helpers/users/create-one.js index 8df1d146..ddd07b94 100644 --- a/server/api/helpers/users/create-one.js +++ b/server/api/helpers/users/create-one.js @@ -9,7 +9,7 @@ const valuesValidator = (value) => { return false; } - if (!_.isString(value.password)) { + if (!_.isNil(value.password) && !_.isString(value.password)) { return false; } @@ -40,6 +40,10 @@ module.exports = { async fn(inputs) { const { values } = inputs; + if (values.password) { + values.password = bcrypt.hashSync(values.password, 10); + } + if (values.username) { values.username = values.username.toLowerCase(); } @@ -47,7 +51,6 @@ module.exports = { const user = await User.create({ ...values, email: values.email.toLowerCase(), - password: bcrypt.hashSync(values.password, 10), }) .intercept( { diff --git a/server/api/helpers/users/delete-one.js b/server/api/helpers/users/delete-one.js index 94bceeb7..6e2e6a58 100644 --- a/server/api/helpers/users/delete-one.js +++ b/server/api/helpers/users/delete-one.js @@ -10,6 +10,10 @@ module.exports = { }, async fn(inputs) { + await IdentityProviderUser.destroy({ + userId: inputs.record.id, + }); + await ProjectManager.destroy({ userId: inputs.record.id, }); diff --git a/server/api/helpers/users/get-or-create-one-by-oidc-token.js b/server/api/helpers/users/get-or-create-one-by-oidc-token.js new file mode 100644 index 00000000..c9e38e07 --- /dev/null +++ b/server/api/helpers/users/get-or-create-one-by-oidc-token.js @@ -0,0 +1,159 @@ +const jwt = require('jsonwebtoken'); +const jwksClient = require('jwks-rsa'); +const openidClient = require('openid-client'); + +const jwks = jwksClient({ + jwksUri: sails.config.custom.oidcJwksUri, +}); + +const verifyOidcToken = async (oidcToken) => { + const signingKeys = await jwks.getSigningKeys(); + + const options = { + issuer: sails.config.custom.oidcIssuer, + }; + + if (sails.config.custom.oidcAudience) { + options.audience = sails.config.custom.oidcAudience; + } + + let payload = null; + signingKeys.some((signingKey) => { + try { + const publicKey = signingKey.getPublicKey(); + payload = jwt.verify(oidcToken, publicKey, options); + } catch (error) { + sails.log.error(error); + } + + return !!payload; + }); + + return payload; +}; + +const getUserInfo = async (oidcToken) => { + const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer); + + const client = new issuer.Client({ + client_id: 'irrelevant', + }); + + return client.userinfo(oidcToken); +}; + +module.exports = { + inputs: { + token: { + type: 'string', + required: true, + }, + }, + + exits: { + invalidToken: {}, + missingValues: {}, + emailAlreadyInUse: {}, + usernameAlreadyInUse: {}, + }, + + async fn(inputs) { + const oidcUser = await verifyOidcToken(inputs.token); + + if (!oidcUser) { + throw 'invalidToken'; + } + + if (!sails.config.custom.oidcSkipUserInfo) { + const userInfo = await getUserInfo(inputs.token); + Object.assign(oidcUser, userInfo); + } + + if (!oidcUser.email || !oidcUser.name) { + throw 'missingValues'; + } + + let isAdmin = false; + if (sails.config.custom.oidcAdminRoles.includes('*')) { + isAdmin = true; + } else { + const roles = oidcUser[sails.config.custom.oidcRolesAttribute]; + + if (Array.isArray(roles)) { + isAdmin = sails.config.custom.oidcAdminRoles.some((role) => roles.includes(role)); + } + } + + const values = { + isAdmin, + email: oidcUser.email, + isSso: true, + name: oidcUser.name, + username: oidcUser.preferred_username, + subscribeToOwnCards: false, + }; + + let user; + /* eslint-disable no-constant-condition, no-await-in-loop */ + while (true) { + let identityProviderUser = await IdentityProviderUser.findOne({ + issuer: oidcUser.iss, + sub: oidcUser.sub, + }); + + if (identityProviderUser) { + user = await sails.helpers.users.getOne(identityProviderUser.userId); + } else { + while (true) { + user = await sails.helpers.users.getOne({ + email: values.email, + }); + + if (!user) { + user = await sails.helpers.users + .createOne(values) + .tolerate('emailAlreadyInUse') + .intercept('usernameAlreadyInUse', 'usernameAlreadyInUse'); + } + + if (user) { + break; + } + } + + identityProviderUser = await IdentityProviderUser.create({ + userId: user.id, + issuer: oidcUser.iss, + sub: oidcUser.sub, + }).tolerate('E_UNIQUE'); + } + + if (identityProviderUser) { + break; + } + } + /* eslint-enable no-constant-condition, no-await-in-loop */ + + const updateFieldKeys = ['email', 'isAdmin', 'isSso', 'name', 'username']; + + const updateValues = updateFieldKeys.reduce((result, fieldKey) => { + if (values[fieldKey] === user[fieldKey]) { + return result; + } + + return { + ...result, + [fieldKey]: values[fieldKey], + }; + }, {}); + + if (Object.keys(updateValues).length > 0) { + user = await sails.helpers.users + .updateOne(user, updateValues, {}) // FIXME: hack for last parameter + .intercept('emailAlreadyInUse', 'emailAlreadyInUse') + .intercept('usernameAlreadyInUse', 'usernameAlreadyInUse'); + } + + return user; + }, +}; diff --git a/server/api/models/User.js b/server/api/models/User.js index f63dc68f..5a16fe23 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -24,6 +24,11 @@ module.exports = { defaultsTo: false, columnName: 'is_admin', }, + isSso: { + type: 'boolean', + defaultsTo: false, + columnName: 'is_sso', + }, name: { type: 'string', required: true, @@ -67,10 +72,6 @@ module.exports = { type: 'ref', columnName: 'password_changed_at', }, - locked: { - type: 'boolean', - columnName: 'locked', - }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ @@ -109,12 +110,15 @@ module.exports = { tableName: 'user_account', customToJSON() { + const isLockedAdmin = this.email === sails.config.custom.defaultAdminEmail; + return { - ..._.omit(this, ['password', 'avatar', 'passwordChangedAt']), + ..._.omit(this, ['password', 'isSso', 'avatar', 'passwordChangedAt']), + isLockedAdmin, + isLocked: this.isSso || isLockedAdmin, avatarUrl: this.avatar && `${sails.config.custom.userAvatarsUrl}/${this.avatar.dirname}/square-100.${this.avatar.extension}`, - isLocked: this.email === sails.config.custom.defaultAdminEmail, }; }, }; diff --git a/server/config/custom.js b/server/config/custom.js index a132e5ad..53f48b5c 100644 --- a/server/config/custom.js +++ b/server/config/custom.js @@ -31,15 +31,15 @@ module.exports.custom = { attachmentsPath: path.join(sails.config.appPath, 'private', 'attachments'), attachmentsUrl: `${process.env.BASE_URL}/attachments`, - oidcIssuer: process.env.OIDC_ISSUER, - oidcAudience: process.env.OIDC_AUDIENCE, - oidcClientId: process.env.OIDC_CLIENT_ID, - oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups', - oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [], - oidcredirectUri: process.env.OIDC_REDIRECT_URI, - oidcJwksUri: process.env.OIDC_JWKS_URI, - oidcScopes: process.env.OIDC_SCOPES || 'openid profile email', - oidcSkipUserInfo: process.env.OIDC_SKIP_USER_INFO === 'true', - defaultAdminEmail: process.env.DEFAULT_ADMIN_EMAIL, + + oidcIssuer: process.env.OIDC_ISSUER, + oidcClientId: process.env.OIDC_CLIENT_ID, + oidcRedirectUri: process.env.OIDC_REDIRECT_URI, + oidcScopes: process.env.OIDC_SCOPES || 'openid email profile', + oidcJwksUri: process.env.OIDC_JWKS_URI, + oidcAudience: process.env.OIDC_AUDIENCE, + oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [], + oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups', + oidcSkipUserInfo: process.env.OIDC_SKIP_USER_INFO === 'true', }; diff --git a/server/config/policies.js b/server/config/policies.js index 272355c4..6d923403 100644 --- a/server/config/policies.js +++ b/server/config/policies.js @@ -23,7 +23,7 @@ module.exports.policies = { 'projects/create': ['is-authenticated', 'is-admin'], + 'show-config': true, 'access-tokens/create': true, 'access-tokens/exchange': true, - 'appconfig/index': true, }; diff --git a/server/config/routes.js b/server/config/routes.js index 9f380655..16ae350e 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -9,7 +9,7 @@ */ module.exports.routes = { - 'GET /api/appconfig': 'appconfig/index', + 'GET /api/config': 'show-config', 'POST /api/access-tokens': 'access-tokens/create', 'POST /api/access-tokens/exchange': 'access-tokens/exchange', diff --git a/server/db/migrations/20230809022050_create_identity_provider_user.js b/server/db/migrations/20230809022050_create_identity_provider_user.js deleted file mode 100644 index 615cf09a..00000000 --- a/server/db/migrations/20230809022050_create_identity_provider_user.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports.up = (knex) => - knex.schema.createTable('identity_provider_user', (table) => { - /* Columns */ - - table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); - table.timestamp('created_at', true); - table.timestamp('updated_at', true); - - table - .bigInteger('user_id') - .notNullable() - .references('id') - .inTable('user_account') - .onDelete('CASCADE'); - - table.text('issuer').notNullable(); - table.text('sub').notNullable(); - - /* Indexes */ - - table.index('user_id'); - }); - -module.exports.down = (knex) => knex.schema.dropTable('identity_provider_user'); diff --git a/server/db/migrations/20230809022050_oidc_with_pkce_flow.js b/server/db/migrations/20230809022050_oidc_with_pkce_flow.js new file mode 100644 index 00000000..d3d997bc --- /dev/null +++ b/server/db/migrations/20230809022050_oidc_with_pkce_flow.js @@ -0,0 +1,44 @@ +module.exports.up = async (knex) => { + await knex.schema.createTable('identity_provider_user', (table) => { + /* Columns */ + + table.bigInteger('id').primary().defaultTo(knex.raw('next_id()')); + + table.bigInteger('user_id').notNullable(); + + table.text('issuer').notNullable(); + table.text('sub').notNullable(); + + table.timestamp('created_at', true); + table.timestamp('updated_at', true); + + /* Indexes */ + + table.unique(['issuer', 'sub']); + table.index('user_id'); + }); + + await knex.schema.table('user_account', (table) => { + /* Columns */ + + table.boolean('is_sso').notNullable().default(false); + + /* Modifications */ + + table.setNullable('password'); + }); + + return knex.schema.alterTable('user_account', (table) => { + table.boolean('is_sso').notNullable().alter(); + }); +}; + +module.exports.down = async (knex) => { + await knex.schema.dropTable('identity_provider_user'); + + return knex.schema.table('user_account', (table) => { + table.dropColumn('is_sso'); + + table.dropNullable('password'); + }); +}; diff --git a/server/db/migrations/20230809024146_all_null_password_field.js b/server/db/migrations/20230809024146_all_null_password_field.js deleted file mode 100644 index c86529f1..00000000 --- a/server/db/migrations/20230809024146_all_null_password_field.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports.up = async (knex) => { - return knex.schema.table('user_account', (table) => { - table.setNullable('password'); - }); -}; - -module.exports.down = async (knex) => { - return knex.schema.table('user_account', (table) => { - table.dropNullable('password'); - }); -}; diff --git a/server/db/migrations/20230809025904_add_lock_to_user_account.js b/server/db/migrations/20230809025904_add_lock_to_user_account.js deleted file mode 100644 index 07c341f1..00000000 --- a/server/db/migrations/20230809025904_add_lock_to_user_account.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports.up = async (knex) => { - return knex.schema.table('user_account', (table) => { - table.boolean('locked').default(false); - }); -}; - -module.exports.down = async (knex) => { - return knex.schema.table('user_account', (table) => { - table.dropColumn('locked'); - }); -}; diff --git a/server/db/seeds/default.js b/server/db/seeds/default.js index 8fa4cdc2..20624e50 100644 --- a/server/db/seeds/default.js +++ b/server/db/seeds/default.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); const buildData = () => { const data = { isAdmin: true, + isSso: false, }; if (process.env.DEFAULT_ADMIN_PASSWORD) {