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 package databag
import ( import (
"bytes"
"net/http" "net/http"
"image/png"
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"databag/internal/store" "databag/internal/store"
"encoding/base64"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -51,6 +54,14 @@ func AddMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
SetStatus(account) var buf bytes.Buffer
WriteResponse(w, account.MFASecret) 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, MFASecret{ Image: "data:image/png;base64," + enc, Text: account.MFASecret })
} }

View File

@ -2,6 +2,7 @@ package databag
import ( import (
"databag/internal/store" "databag/internal/store"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"gorm.io/gorm" "gorm.io/gorm"
"net/http" "net/http"
@ -34,7 +35,8 @@ func SetMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
return; 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 { err := store.DB.Transaction(func(tx *gorm.DB) error {
if account.MFAFailedTime + APPMFAFailPeriod > curTime { if account.MFAFailedTime + APPMFAFailPeriod > curTime {
account.MFAFailedCount += 1 account.MFAFailedCount += 1
@ -61,6 +63,23 @@ func SetMultiFactorAuth(w http.ResponseWriter, r *http.Request) {
return 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) SetStatus(account)
WriteResponse(w, nil) WriteResponse(w, nil)
} }

View File

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

View File

@ -53,6 +53,13 @@ type Announce struct {
AppToken string `json:"appToken"` 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 //Notification describes type of notifications to receive
type Notification struct { type Notification struct {
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`

View File

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

View File

@ -8,7 +8,7 @@ export function createWebsocket(url) {
export function checkResponse(response) { export function checkResponse(response) {
if(response.status >= 400 && response.status < 600) { 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); await removeAccountMFA(access.current);
}, },
confirmMFA: async (code) => { confirmMFA: async (code) => {
console.log("CONFIRMING: ", code);
await setAccountMFA(access.current, code); await setAccountMFA(access.current, code);
}, },
setSeal: async (seal, sealKey) => { setSeal: async (seal, sealKey) => {

View File

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

View File

@ -3,7 +3,7 @@ import { AccountItemWrapper, AccessLayout, DeleteButton, EnableButton, DisableBu
import { useAccountItem } from './useAccountItem.hook'; import { useAccountItem } from './useAccountItem.hook';
import { ExclamationCircleOutlined, UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { ExclamationCircleOutlined, UserDeleteOutlined, UnlockOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { Modal, Tooltip, Button } from 'antd'; import { Modal, Tooltip, Button } from 'antd';
import { CopyButton } from '../copyButton/CopyButton'; import { CopyButton } from '../../copyButton/CopyButton';
export function AccountItem({ item, remove }) { 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 { useAccountAccess } from './useAccountAccess.hook';
import { Button, Modal, Switch, Input, Radio, Select } from 'antd'; import { Button, Modal, Switch, Input, Radio, Select, Flex, Typography } from 'antd';
import { LogoutOutlined, SettingOutlined, UserOutlined, LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; 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'; import { useRef } from 'react';
export function AccountAccess() { export function AccountAccess() {
@ -40,7 +43,6 @@ export function AccountAccess() {
}; };
const enableMFA = async (enable) => { const enableMFA = async (enable) => {
console.log("ENABLE: ", enable);
try { try {
if (enable) { if (enable) {
await actions.enableMFA(); await actions.enableMFA();
@ -264,8 +266,30 @@ console.log("ENABLE: ", enable);
</div> </div>
</LoginModal> </LoginModal>
</Modal> </Modal>
<Modal centerd closable={false} footer={null} visible={state.mfaModal} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}> <Modal centerd closable={false} footer={null} visible={state.mfaModal} destroyOnClose={true} bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} onCancel={actions.dismissMFA}>
<div>{ state.mfaSecret }</div> <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> </Modal>
</AccountAccessWrapper> </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` export const LoginModal = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

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

File diff suppressed because it is too large Load Diff