sending mfa qr code for setup

This commit is contained in:
Roland Osborne 2024-05-16 15:11:39 -07:00
parent e5fe393b43
commit 810009f7aa
15 changed files with 550 additions and 330 deletions

View File

@ -1,10 +1,13 @@
package databag
import (
"bytes"
"net/http"
"image/png"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"databag/internal/store"
"encoding/base64"
"gorm.io/gorm"
)
@ -51,6 +54,14 @@ func AddMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
return
}
var buf bytes.Buffer
img, err := key.Image(200, 200)
if err != nil {
panic(err)
}
png.Encode(&buf, img)
enc := base64.StdEncoding.EncodeToString(buf.Bytes())
SetStatus(account)
WriteResponse(w, account.MFASecret)
WriteResponse(w, MFASecret{ Image: "data:image/png;base64," + enc, Text: account.MFASecret })
}

View File

@ -2,6 +2,7 @@ package databag
import (
"databag/internal/store"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"net/http"
@ -34,7 +35,8 @@ func SetMultiFactorAuth(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
@ -61,6 +63,23 @@ func SetMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
return
}
err = store.DB.Transaction(func(tx *gorm.DB) error {
account.MFAConfirmed = true
if res := tx.Model(account).Update("mfa_confirmed", account.MFAConfirmed).Error; res != nil {
ErrResponse(w, http.StatusInternalServerError, res)
return res
}
account.AccountRevision += 1;
if res := tx.Model(&account).Update("account_revision", account.AccountRevision).Error; res != nil {
return res
}
return nil
})
if err != nil {
ErrResponse(w, http.StatusInternalServerError, err)
return
}
SetStatus(account)
WriteResponse(w, nil)
}

View File

@ -148,7 +148,7 @@ const APPQueueDefault = ""
const APPDefaultPath = "/tmp/databag/assets"
//APPMFAIssuer name servive
const APPMFAIssuer = "databag"
const APPMFAIssuer = "Databag"
//APPMFAFailPeriod time window login failures can occur
const APPMFAFailPeriod = 300

View File

@ -53,6 +53,13 @@ type Announce struct {
AppToken string `json:"appToken"`
}
//MFASecret values for configuring TOTP
type MFASecret struct {
Image string `json:"secretImage"`
Text string `json:"secretText"`
}
//Notification describes type of notifications to receive
type Notification struct {
Event string `json:"event,omitempty"`

View File

@ -19,7 +19,7 @@
"@charliewilco/gluejar": "^1.0.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/user-event": "^13.2.1",
"antd": "^5.0.4",
"antd": "^5.17.2",
"axios": "^0.27.2",
"base-64": "^1.0.0",
"crypto-js": "^4.1.1",

View File

@ -8,7 +8,7 @@ export function createWebsocket(url) {
export function checkResponse(response) {
if(response.status >= 400 && response.status < 600) {
throw new Error(response.url + " failed");
throw new Error(response.status);
}
}

View File

@ -84,6 +84,8 @@ export function useAccountContext() {
await removeAccountMFA(access.current);
},
confirmMFA: async (code) => {
console.log("CONFIRMING: ", code);
await setAccountMFA(access.current, code);
},
setSeal: async (seal, sealKey) => {

View File

@ -4,7 +4,7 @@ import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutl
import { ThemeProvider } from "styled-components";
import { useDashboard } from './useDashboard.hook';
import { AccountItem } from './accountItem/AccountItem';
import { CopyButton } from './copyButton/CopyButton';
import { CopyButton } from '../copyButton/CopyButton';
export function Dashboard() {

View File

@ -3,7 +3,7 @@ import { AccountItemWrapper, AccessLayout, DeleteButton, EnableButton, DisableBu
import { useAccountItem } from './useAccountItem.hook';
import { ExclamationCircleOutlined, UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { Modal, Tooltip, Button } from 'antd';
import { CopyButton } from '../copyButton/CopyButton';
import { CopyButton } from '../../copyButton/CopyButton';
export function AccountItem({ item, remove }) {

View File

@ -1,7 +1,10 @@
import { AccountAccessWrapper, LoginModal, SealModal, LogoutContent } from './AccountAccess.styled';
import { AccountAccessWrapper, LoginModal, MFAModal, SealModal, LogoutContent } from './AccountAccess.styled';
import { useAccountAccess } from './useAccountAccess.hook';
import { Button, Modal, Switch, Input, Radio, Select } from 'antd';
import { LogoutOutlined, SettingOutlined, UserOutlined, LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Modal, Switch, Input, Radio, Select, Flex, Typography } from 'antd';
import type { GetProp } from 'antd';
import type { OTPProps } from 'antd/es/input/OTP';
import { LogoutOutlined, SettingOutlined, UserOutlined, LockOutlined, ExclamationCircleOutlined, KeyOutlined } from '@ant-design/icons';
import { CopyButton } from '../../../../copyButton/CopyButton';
import { useRef } from 'react';
export function AccountAccess() {
@ -40,7 +43,6 @@ export function AccountAccess() {
};
const enableMFA = async (enable) => {
console.log("ENABLE: ", enable);
try {
if (enable) {
await actions.enableMFA();
@ -264,8 +266,30 @@ console.log("ENABLE: ", enable);
</div>
</LoginModal>
</Modal>
<Modal centerd closable={false} footer={null} visible={state.mfaModal} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}>
<div>{ state.mfaSecret }</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">Multi-Factor Authentication</div>
<div className="description">Store the secret and confirm the verification code</div>
<img src={state.mfaImage} alt="QRCode" />
<div className="secret">
<div className="label">{ state.mfaSecret }</div>
<CopyButton onCopy={async () => await navigator.clipboard.writeText(state.mfaSecret)} />
</div>
<Input.OTP onChange={actions.setCode} />
<div className="alert">
{ state.mfaError && state.mfaErrorCode == 'Error: 401' && (
<span>verification code error</span>
)}
{ state.mfaError && state.mfaErrorCode == 'Error: 429' && (
<span>verification temporarily disabled</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>
</div>
</MFAModal>
</Modal>
</AccountAccessWrapper>
);

View File

@ -182,6 +182,73 @@ export const SealModal = 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;
}
.secret {
display: flex;
flex-direction: row;
gap: 8px;
.label {
font-weight: bold;
}
}
.code {
padding-top: 4px;
border-bottom: 1px solid ${props => props.theme.sectionBorder};
}
.codeLabel {
padding-top: 4px;
font-size: 0.9.rem;
color: ${props => props.theme.mainText};
}
.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};
}
}
}
`
export const LoginModal = styled.div`
display: flex;
flex-direction: column;

View File

@ -43,7 +43,10 @@ export function useAccountAccess() {
mfaModal: false,
mfaEnabled: null,
mfaSecret: null,
mfaImage: null,
mfaCode: null,
mfaError: false,
mfaErrorCode: null,
seal: null,
sealKey: null,
@ -319,8 +322,8 @@ export function useAccountAccess() {
if (!state.busy) {
try {
updateState({ busy: true });
const secret = await account.actions.enableMFA();
updateState({ busy: false, mfaModal: true, mfaSecret: secret, mfaCode: '' });
const mfa = await account.actions.enableMFA();
updateState({ busy: false, mfaModal: true, mfaError: false, mfaSecret: mfa.secretText, mfaImage: mfa.secretImage, mfaCode: '' });
}
catch (err) {
console.log(err);
@ -347,12 +350,12 @@ export function useAccountAccess() {
if (!state.busy) {
try {
updateState({ busy: true });
await account.actions.confirmMFA(state.code);
updateState({ busy: false });
await account.actions.confirmMFA(state.mfaCode);
updateState({ busy: false, mfaModal: false });
}
catch (err) {
console.log(err);
updateState({ busy: false });
console.log("error code: ", err);
updateState({ busy: false, mfaError: true, mfaErrorCode: err });
throw new Error('failed to confirm mfa');
}
}

File diff suppressed because it is too large Load Diff