From e8ffae6ffaf8870c2461714991904a0a7285c1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 30 Jan 2026 17:14:29 +0100 Subject: [PATCH 1/4] Adding short session option to standard login form. --- README.md | 2 ++ etc/env.json.example | 1 + src/components/forms/LoginForm/LoginForm.js | 35 +++++++++++++++++++-- src/locales/cs.json | 4 ++- src/locales/en.json | 2 ++ src/pages/Login/Login.js | 12 ++++--- src/redux/modules/auth.js | 4 +-- 7 files changed, 50 insertions(+), 10 deletions(-) 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/locales/cs.json b/src/locales/cs.json index de4a27d97..3b66573dc 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ě.", @@ -2174,4 +2176,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..f213bbfed 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.", diff --git a/src/pages/Login/Login.js b/src/pages/Login/Login.js index fcaa5706c..84228a6ba 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 }}> + +

({ - 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..1d97c805d 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 }, }); From 74c468ba9c94c3b6d5345e2974a0486c6caca2dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 31 Jan 2026 14:08:23 +0100 Subject: [PATCH 2/4] Implementing short sessions for external authentication. --- .../ExternalLogin/ExternalLoginBox.js | 30 ++++++++++++++++--- src/pages/Login/Login.js | 1 + src/redux/modules/auth.js | 4 +-- 3 files changed, 29 insertions(+), 6 deletions(-) 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/pages/Login/Login.js b/src/pages/Login/Login.js index 84228a6ba..04b5674e9 100644 --- a/src/pages/Login/Login.js +++ b/src/pages/Login/Login.js @@ -182,6 +182,7 @@ class Login extends Component { service={EXTERNAL_AUTH_SERVICE_ID} helpUrl={EXTERNAL_AUTH_HELPDESK_URL} afterLogin={this.redirectAfterLogin} + shortSessionConfig={SHORT_SESSION} /> )} diff --git a/src/redux/modules/auth.js b/src/redux/modules/auth.js index 1d97c805d..b6ea61a1f 100644 --- a/src/redux/modules/auth.js +++ b/src/redux/modules/auth.js @@ -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 }, }); From 0773c43da62f95b981d8bf52b11012ec4405925e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 31 Jan 2026 14:15:10 +0100 Subject: [PATCH 3/4] Fixing tests. --- test/redux/modules/auth-test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }, }); From 28c8ef1eab783ad4964d937259b868a41eb7e630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Sat, 31 Jan 2026 15:18:11 +0100 Subject: [PATCH 4/4] Adding a "logout from all sessions" button that invalidates all previously generated JWTs. --- src/components/icons/index.js | 1 + .../widgets/Sidebar/UserPanel/UserPanel.js | 6 +- src/locales/cs.json | 2 + src/locales/en.json | 4 +- src/pages/EditUser/EditUser.js | 66 +++++++++++++++++-- src/redux/modules/users.js | 9 +++ 6 files changed, 80 insertions(+), 8 deletions(-) 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/locales/cs.json b/src/locales/cs.json index 3b66573dc..3e9478566 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -2076,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", diff --git a/src/locales/en.json b/src/locales/en.json index f213bbfed..e7041788a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2076,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", @@ -2176,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/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 */