diff --git a/README.md b/README.md index 6784dcee2..d77ac1c6e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Sample content of this file is following: "PERSISTENT_TOKENS_KEY_PREFIX": "recodex", "ENVIRONMENTS_INFO_URL": "https://github.com/ReCodEx/wiki/wiki/Runtime-Environments", "ALLOW_LOCAL_REGISTRATION": false, + "SHORT_SESSION": 15, "EXTERNAL_AUTH_URL": "https://some.other.domain/cas/", "EXTERNAL_AUTH_SERVICE_ID": "id-from-core-api", "EXTERNAL_AUTH_NAME": { @@ -105,6 +106,7 @@ Meaning of individual values: * `PERSISTENT_TOKENS_KEY_PREFIX` - Prefix used for security token identifiers (in cookies or in local storage). If you run multiple ReCodEx instances on the same domain, it might be necessary to give each instance different prefix. * `ENVIRONMENTS_INFO_URL` - Link to a web page where individual runtime environments are explained (default refers to our wiki). * `ALLOW_LOCAL_REGISTRATION` - Allows or disables different forms for registration. Note that this configuration should match which registration types are supported by the API. +* `SHORT_SESSION` - Default expiration time (in minutes) of short sessions (if supported by the API). If set to `0` (or omitted), short sessions are disabled. * `EXTERNAL_AUTH_URL` - URL of external authentication service (that implements [ReCodEx protocol](https://github.com/ReCodEx/wiki/wiki/External-Authenticators)). * `EXTERNAL_AUTH_SERVICE_ID` - Identifier (name) of the external authenticator as specified in core-api configuration (and in database). * `EXTERNAL_AUTH_NAME` - Caption (string) or object with localized captions (keys are locales) of the service (will be displayed in UI). diff --git a/etc/env.json.example b/etc/env.json.example index f991449a5..07988fb54 100644 --- a/etc/env.json.example +++ b/etc/env.json.example @@ -7,6 +7,7 @@ "PERSISTENT_TOKENS_KEY_PREFIX": "recodex", "ENVIRONMENTS_INFO_URL": "https://github.com/ReCodEx/wiki/wiki/Runtime-Environments", "ALLOW_LOCAL_REGISTRATION": false, + "SHORT_SESSION": 15, "EXTERNAL_AUTH_URL": "https://some.other.domain/cas/", "EXTERNAL_AUTH_SERVICE_ID": "id-from-core-api", "EXTERNAL_AUTH_NAME": { diff --git a/src/components/forms/LoginForm/LoginForm.js b/src/components/forms/LoginForm/LoginForm.js index df04a2ccf..8c98ba6f3 100644 --- a/src/components/forms/LoginForm/LoginForm.js +++ b/src/components/forms/LoginForm/LoginForm.js @@ -8,9 +8,18 @@ import { SignInIcon, SuccessIcon, LoadingIcon } from '../../icons'; import FormBox from '../../widgets/FormBox'; import Button from '../../widgets/TheButton'; import Callout from '../../widgets/Callout'; -import { EmailField, PasswordField } from '../Fields'; +import Explanation from '../../widgets/Explanation'; +import { CheckboxField, EmailField, PasswordField } from '../Fields'; -const LoginForm = ({ invalid, handleSubmit, submitFailed: hasFailed, submitting, hasSucceeded, error }) => ( +const LoginForm = ({ + shortSession = null, + invalid, + handleSubmit, + submitFailed: hasFailed, + submitting, + hasSucceeded, + error, +}) => ( } type={hasSucceeded ? 'success' : undefined} @@ -61,10 +70,32 @@ const LoginForm = ({ invalid, handleSubmit, submitFailed: hasFailed, submitting, tabIndex={2} label={} /> + + {Boolean(shortSession) && shortSession !== 0 && ( + + ({shortSession} min) + + + + + } + /> + )} ); LoginForm.propTypes = { + shortSession: PropTypes.number, onSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired, invalid: PropTypes.bool, diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 69eded220..f798ff829 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -85,6 +85,7 @@ export const LoadingIcon = ({ className = '', ...props }) => ( ); export const LocalIcon = props => ; export { LockIcon }; +export const LogoutIcon = props => ; export const MailIcon = props => ; export { NeedFixingIcon }; export const NoteIcon = props => ; diff --git a/src/components/widgets/Sidebar/UserPanel/UserPanel.js b/src/components/widgets/Sidebar/UserPanel/UserPanel.js index 124976af9..8fae5b7b8 100644 --- a/src/components/widgets/Sidebar/UserPanel/UserPanel.js +++ b/src/components/widgets/Sidebar/UserPanel/UserPanel.js @@ -7,7 +7,7 @@ import { Tooltip, OverlayTrigger, Modal } from 'react-bootstrap'; import UserName from '../../../Users/UsersName'; import EffectiveRoleSwitching from '../../../Users/EffectiveRoleSwitching'; import withLinks from '../../../../helpers/withLinks.js'; -import Icon from '../../../icons'; +import { EditIcon, LogoutIcon } from '../../../icons'; import { isSuperadminRole, UserRoleIcon, roleLabels } from '../../../helpers/usersRoles.js'; import './userPanel.css'; @@ -50,7 +50,7 @@ class UserPanel extends Component {
- + @@ -76,7 +76,7 @@ class UserPanel extends Component { e.preventDefault(); logout(); }}> - + diff --git a/src/containers/ExternalLogin/ExternalLoginBox.js b/src/containers/ExternalLogin/ExternalLoginBox.js index 404c9631a..26f44f0b9 100644 --- a/src/containers/ExternalLogin/ExternalLoginBox.js +++ b/src/containers/ExternalLogin/ExternalLoginBox.js @@ -6,7 +6,10 @@ import { FormattedMessage, injectIntl } from 'react-intl'; import Button from '../../components/widgets/TheButton'; import Box from '../../components/widgets/Box'; import Callout from '../../components/widgets/Callout'; +import Explanation from '../../components/widgets/Explanation'; import { LinkIcon, LoadingIcon, SuccessIcon } from '../../components/icons'; +import NiceCheckbox from '../../components/forms/NiceCheckbox'; + import { externalLogin, externalLoginFailed, statusTypes } from '../../redux/modules/auth.js'; import { statusSelector, loginErrorSelector } from '../../redux/selectors/auth.js'; import { hasErrorMessage, getErrorMessage } from '../../locales/apiErrorMessages.js'; @@ -17,7 +20,7 @@ export const openPopupWindow = url => : null; class ExternalLoginBox extends Component { - state = { pending: false, lastError: null }; + state = { pending: false, lastError: null, short: false }; constructor(props) { super(props); @@ -25,14 +28,19 @@ class ExternalLoginBox extends Component { this.pollPopupClosed = null; } + setShort = () => { + this.setState({ short: !this.state.short }); + }; + // Handle the messages from our popup window... messageHandler = e => { + const { shortSessionConfig, login } = this.props; const token = e.data; // the message should be the external JWT token if (token !== null && e.source === this.popupWindow && this.popupWindow !== null) { // cancel the window and the interval this.popupWindow.postMessage('received', e.origin); - this.props.login(token, this.popupWindow, error => { + login(token, this.state.short && shortSessionConfig ? shortSessionConfig * 60 : null, this.popupWindow, error => { if (hasErrorMessage(error)) { this.setState({ lastError: error }); } @@ -93,6 +101,7 @@ class ExternalLoginBox extends Component { const { name, helpUrl, + shortSessionConfig = null, loginStatus, loginError, intl: { formatMessage }, @@ -150,6 +159,18 @@ class ExternalLoginBox extends Component {

)} + {shortSessionConfig && shortSessionConfig > 0 && ( + + ({shortSessionConfig} min) + + + + + )} + {!pending && (loginStatus === statusTypes.LOGIN_FAILED || this.state.lastError) && ( {loginError || this.state.lastError ? ( @@ -170,6 +191,7 @@ ExternalLoginBox.propTypes = { url: PropTypes.string.isRequired, service: PropTypes.string.isRequired, helpUrl: PropTypes.string, + shortSessionConfig: PropTypes.number, loginStatus: PropTypes.string, loginError: PropTypes.object, login: PropTypes.func.isRequired, @@ -184,8 +206,8 @@ export default connect( loginError: loginErrorSelector(state, service), }), (dispatch, { service, afterLogin = null }) => ({ - login: (token, popupWindow, errorHandler = null) => { - const promise = dispatch(externalLogin(service, token, popupWindow)); + login: (token, expiration, popupWindow, errorHandler = null) => { + const promise = dispatch(externalLogin(service, token, expiration, popupWindow)); return (afterLogin ? promise.then(afterLogin) : promise).catch(e => errorHandler && errorHandler(e)); }, fail: () => dispatch(externalLoginFailed(service)), diff --git a/src/locales/cs.json b/src/locales/cs.json index de4a27d97..3e9478566 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1271,6 +1271,8 @@ "app.loginForm.login": "Přihlásit se", "app.loginForm.password": "Heslo:", "app.loginForm.processing": "Ověřují se přihlašovací údaje...", + "app.loginForm.short": "Krátká doba expirace relace", + "app.loginForm.short.explanation": "Použijte krátkou expiraci relace na veřejných počítačích, abyste snížili riziko neoprávněného přístupu k vašemu účtu (pokud se zapomenete odhlásit).", "app.loginForm.success": "Přihlášení bylo úspěšné", "app.loginForm.title": "Přihlásit se lokálním účtem", "app.loginForm.validation.emailIsNotAnEmail": "E-mailová adresa není zadána správně.", @@ -2074,6 +2076,8 @@ "app.users.createUser": "Vytvořit uživatele", "app.users.createUser.userCreated": "Uživatelský účet byl vytvořen.", "app.users.listTitle": "Uživatelé", + "app.users.logoutEverywhere": "Odhlásit ze všech zařízení", + "app.users.logoutEverywhereConfirm": "Tato operace zneplatní všechny autentizační tokeny uživatele vytvořené před tímto okamžikem. Opravdu chcete pokračovat?", "app.users.takeOver": "Přihlásit jako", "app.users.title": "Seznam všech uživatelů", "app.users.userCreatedAt": "Uživatel vytvořen", @@ -2174,4 +2178,4 @@ "recodex-judge-shuffle-all": "Sudí neuspořádaných tokenů a řádků", "recodex-judge-shuffle-newline": "Sudí neuspořádaných tokenů (ignorující konce řádků)", "recodex-judge-shuffle-rows": "Sudí neuspořádaných řádků" -} \ No newline at end of file +} diff --git a/src/locales/en.json b/src/locales/en.json index bbe3bd429..e7041788a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1271,6 +1271,8 @@ "app.loginForm.login": "Sign in", "app.loginForm.password": "Password:", "app.loginForm.processing": "Signing in...", + "app.loginForm.short": "Short session", + "app.loginForm.short.explanation": "Use short session on public computers to reduce the risk of unauthorized access to your account (if you forget to log out).", "app.loginForm.success": "You are successfully signed in", "app.loginForm.title": "Sign-in by Local Account", "app.loginForm.validation.emailIsNotAnEmail": "E-mail address is not valid.", @@ -2074,6 +2076,8 @@ "app.users.createUser": "Create User", "app.users.createUser.userCreated": "The user account was created.", "app.users.listTitle": "Users", + "app.users.logoutEverywhere": "Logout From All Sessions", + "app.users.logoutEverywhereConfirm": "This operation will invalidate all access tokens of the user generated before this moment. Are you sure you want to proceed?", "app.users.takeOver": "Login as", "app.users.title": "List of All Users", "app.users.userCreatedAt": "User created", @@ -2174,4 +2178,4 @@ "recodex-judge-shuffle-all": "Unordered-tokens-and-rows judge", "recodex-judge-shuffle-newline": "Unordered-tokens judge (ignoring ends of lines)", "recodex-judge-shuffle-rows": "Unordered-rows judge" -} \ No newline at end of file +} diff --git a/src/pages/EditUser/EditUser.js b/src/pages/EditUser/EditUser.js index 8185766b1..0ce8000d1 100644 --- a/src/pages/EditUser/EditUser.js +++ b/src/pages/EditUser/EditUser.js @@ -10,7 +10,7 @@ import Page from '../../components/layout/Page'; import { UserNavigation } from '../../components/layout/Navigation'; import NotVerifiedEmailCallout from '../../components/Users/NotVerifiedEmailCallout'; import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; -import { LocalIcon, TransferIcon, EditUserIcon } from '../../components/icons'; +import { EditUserIcon, LoadingIcon, LocalIcon, LogoutIcon, TransferIcon } from '../../components/icons'; import { isStudentRole } from '../../components/helpers/usersRoles.js'; import AllowUserButtonContainer from '../../containers/AllowUserButtonContainer'; import EditUserProfileForm, { @@ -38,9 +38,11 @@ import { updateUIData, makeLocalLogin, setRole, + invalidateTokens, } from '../../redux/modules/users.js'; import { getUser, isLoggedAsSuperAdmin } from '../../redux/selectors/users.js'; -import { generateToken, takeOver } from '../../redux/modules/auth.js'; +import { generateToken, takeOver, logout } from '../../redux/modules/auth.js'; +import { addNotification } from '../../redux/modules/notifications.js'; import { lastGeneratedToken, loggedInUserIdSelector } from '../../redux/selectors/auth.js'; import { fetchUserCalendarsIfNeeded, @@ -88,6 +90,8 @@ class EditUser extends Component { this.user = null; } + state = { invalidateTokensInProgress: false }; + static loadAsync = ({ userId }, dispatch) => Promise.all([dispatch(fetchUserIfNeeded(userId)), dispatch(fetchUserCalendarsIfNeeded(userId))]); @@ -114,6 +118,31 @@ class EditUser extends Component { return setRole(role); }; + invalidateTokens = async () => { + const { + params: { userId }, + loggedUserId, + invalidateTokens, + logout, + addNotification, + } = this.props; + + this.setState({ invalidateTokensInProgress: true }); + + try { + await invalidateTokens(); + } catch (e) { + addNotification(e.message, false); + this.setState({ invalidateTokensInProgress: false }); + return; + } + + if (userId === loggedUserId) { + await logout(); + } + this.setState({ invalidateTokensInProgress: false }); + }; + render() { const { user, @@ -146,8 +175,8 @@ class EditUser extends Component { /> )} - {data && (!data.privateData.isLocal || (isSuperAdmin && data.id !== loggedUserId)) && ( -
+ {data && (isSuperAdmin || data.id === loggedUserId) && ( +
{!data.privateData.isLocal && ( + )} +
)} @@ -269,6 +321,9 @@ EditUser.propTypes = { takeOver: PropTypes.func.isRequired, createCalendar: PropTypes.func.isRequired, setCalendarExpired: PropTypes.func.isRequired, + invalidateTokens: PropTypes.func.isRequired, + logout: PropTypes.func.isRequired, + addNotification: PropTypes.func.isRequired, }; export default connect( @@ -307,5 +362,8 @@ export default connect( takeOver: userId => dispatch(takeOver(userId)), createCalendar: () => dispatch(createUserCalendar(userId)), setCalendarExpired: calendarId => dispatch(setUserCalendarExpired(userId, calendarId)), + invalidateTokens: () => dispatch(invalidateTokens(userId)), + logout: () => dispatch(logout()), + addNotification: (msg, successful) => dispatch(addNotification(msg, successful)), }) )(EditUser); diff --git a/src/pages/Login/Login.js b/src/pages/Login/Login.js index fcaa5706c..04b5674e9 100644 --- a/src/pages/Login/Login.js +++ b/src/pages/Login/Login.js @@ -30,6 +30,7 @@ import { withRouterProps } from '../../helpers/withRouter.js'; const EXTERNAL_AUTH_URL = getConfigVar('EXTERNAL_AUTH_URL'); const EXTERNAL_AUTH_SERVICE_ID = getConfigVar('EXTERNAL_AUTH_SERVICE_ID'); const EXTERNAL_AUTH_HELPDESK_URL = getConfigVar('EXTERNAL_AUTH_HELPDESK_URL'); +const SHORT_SESSION = getConfigVar('SHORT_SESSION'); class Login extends Component { /** @@ -77,12 +78,12 @@ class Login extends Component { /** * Log the user in (by given credentials) and then perform the redirect. */ - loginAndRedirect = credentials => { + loginAndRedirect = ({ short, ...credentials }) => { const { login, intl: { formatMessage }, } = this.props; - return login(credentials) + return login(credentials, short && SHORT_SESSION ? SHORT_SESSION * 60 : null) .then(this.redirectAfterLogin) .catch(error => { // Translate fetch response error into form error message... @@ -155,8 +156,9 @@ class Login extends Component { lg={{ span: 4, offset: external ? 1 : 4 }} md={{ span: 6, offset: external ? 0 : 3 }} sm={{ span: 10, offset: 2 }} - xs={{ spna: 12, offset: 0 }}> - + xs={{ span: 12, offset: 0 }}> + +

)} @@ -215,7 +218,7 @@ export default withLinks( loggedInUser: loggedInUserSelector(state), }), dispatch => ({ - login: ({ email, password }) => dispatch(login(email, password)), + login: ({ email, password }, expiration) => dispatch(login(email, password, expiration)), logout: () => dispatch(logout()), reset: () => { dispatch(reset('login')); diff --git a/src/redux/modules/auth.js b/src/redux/modules/auth.js index 62a06837d..b6ea61a1f 100644 --- a/src/redux/modules/auth.js +++ b/src/redux/modules/auth.js @@ -26,12 +26,12 @@ export const takeOver = userId => meta: { service: LOCAL_LOGIN }, }); -export const login = (username, password) => +export const login = (username, password, expiration = null) => createApiAction({ type: actionTypes.LOGIN, method: 'POST', endpoint: '/login', - body: { username, password }, + body: { username, password, expiration }, meta: { service: LOCAL_LOGIN }, }); @@ -62,12 +62,12 @@ export const validatePasswordStrength = password => body: { password }, }); -export const externalLogin = (service, token, popupWindow = null) => +export const externalLogin = (service, token, expiration = null, popupWindow = null) => createApiAction({ type: actionTypes.LOGIN, method: 'POST', endpoint: `/login/${service}`, - body: { token }, + body: { token, expiration }, meta: { service, popupWindow }, }); diff --git a/src/redux/modules/users.js b/src/redux/modules/users.js index 6934eed7d..997244a6d 100644 --- a/src/redux/modules/users.js +++ b/src/redux/modules/users.js @@ -27,6 +27,7 @@ export const additionalActionTypes = { ...createActionsWithPostfixes('SET_IS_ALLOWED', 'recodex/users'), ...createActionsWithPostfixes('INVITE_USER', 'recodex/users'), ...createActionsWithPostfixes('ACCEPT_INVITATION', 'recodex/users'), + ...createActionsWithPostfixes('INVALIDATE_TOKENS', 'recodex/users'), }; const resourceName = 'users'; @@ -113,6 +114,14 @@ export const acceptInvitation = (password, token) => body: { token, password, passwordConfirm: password }, }); +export const invalidateTokens = id => + createApiAction({ + type: additionalActionTypes.INVALIDATE_TOKENS, + endpoint: `/users/${id}/invalidate-tokens`, + method: 'POST', + meta: { id }, + }); + /** * Reducer */ diff --git a/test/redux/modules/auth-test.js b/test/redux/modules/auth-test.js index b18b2e9f0..65d7e2c8e 100644 --- a/test/redux/modules/auth-test.js +++ b/test/redux/modules/auth-test.js @@ -30,7 +30,7 @@ describe('Authentication', () => { type: actionTypes.LOGIN, method: 'POST', endpoint: '/login', - body: { username: 'usr', password: 'pwd' }, + body: { username: 'usr', password: 'pwd', expiration: null }, meta: { service: LOCAL_LOGIN }, }); }); @@ -45,6 +45,7 @@ describe('Authentication', () => { endpoint: `/login/${serviceId}`, body: { token, + expiration: null, }, meta: { service: serviceId, popupWindow: null }, });