Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[kbss-cvut/23ava-distribution#18] User impersonation #19

Merged
merged 7 commits into from
Nov 29, 2023
29 changes: 23 additions & 6 deletions js/actions/AuthActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import {axiosBackend} from "./index";
import {transitionToHome} from "../utils/Routing";
import * as ActionConstants from "../constants/ActionConstants";
import {API_URL} from '../../config';
import {IMPERSONATOR_TYPE} from "../constants/Vocabulary";
import {IMPERSONATE_LOGOUT_SUCCESS, IMPERSONATE_PENDING} from "../constants/ActionConstants";
import {MediaType} from "../constants/DefaultConstants";

export function login(username, password) {
return function (dispatch) {
dispatch(userAuthPending());
axiosBackend.post(`${API_URL}/j_spring_security_check`, `username=${username}&password=${password}`,
{
headers: {'Content-Type': 'application/x-www-form-urlencoded'}
headers: {'Content-Type': MediaType.FORM_URLENCODED}
}).then((response) => {
const data = response.data;
if (!data.success || !data.loggedIn) {
Expand Down Expand Up @@ -49,17 +52,31 @@ export function userAuthError(error) {
}

export function logout() {
//console.log("Logouting user");
return function (dispatch) {
return function (dispatch, getState) {
if (getState().auth.user.types.indexOf(IMPERSONATOR_TYPE) !== -1) {
return logoutImpersonator(dispatch);
}
return axiosBackend.post(`${API_URL}/j_spring_security_logout`).then(() => {
dispatch(unauthUser());
//Logger.log('User successfully logged out.');
}).catch(() => {
//Logger.error('Logout failed. Status: ' + error.status);
}).catch((error) => {
dispatch(userAuthError(error.response.data));
});
}
}

function logoutImpersonator(dispatch) {
dispatch({type: IMPERSONATE_PENDING});
return axiosBackend.post(`${API_URL}/rest/users/impersonate/logout`)
.then(() => {
dispatch({type: IMPERSONATE_LOGOUT_SUCCESS});
transitionToHome();
window.location.reload();
})
.catch((error) => {
dispatch(userAuthError(error.response.data));
});
}

export function unauthUser() {
return {
type: ActionConstants.UNAUTH_USER
Expand Down
31 changes: 28 additions & 3 deletions js/actions/UserActions.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {ACTION_FLAG, ROLE} from "../constants/DefaultConstants";
import {ACTION_FLAG, MediaType, ROLE} from "../constants/DefaultConstants";
import {axiosBackend} from "./index";
import * as ActionConstants from "../constants/ActionConstants";
import {loadUsers} from "./UsersActions";
import {API_URL} from '../../config';
import {API_URL, getEnv} from '../../config';
import {transitionToHome} from "../utils/Routing";
import {getOidcToken, saveOidcToken} from "../utils/SecurityUtils";

export function createUser(user) {
//console.log("Creating user: ", user);
Expand Down Expand Up @@ -253,9 +255,32 @@ export function deleteInvitationOption(username) {
export function impersonate(username) {
return function (dispatch) {
dispatch({type: ActionConstants.IMPERSONATE_PENDING});
axiosBackend.post(`${API_URL}/rest/users/impersonate`, username, {headers: {"Content-Type": "text/plain"}}).then(() => {
axiosBackend.post(`${API_URL}/rest/users/impersonate`, `username=${username}`, {
headers: {'Content-Type': MediaType.FORM_URLENCODED}
}).then(() => {
dispatch({type: ActionConstants.IMPERSONATE_SUCCESS, username});
transitionToHome();
window.location.reload();
}).catch((error) => {
dispatch({type: ActionConstants.IMPERSONATE_ERROR, error: error.response.data});
});
}
}

export function oidcImpersonate(username) {
return function (dispatch) {
dispatch({type: ActionConstants.IMPERSONATE_PENDING});
axiosBackend.post(`${getEnv("AUTH_SERVER_URL")}/protocol/openid-connect/token`, new URLSearchParams({
client_id: getEnv("AUTH_CLIENT_ID"),
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
requested_subject: username,
subject_token: getOidcToken().substring("Bearer ".length) // Extract only the token value
}), {
headers: {'Content-Type': MediaType.FORM_URLENCODED}
}).then((resp) => {
dispatch({type: ActionConstants.IMPERSONATE_SUCCESS, username});
saveOidcToken(resp.data);
transitionToHome();
window.location.reload();
}).catch((error) => {
dispatch({type: ActionConstants.IMPERSONATE_ERROR, error: error.response.data});
Expand Down
4 changes: 4 additions & 0 deletions js/components/HelpIcon.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ HelpIcon.propTypes = {
glyph: PropTypes.string
};

HelpIcon.defaultProps = {
glyph: "help"
};

export default HelpIcon;
16 changes: 16 additions & 0 deletions js/components/ImpersonatorBadge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from "react";
import {useSelector} from "react-redux";
import {Badge} from "react-bootstrap";
import {useI18n} from "../hooks/useI18n";
import {isImpersonator} from "../utils/SecurityUtils";

const ImpersonatorBadge = () => {
const {i18n} = useI18n();
const user = useSelector(state => state.auth.user);
if (isImpersonator(user)) {
return <Badge variant="warning" className="nav-badge">{i18n("main.impersonating")}</Badge>;
}
return null;
};

export default ImpersonatorBadge;
2 changes: 2 additions & 0 deletions js/components/MainView.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {NavLink, withRouter} from 'react-router-dom';
import {IfGranted} from "react-authorization";
import {transitionTo, transitionToWithOpts} from "../utils/Routing";
import {isUsingOidcAuth, userProfileLink} from "../utils/OidcUtils";
import ImpersonatorBadge from "./ImpersonatorBadge";

class MainView extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -122,6 +123,7 @@ class MainView extends React.Component {
</Nav>

<Nav>
<ImpersonatorBadge/>
<NavDropdown className="pr-0" id='logout' title={name}>
<DropdownItem
onClick={() => this.onProfileClick()}>{this.i18n('main.my-profile')}</DropdownItem>
Expand Down
4 changes: 2 additions & 2 deletions js/components/user/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class User extends React.Component {
disabled={!UserValidator.isValid(user) || userSaved.status === ACTION_STATUS.PENDING}
onClick={() => this._onSaveAndSendEmail()} className="d-inline-flex"
title={this.i18n('required')}>{this.i18n('save-and-send-email')}
{!UserValidator.isValid(user) && <HelpIcon text={this.i18n('required')} glyph="help"/>}
{!UserValidator.isValid(user) && <HelpIcon text={this.i18n('required')} className="align-self-center"/>}
{userSaved.status === ACTION_STATUS.PENDING && <LoaderSmall/>}
</Button>
} else {
Expand Down Expand Up @@ -259,7 +259,7 @@ class User extends React.Component {
title={this.i18n('required')}>
{this.i18n('save')}
{!UserValidator.isValid(user) &&
<HelpIcon className="align-self-center" text={this.i18n('required')} glyph="help"/>}
<HelpIcon className="align-self-center" text={this.i18n('required')}/>}
{userSaved.status === ACTION_STATUS.PENDING &&
<LoaderSmall/>}
</Button>
Expand Down
12 changes: 9 additions & 3 deletions js/components/user/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import {setTransitionPayload} from "../../actions/RouterActions";
import {
deleteInvitationOption,
createUser, generateUsername, impersonate, loadUser, sendInvitation, unloadSavedUser, unloadUser,
updateUser
updateUser, oidcImpersonate
} from "../../actions/UserActions";
import * as UserFactory from "../../utils/EntityFactory";
import omit from 'lodash/omit';
import {getRole} from "../../utils/Utils";
import {isUsingOidcAuth} from "../../utils/OidcUtils";

class UserController extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -141,7 +142,11 @@ class UserController extends React.Component {

_impersonate = () => {
this.setState({impersonated: true, showAlert: false});
this.props.impersonate(this.state.user.username);
if (isUsingOidcAuth()) {
this.props.oidcImpersonate(this.state.user.username);
} else {
this.props.impersonate(this.state.user.username);
}
};

render() {
Expand Down Expand Up @@ -201,6 +206,7 @@ function mapDispatchToProps(dispatch) {
generateUsername: bindActionCreators(generateUsername, dispatch),
sendInvitation: bindActionCreators(sendInvitation, dispatch),
deleteInvitationOption: bindActionCreators(deleteInvitationOption, dispatch),
impersonate: bindActionCreators(impersonate, dispatch)
impersonate: bindActionCreators(impersonate, dispatch),
oidcImpersonate: bindActionCreators(oidcImpersonate, dispatch),
}
}
5 changes: 4 additions & 1 deletion js/constants/ActionConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,7 @@ export const INVITATION_OPTION_DELETE_ERROR = "INVITATION_OPTION_DELETE_ERROR";

export const IMPERSONATE_PENDING = "IMPERSONATE_PENDING";
export const IMPERSONATE_SUCCESS = "IMPERSONATE_SUCCESS";
export const IMPERSONATE_ERROR = "IMPERSONATE_ERROR";
export const IMPERSONATE_ERROR = "IMPERSONATE_ERROR";

export const IMPERSONATE_LOGOUT_PENDING = "IMPERSONATE_LOGOUT_PENDING";
export const IMPERSONATE_LOGOUT_SUCCESS = "IMPERSONATE_LOGOUT_SUCCESS";
4 changes: 4 additions & 0 deletions js/constants/DefaultConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,7 @@ export const SCRIPT_ERROR = 'SCRIPT_ERROR';
export const HttpHeaders = {
AUTHORIZATION: "Authorization"
}

export const MediaType = {
FORM_URLENCODED: "application/x-www-form-urlencoded"
}
1 change: 1 addition & 0 deletions js/constants/Vocabulary.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export const RDFS_LABEL = 'http://www.w3.org/2000/01/rdf-schema#label';
export const RDFS_COMMENT = 'http://www.w3.org/2000/01/rdf-schema#comment';
export const ADMIN_TYPE = 'http://onto.fel.cvut.cz/ontologies/record-manager/administrator';
export const DOCTOR_TYPE = 'http://onto.fel.cvut.cz/ontologies/record-manager/doctor';
export const IMPERSONATOR_TYPE = 'http://onto.fel.cvut.cz/ontologies/record-manager/impersonator';
25 changes: 25 additions & 0 deletions js/hooks/useI18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useCallback } from "react";
import { useIntl } from "react-intl";

/**
* React Hook providing basic i18n functions.
*/
export function useI18n() {
const intl = useIntl();
const i18n = useCallback(
(msgId) => (intl.messages[msgId]) || "{" + msgId + "}",
[intl]
);
const formatMessage = useCallback(
(msgId, values = {}) =>
intl.formatMessage({ id: msgId }, values),
[intl]
);
return {
i18n,
formatMessage,
formatDate: intl.formatDate,
formatTime: intl.formatTime,
locale: intl.locale,
};
}
1 change: 1 addition & 0 deletions js/i18n/cs.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default {
'main.logout': 'Odhlásit se',
'main.my-profile': 'Můj profil',
'main.history': 'Historie',
'main.impersonating': 'Přihlášen jako',

'dashboard.welcome': 'Dobrý den, {name}, vítejte v ' + Constants.APP_NAME + '.',
'dashboard.create-tile': 'Vytvořit záznam',
Expand Down
1 change: 1 addition & 0 deletions js/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default {
'main.logout': 'Logout',
'main.my-profile': 'My profile',
'main.history': 'History',
'main.impersonating': 'Impersonating',

'dashboard.welcome': 'Hello {name}, Welcome to ' + Constants.APP_NAME + '.',
'dashboard.create-tile': 'Create record',
Expand Down
13 changes: 12 additions & 1 deletion js/utils/SecurityUtils.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import {getOidcIdentityStorageKey} from "./OidcUtils";
import {getOidcIdentityStorageKey, isUsingOidcAuth} from "./OidcUtils";
import {sanitizeArray} from "./Utils";
import {IMPERSONATOR_TYPE} from "../constants/Vocabulary";

export function getOidcToken() {
const identityData = sessionStorage.getItem(getOidcIdentityStorageKey());
const identity = identityData ? JSON.parse(identityData) : null;
return `${identity?.token_type} ${identity?.access_token}`;
}

export function saveOidcToken(token) {
sessionStorage.setItem(getOidcIdentityStorageKey(), JSON.stringify(token));
}

export function clearToken() {
sessionStorage.removeItem(getOidcIdentityStorageKey());
}

export function isImpersonator(currentUser) {
// When using OIDC, the access token does not contain any info that the current user is being impersonated
return !isUsingOidcAuth() && sanitizeArray(currentUser.types).indexOf(IMPERSONATOR_TYPE) !== -1;
}
6 changes: 5 additions & 1 deletion js/utils/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,8 @@ export function formatDateWithMilliseconds(timestamp) {
("00" + date.getHours()).slice(-2) + ":" +
("00" + date.getMinutes()).slice(-2) + ":" +
("00" + date.getSeconds()).slice(-2) + ("00" + date.getMilliseconds()).slice(-2);
}
}

export function sanitizeArray(arr) {
return arr ? (Array.isArray(arr) ? arr : [arr]) : [];
}
Loading
Loading