diff --git a/modern/src/common/attributes/useServerAttributes.js b/modern/src/common/attributes/useServerAttributes.js index 5cce479efb..4339840e87 100644 --- a/modern/src/common/attributes/useServerAttributes.js +++ b/modern/src/common/attributes/useServerAttributes.js @@ -35,6 +35,14 @@ export default (t) => useMemo(() => ({ name: t('settingsDarkMode'), type: 'boolean', }, + totpEnable: { + name: t('settingsTotpEnable'), + type: 'boolean', + }, + totpForce: { + name: t('settingsTotpForce'), + type: 'boolean', + }, 'ui.disableLoginLanguage': { name: t('attributeUiDisableLoginLanguage'), type: 'boolean', diff --git a/modern/src/login/LoginPage.jsx b/modern/src/login/LoginPage.jsx index 73104def06..6cca2837ab 100644 --- a/modern/src/login/LoginPage.jsx +++ b/modern/src/login/LoginPage.jsx @@ -57,6 +57,7 @@ const LoginPage = () => { const [email, setEmail] = usePersistedState('loginEmail', ''); const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); const registrationEnabled = useSelector((state) => state.session.server.registration); const languageEnabled = useSelector((state) => !state.session.server.attributes['ui.disableLoginLanguage']); @@ -64,6 +65,7 @@ const LoginPage = () => { const emailEnabled = useSelector((state) => state.session.server.emailEnabled); const openIdEnabled = useSelector((state) => state.session.server.openIdEnabled); const openIdForced = useSelector((state) => state.session.server.openIdEnabled && state.session.server.openIdForce); + const [codeEnabled, setCodeEnabled] = useState(false); const [announcementShown, setAnnouncementShown] = useState(false); const announcement = useSelector((state) => state.session.server.announcement); @@ -89,16 +91,20 @@ const LoginPage = () => { const handlePasswordLogin = async (event) => { event.preventDefault(); + setFailed(false); try { + const query = `email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`; const response = await fetch('/api/session', { method: 'POST', - body: new URLSearchParams(`email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`), + body: new URLSearchParams(code.length ? query + `&code=${code}` : query), }); if (response.ok) { const user = await response.json(); generateLoginToken(); dispatch(sessionActions.updateUser(user)); navigate('/'); + } else if (response.status === 401 && response.headers.get('WWW-Authenticate') === 'TOTP') { + setCodeEnabled(true); } else { throw Error(await response.text()); } @@ -120,7 +126,7 @@ const LoginPage = () => { }); const handleSpecialKey = (e) => { - if (e.keyCode === 13 && email && password) { + if (e.keyCode === 13 && email && password && (!codeEnabled || code)) { handlePasswordLogin(e); } }; @@ -179,12 +185,24 @@ const LoginPage = () => { onChange={(e) => setPassword(e.target.value)} onKeyUp={handleSpecialKey} /> + {codeEnabled && ( + setCode(e.target.value)} + onKeyUp={handleSpecialKey} + /> + )} diff --git a/modern/src/login/RegisterPage.jsx b/modern/src/login/RegisterPage.jsx index 6dfe40a4b6..1ec791a18c 100644 --- a/modern/src/login/RegisterPage.jsx +++ b/modern/src/login/RegisterPage.jsx @@ -9,7 +9,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import LoginLayout from './LoginLayout'; import { useTranslation } from '../common/components/LocalizationProvider'; import { snackBarDurationShortMs } from '../common/util/duration'; -import { useCatch } from '../reactHelper'; +import { useCatch, useEffectAsync } from '../reactHelper'; import { sessionActions } from '../store'; const useStyles = makeStyles((theme) => ({ @@ -37,17 +37,30 @@ const RegisterPage = () => { const t = useTranslation(); const server = useSelector((state) => state.session.server); + const totpForce = useSelector((state) => state.session.server.attributes.totpForce); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [totpKey, setTotpKey] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); + useEffectAsync(async () => { + if (totpForce) { + const response = await fetch('/api/users/totp', { method: 'POST' }); + if (response.ok) { + setTotpKey(await response.text()); + } else { + throw Error(await response.text()); + } + } + }, [totpForce, setTotpKey]); + const handleSubmit = useCatch(async () => { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, email, password }), + body: JSON.stringify({ name, email, password, totpKey }), }); if (response.ok) { setSnackbarOpen(true); @@ -96,6 +109,17 @@ const RegisterPage = () => { autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} /> + {totpForce && ( + + )}