diff --git a/doc/api.oa3 b/doc/api.oa3 index c0bd6e81..e0f31af4 100644 --- a/doc/api.oa3 +++ b/doc/api.oa3 @@ -681,6 +681,8 @@ paths: description: success '401': description: permission denied + '403': + description: totp code not correct '405': description: totp code required but not set '429': diff --git a/net/server/internal/api_addAccountApp.go b/net/server/internal/api_addAccountApp.go index ddd63e9c..5efd9184 100644 --- a/net/server/internal/api_addAccountApp.go +++ b/net/server/internal/api_addAccountApp.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "github.com/theckman/go-securerandom" "github.com/pquerna/otp/totp" + "github.com/pquerna/otp" "gorm.io/gorm" "net/http" "errors" @@ -33,7 +34,8 @@ func AddAccountApp(w http.ResponseWriter, r *http.Request) { return; } - if !totp.Validate(account.MFASecret, code) { + opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256} + if valid, _ := totp.ValidateCustom(code, account.MFASecret, time.Now(), opts); !valid { err := store.DB.Transaction(func(tx *gorm.DB) error { if account.MFAFailedTime + APPMFAFailPeriod > curTime { account.MFAFailedCount += 1; @@ -56,7 +58,7 @@ func AddAccountApp(w http.ResponseWriter, r *http.Request) { LogMsg("failed to increment fail count"); } - ErrResponse(w, http.StatusUnauthorized, errors.New("invalid code")) + ErrResponse(w, http.StatusForbidden, errors.New("invalid code")) return } } diff --git a/net/web/src/access/login/Login.jsx b/net/web/src/access/login/Login.jsx index 481dac2d..0d80f446 100644 --- a/net/web/src/access/login/Login.jsx +++ b/net/web/src/access/login/Login.jsx @@ -1,6 +1,6 @@ import { Button, Modal, Form, Input } from 'antd'; import { SettingOutlined, LockOutlined, UserOutlined } from '@ant-design/icons'; -import { LoginWrapper } from './Login.styled'; +import { LoginWrapper, MFAModal } from './Login.styled'; import { useLogin } from './useLogin.hook'; export function Login() { @@ -27,6 +27,8 @@ export function Login() { } } +console.log(state.mfaError); + return ( { modalContext } @@ -73,6 +75,27 @@ export function Login() { + + +
{state.strings.mfaTitle}
+
{state.strings.mfaEnter}
+ +
+ { state.mfaError == 'Error: 403' && ( + {state.strings.mfaError} + )} + { state.mfaError == 'Error: 429' && ( + {state.strings.mfaDisabled} + )} +
+
+ + +
+ +
+
); }; diff --git a/net/web/src/access/login/Login.styled.js b/net/web/src/access/login/Login.styled.js index 829979d6..69aa551b 100644 --- a/net/web/src/access/login/Login.styled.js +++ b/net/web/src/access/login/Login.styled.js @@ -80,4 +80,53 @@ export const LoginWrapper = styled.div` } `; +export const MFAModal = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + .title { + font-size: 1.2rem; + display: flex; + justify-content: center; + } + + .description { + font-size: 1.0rem; + padding-bottom: 8px; + } + + .code { + padding-top: 4px; + border-bottom: 1px solid ${props => props.theme.sectionBorder}; + } + + .alert { + height: 24px; + color: ${props => props.theme.alertText}; + } + + .controls { + width: 100%; + display: flex; + justify-content: flex-end; + gap: 16px; + + .saveDisabled { + background-color: ${props => props.theme.disabledArea}; + + button { + color: ${props => props.theme.idleText}; + } + } + + .saveEnabled { + background-color: ${props => props.theme.enabledArea}; + + button { + color: ${props => props.theme.activeText}; + } + } + } +` diff --git a/net/web/src/access/login/useLogin.hook.js b/net/web/src/access/login/useLogin.hook.js index dc27d49c..c6457b08 100644 --- a/net/web/src/access/login/useLogin.hook.js +++ b/net/web/src/access/login/useLogin.hook.js @@ -15,6 +15,9 @@ export function useLogin() { busy: false, strings: {}, menuStyle: {}, + mfaModal: false, + mfaCode: null, + mfaError: null, }); const navigate = useNavigate(); @@ -46,12 +49,17 @@ export function useLogin() { if (!state.busy && state.username !== '' && state.password !== '') { updateState({ busy: true }) try { - await app.actions.login(state.username, state.password) + await app.actions.login(state.username, state.password, state.mfaCode) } catch (err) { - console.log(err); - updateState({ busy: false }) - throw new Error('login failed: check your username and password'); + if (err == 'Error: 405' || err == 'Error: 403' || err == 'Error: 429') { + updateState({ busy: false, mfaModal: true, mfaError: err.toString() }); + } + else { + console.log(err); + updateState({ busy: false }) + throw new Error('login failed: check your username and password'); + } } updateState({ busy: false }) } @@ -59,6 +67,12 @@ export function useLogin() { onCreate: () => { navigate('/create'); }, + setCode: (mfaCode) => { + updateState({ mfaCode }); + }, + dismissMFA: () => { + updateState({ mfaModal: false, mfaCode: null }); + }, }; useEffect(() => { diff --git a/net/web/src/api/setLogin.js b/net/web/src/api/setLogin.js index df40a604..4be6e5ad 100644 --- a/net/web/src/api/setLogin.js +++ b/net/web/src/api/setLogin.js @@ -1,11 +1,12 @@ import { checkResponse, fetchWithTimeout } from './fetchUtil'; var base64 = require('base-64'); -export async function setLogin(username, password, appName, appVersion, userAgent) { +export async function setLogin(username, password, code, appName, appVersion, userAgent) { const platform = encodeURIComponent(userAgent); + const mfa = code ? `&code=${code}` : ''; let headers = new Headers() headers.append('Authorization', 'Basic ' + base64.encode(username + ":" + password)); - let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}`, { method: 'POST', body: JSON.stringify([]), headers: headers }) + let login = await fetchWithTimeout(`/account/apps?appName=${appName}&appVersion=${appVersion}&platform=${platform}${mfa}`, { method: 'POST', body: JSON.stringify([]), headers: headers }) checkResponse(login) return await login.json() } diff --git a/net/web/src/constants/Strings.js b/net/web/src/constants/Strings.js index 4f421af1..e68d30e9 100644 --- a/net/web/src/constants/Strings.js +++ b/net/web/src/constants/Strings.js @@ -188,6 +188,13 @@ export const en = { confirmRemove: 'Are you sure you want to delete the contact?', message: 'Message', securedMessage: 'Sealed Message', + + mfaTitle: 'Multi-Factor Authentication', + mfaSteps: 'Store the secret and confirm the verification code', + mfaError: 'verification code error', + mfaDisabled: 'verification temporarily disabled', + mfaConfirm: 'Confirm', + mfaEnter: 'Enter your verification code', }; export const fr = { @@ -381,6 +388,13 @@ export const fr = { message: 'Message', sealedMessage: 'Message Sécurisé', + + mfaTitle: 'Authentification Multi-Factor', + mfaSteps: 'Enregistrez le secret et confirmez le code de vérification', + mfaEnter: 'Entrez votre code de vérification', + mfaError: 'erreur de code de vérification', + mfaDisabled: 'vérification temporairement désactivée', + mfaConfirm: 'Confirmer', }; export const sp = { @@ -573,6 +587,13 @@ export const sp = { confirmRemove: '¿Estás seguro de que quieres eliminar el contacto?', message: 'Mensaje', sealedMessage: 'Mensaje Seguro', + + mfaTitle: 'Autenticación de Dos Factores', + mfaSteps: 'Guarde el secreto y confirme el código de verificación', + mfaEnter: 'Ingresa tu código de verificación', + mfaError: 'error de código de verificación', + mfaDisabled: 'verificación temporalmente deshabilitada', + mfaConfirm: 'Confirmar', }; export const pt = { @@ -765,6 +786,13 @@ export const pt = { confirmRemove: 'Tem certeza de que deseja remover o contato?', message: 'Mensagem', sealedMessage: 'Mensagem Segura', + + mfaTitle: 'Autenticação de Dois Fatores', + mfaSteps: 'Salve o segredo e confirme o código de verificação', + mfaEnter: 'Digite seu código de verificação', + mfaError: 'erro de código de verificação', + mfaDisabled: 'verificação temporariamente desativada', + mfaConfirm: 'Confirmar', }; export const de = { @@ -957,6 +985,13 @@ export const de = { confirmRemove: 'Sind Sie sicher, dass Sie den Kontakt löschen möchten?', message: 'Nachricht', sealedMessage: 'Gesicherte Nachricht', + + mfaTitle: 'Zwei-Faktor-Authentifizierung', + mfaSteps: 'Speichern Sie das Geheimnis und bestätigen Sie den Verifizierungscode', + mfaEnter: 'Geben Sie Ihren Bestätigungs-Code ein', + mfaError: 'Verifizierungscodefehler', + mfaDisabled: 'Verifizierung vorübergehend deaktiviert', + mfaConfirm: 'Bestätigen', }; export const ru = { @@ -1149,4 +1184,11 @@ export const ru = { confirmRemove: 'Вы уверены, что хотите удалить контакт?', message: 'Cообщение', sealedMessage: 'Защищенное Cообщение', + + mfaTitle: 'Двухфакторная аутентификация', + mfaSteps: 'Сохраните секрет и подтвердите проверочный код', + mfaEnter: 'Введите Ваш верификационный код', + mfaError: 'ошибка проверочного кода', + mfaDisabled: 'проверка временно отключена', + mfaConfirm: 'Подтвердить', }; diff --git a/net/web/src/context/useAppContext.hook.js b/net/web/src/context/useAppContext.hook.js index 8b488215..4ea1331e 100644 --- a/net/web/src/context/useAppContext.hook.js +++ b/net/web/src/context/useAppContext.hook.js @@ -77,8 +77,8 @@ export function useAppContext(websocket) { access: async (token) => { await appAccess(token) }, - login: async (username, password) => { - await appLogin(username, password) + login: async (username, password, code) => { + await appLogin(username, password, code) }, create: async (username, password, token) => { await appCreate(username, password, token) @@ -96,7 +96,7 @@ export function useAppContext(websocket) { throw new Error('invalid session state'); } await addAccount(username, password, token); - const access = await setLogin(username, password, appName, appVersion, userAgent); + const access = await setLogin(username, password, null, appName, appVersion, userAgent); storeContext.actions.setValue('login:timestamp', access.created); setSession(access.appToken); appToken.current = access.appToken; @@ -108,11 +108,11 @@ export function useAppContext(websocket) { return access.created; } - const appLogin = async (username, password) => { + const appLogin = async (username, password, code) => { if (appToken.current || !checked.current) { throw new Error('invalid session state'); } - const access = await setLogin(username, password, appName, appVersion, userAgent); + const access = await setLogin(username, password, code, appName, appVersion, userAgent); storeContext.actions.setValue('login:timestamp', access.created); setSession(access.appToken); appToken.current = access.appToken; diff --git a/net/web/src/session/account/profile/accountAccess/AccountAccess.jsx b/net/web/src/session/account/profile/accountAccess/AccountAccess.jsx index 82dba3c9..44be05eb 100644 --- a/net/web/src/session/account/profile/accountAccess/AccountAccess.jsx +++ b/net/web/src/session/account/profile/accountAccess/AccountAccess.jsx @@ -268,8 +268,8 @@ export function AccountAccess() { -
Multi-Factor Authentication
-
Store the secret and confirm the verification code
+
{state.strings.mfaTitle}
+
{state.strings.mfaSteps}
QRCode
{ state.mfaSecret }
@@ -278,16 +278,16 @@ export function AccountAccess() {
{ state.mfaError && state.mfaErrorCode == 'Error: 401' && ( - verification code error + {state.strings.mfaError} )} { state.mfaError && state.mfaErrorCode == 'Error: 429' && ( - verification temporarily disabled + {state.strings.mfaDisabled} )}
+ disabled={!state.mfaCode} loading={state.busy}>{state.strings.mfaConfirm}