allow for login with MFA token

This commit is contained in:
Roland Osborne 2024-05-16 18:45:48 -07:00
parent 810009f7aa
commit 5336d19608
9 changed files with 152 additions and 19 deletions

View File

@ -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':

View File

@ -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
}
}

View File

@ -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 (
<LoginWrapper>
{ modalContext }
@ -73,6 +75,27 @@ export function Login() {
</Form>
</div>
<Modal centerd closable={false} footer={null} visible={state.mfaModal} destroyOnClose={true} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}>
<MFAModal>
<div className="title">{state.strings.mfaTitle}</div>
<div className="description">{state.strings.mfaEnter}</div>
<Input.OTP onChange={actions.setCode} />
<div className="alert">
{ state.mfaError == 'Error: 403' && (
<span>{state.strings.mfaError}</span>
)}
{ state.mfaError == 'Error: 429' && (
<span>{state.strings.mfaDisabled}</span>
)}
</div>
<div className="controls">
<Button key="back" onClick={actions.dismissMFA}>{state.strings.cancel}</Button>
<Button key="save" type="primary" className={state.mfaCode ? 'saveEnabled' : 'saveDisabled'} onClick={login}
disabled={!state.mfaCode} loading={state.busy}>{state.strings.login}</Button>
</div>
</MFAModal>
</Modal>
</LoginWrapper>
);
};

View File

@ -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};
}
}
}
`

View File

@ -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(() => {

View File

@ -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()
}

View File

@ -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: 'Подтвердить',
};

View File

@ -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;

View File

@ -268,8 +268,8 @@ export function AccountAccess() {
</Modal>
<Modal centerd closable={false} footer={null} visible={state.mfaModal} destroyOnClose={true} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}>
<MFAModal>
<div className="title">Multi-Factor Authentication</div>
<div className="description">Store the secret and confirm the verification code</div>
<div className="title">{state.strings.mfaTitle}</div>
<div className="description">{state.strings.mfaSteps}</div>
<img src={state.mfaImage} alt="QRCode" />
<div className="secret">
<div className="label">{ state.mfaSecret }</div>
@ -278,16 +278,16 @@ export function AccountAccess() {
<Input.OTP onChange={actions.setCode} />
<div className="alert">
{ state.mfaError && state.mfaErrorCode == 'Error: 401' && (
<span>verification code error</span>
<span>{state.strings.mfaError}</span>
)}
{ state.mfaError && state.mfaErrorCode == 'Error: 429' && (
<span>verification temporarily disabled</span>
<span>{state.strings.mfaDisabled}</span>
)}
</div>
<div className="controls">
<Button key="back" onClick={actions.dismissMFA}>{state.strings.cancel}</Button>
<Button key="save" type="primary" className={state.mfaCode ? 'saveEnabled' : 'saveDisabled'} onClick={actions.confirmMFA}
disabled={!state.mfaCode} loading={state.busy}>Confirm</Button>
disabled={!state.mfaCode} loading={state.busy}>{state.strings.mfaConfirm}</Button>
</div>
</MFAModal>
</Modal>