diff --git a/net/server/internal/api_addAdminMFAuth.go b/net/server/internal/api_addAdminMFAuth.go index d7898e72..c7bd97c6 100644 --- a/net/server/internal/api_addAdminMFAuth.go +++ b/net/server/internal/api_addAdminMFAuth.go @@ -34,7 +34,7 @@ func AddAdminMFAuth(w http.ResponseWriter, r *http.Request) { if res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "config_id"}}, DoUpdates: clause.AssignmentColumns([]string{"bool_value"}), - }).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: false}).Error; res != nil { + }).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: true}).Error; res != nil { return res } @@ -42,7 +42,7 @@ func AddAdminMFAuth(w http.ResponseWriter, r *http.Request) { if res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "config_id"}}, DoUpdates: clause.AssignmentColumns([]string{"bool_value"}), - }).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: true}).Error; res != nil { + }).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: false}).Error; res != nil { return res } diff --git a/net/server/internal/api_addMultiFactorAuth.go b/net/server/internal/api_addMultiFactorAuth.go index b12a1da6..323ccb9e 100644 --- a/net/server/internal/api_addMultiFactorAuth.go +++ b/net/server/internal/api_addMultiFactorAuth.go @@ -22,7 +22,7 @@ func AddMultiFactorAuth(w http.ResponseWriter, r *http.Request) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: APPMFAIssuer, - AccountName: account.GUID, + AccountName: account.Handle, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256, }) diff --git a/net/server/internal/api_removeAdminMFAuth.go b/net/server/internal/api_removeAdminMFAuth.go index 32403c73..ecd0156d 100644 --- a/net/server/internal/api_removeAdminMFAuth.go +++ b/net/server/internal/api_removeAdminMFAuth.go @@ -19,13 +19,13 @@ func RemoveAdminMFAuth(w http.ResponseWriter, r *http.Request) { err := store.DB.Transaction(func(tx *gorm.DB) error { if res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "config_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"str_value"}), + DoUpdates: clause.AssignmentColumns([]string{"bool_value"}), }).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: false}).Error; res != nil { return res } if res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "config_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"str_value"}), + DoUpdates: clause.AssignmentColumns([]string{"bool_value"}), }).Create(&store.Config{ConfigID: CNFMFAEnabled, BoolValue: false}).Error; res != nil { return res } diff --git a/net/server/internal/api_setAdminAccess.go b/net/server/internal/api_setAdminAccess.go index d38a66f7..3e514a66 100644 --- a/net/server/internal/api_setAdminAccess.go +++ b/net/server/internal/api_setAdminAccess.go @@ -7,6 +7,10 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" "net/http" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "errors" + "time" ) //SetAdminAccess begins a session for admin access @@ -18,6 +22,60 @@ func SetAdminAccess(w http.ResponseWriter, r *http.Request) { return } + // check mfa + curTime := time.Now().Unix() + failedTime := getNumConfigValue(CNFMFAFailedTime, 0); + failedCount := getNumConfigValue(CNFMFAFailedCount, 0); + if failedTime + APPMFAFailPeriod > curTime && failedCount > APPMFAFailCount { + ErrResponse(w, http.StatusTooManyRequests, errors.New("temporarily locked")) + return; + } + + mfaEnabled := getBoolConfigValue(CNFMFAEnabled, false); + mfaConfirmed := getBoolConfigValue(CNFMFAConfirmed, false); + if mfaEnabled && mfaConfirmed { + code := r.FormValue("code") + if code == "" { + ErrResponse(w, http.StatusMethodNotAllowed, errors.New("totp code required")) + return; + } + + secret := getStrConfigValue(CNFMFASecret, ""); + opts := totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA256} + if valid, _ := totp.ValidateCustom(code, secret, time.Now(), opts); !valid { + err := store.DB.Transaction(func(tx *gorm.DB) error { + if failedTime + APPMFAFailPeriod > curTime { + if res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "config_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"num_value"}), + }).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil { + return res + } + } else { + if res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "config_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"num_value"}), + }).Create(&store.Config{ConfigID: CNFMFAFailedTime, NumValue: curTime}).Error; res != nil { + return res + } + if res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "config_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"num_value"}), + }).Create(&store.Config{ConfigID: CNFMFAFailedCount, NumValue: failedCount + 1}).Error; res != nil { + return res + } + } + return nil + }) + if err != nil { + LogMsg("failed to increment fail count"); + } + + ErrResponse(w, http.StatusForbidden, errors.New("invalid code")) + return + } + } + // gernate app token data, err := securerandom.Bytes(APPTokenSize) if err != nil { diff --git a/net/server/internal/api_setAdminMFAuth.go b/net/server/internal/api_setAdminMFAuth.go index 61e2333c..333b6e4a 100644 --- a/net/server/internal/api_setAdminMFAuth.go +++ b/net/server/internal/api_setAdminMFAuth.go @@ -77,7 +77,7 @@ func SetAdminMFAuth(w http.ResponseWriter, r *http.Request) { // upsert mfa confirmed if res := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "config_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"str_value"}), + DoUpdates: clause.AssignmentColumns([]string{"bool_value"}), }).Create(&store.Config{ConfigID: CNFMFAConfirmed, BoolValue: true}).Error; res != nil { return res } diff --git a/net/web/src/access/admin/Admin.jsx b/net/web/src/access/admin/Admin.jsx index 9e59c467..1840e57b 100644 --- a/net/web/src/access/admin/Admin.jsx +++ b/net/web/src/access/admin/Admin.jsx @@ -1,6 +1,6 @@ import { Button, Modal, Form, Input } from 'antd'; import { UserOutlined, LockOutlined } from '@ant-design/icons'; -import { AdminWrapper } from './Admin.styled'; +import { AdminWrapper, MFAModal } from './Admin.styled'; import { useAdmin } from './useAdmin.hook'; export function Admin() { @@ -55,6 +55,26 @@ export function Admin() { + + +
{state.strings.mfaTitle}
+
{state.strings.mfaEnter}
+ +
+ { state.mfaError === '403' && ( + {state.strings.mfaError} + )} + { state.mfaError === '429' && ( + {state.strings.mfaDisabled} + )} +
+
+ + +
+
+
); }; diff --git a/net/web/src/access/admin/Admin.styled.js b/net/web/src/access/admin/Admin.styled.js index 97a42676..21e6e9f7 100644 --- a/net/web/src/access/admin/Admin.styled.js +++ b/net/web/src/access/admin/Admin.styled.js @@ -72,3 +72,53 @@ export const AdminWrapper = 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/admin/useAdmin.hook.js b/net/web/src/access/admin/useAdmin.hook.js index 1b458dad..f8bc2f68 100644 --- a/net/web/src/access/admin/useAdmin.hook.js +++ b/net/web/src/access/admin/useAdmin.hook.js @@ -16,6 +16,9 @@ export function useAdmin() { busy: false, strings: {}, menuStyle: {}, + mfaModal: false, + mfaCode: null, + mfaError: null, }); const navigate = useNavigate(); @@ -53,10 +56,22 @@ export function useAdmin() { if (state.unclaimed === true) { await setNodeStatus(state.password); } - const session = await setNodeAccess(state.password); - + try { + const session = await setNodeAccess(state.password, state.mfaCode); + app.actions.setAdmin(session); + } + catch (err) { + const msg = err?.message; + if (msg === '405' || msg === '403' || msg === '429') { + updateState({ busy: false, mfaModal: true, mfaError: msg }); + } + else { + console.log(err); + updateState({ busy: false }) + throw new Error('login failed: check your username and password'); + } + } updateState({ busy: false }); - app.actions.setAdmin(session); } catch(err) { console.log(err); @@ -65,6 +80,12 @@ export function useAdmin() { } } }, + setCode: (mfaCode) => { + updateState({ mfaCode }); + }, + dismissMFA: () => { + updateState({ mfaModal: false, mfaCode: null }); + }, } useEffect(() => { diff --git a/net/web/src/access/login/Login.jsx b/net/web/src/access/login/Login.jsx index 9780cc01..e925c141 100644 --- a/net/web/src/access/login/Login.jsx +++ b/net/web/src/access/login/Login.jsx @@ -93,7 +93,6 @@ console.log(state.mfaError); - diff --git a/net/web/src/api/addAdminMFAuth.js b/net/web/src/api/addAdminMFAuth.js new file mode 100644 index 00000000..a17aec48 --- /dev/null +++ b/net/web/src/api/addAdminMFAuth.js @@ -0,0 +1,8 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function addAdminMFAuth(token) { + const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}`, { method: 'POST' }) + checkResponse(mfa); + return mfa.json(); +} + diff --git a/net/web/src/api/removeAdminMFAuth.js b/net/web/src/api/removeAdminMFAuth.js new file mode 100644 index 00000000..edbc2ee8 --- /dev/null +++ b/net/web/src/api/removeAdminMFAuth.js @@ -0,0 +1,7 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function removeAdminMFAuth(token) { + const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}`, { method: 'DELETE' }) + checkResponse(mfa); +} + diff --git a/net/web/src/api/setAdminMFAuth.js b/net/web/src/api/setAdminMFAuth.js new file mode 100644 index 00000000..0ed120c5 --- /dev/null +++ b/net/web/src/api/setAdminMFAuth.js @@ -0,0 +1,7 @@ +import { checkResponse, fetchWithTimeout } from './fetchUtil'; + +export async function setAdminMFAuth(token, code) { + const mfa = await fetchWithTimeout(`/admin/mfauth?token=${token}&code=${code}`, { method: 'PUT' }) + checkResponse(mfa); +} + diff --git a/net/web/src/api/setNodeAccess.js b/net/web/src/api/setNodeAccess.js index 09315861..d4356d06 100644 --- a/net/web/src/api/setNodeAccess.js +++ b/net/web/src/api/setNodeAccess.js @@ -1,7 +1,8 @@ import { checkResponse, fetchWithTimeout } from './fetchUtil'; -export async function setNodeAccess(token) { - const access = await fetchWithTimeout(`/admin/access?token=${encodeURIComponent(token)}`, { method: 'PUT' }); +export async function setNodeAccess(token, code) { + const mfa = code ? `&code=${code}` : ''; + const access = await fetchWithTimeout(`/admin/access?token=${encodeURIComponent(token)}${mfa}`, { method: 'PUT' }); checkResponse(access); return access.json() } diff --git a/net/web/src/dashboard/Dashboard.jsx b/net/web/src/dashboard/Dashboard.jsx index 8be6658b..d45a3b6e 100644 --- a/net/web/src/dashboard/Dashboard.jsx +++ b/net/web/src/dashboard/Dashboard.jsx @@ -1,4 +1,4 @@ -import { AlertIcon, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled'; +import { AlertIcon, MFAModal, DashboardWrapper, SettingsButton, AddButton, SettingsLayout, CreateLayout } from './Dashboard.styled'; import { Tooltip, Switch, Select, Button, Space, Modal, Input, InputNumber, List } from 'antd'; import { ExclamationCircleOutlined, SettingOutlined, UserAddOutlined, LogoutOutlined, ReloadOutlined, LockOutlined, UnlockOutlined } from '@ant-design/icons'; import { ThemeProvider } from "styled-components"; @@ -19,7 +19,7 @@ export function Dashboard() { return window.location.origin + '/#/create?add=' + state.createToken; }; - const disableMFA = () => { + const confirmDisableMFA = () => { modal.confirm({ title: {state.strings.confirmDisable}, content: {state.strings.disablePrompt}, @@ -28,12 +28,53 @@ export function Dashboard() { okText: state.strings.disable, cancelText: state.strings.cancel, onOk() { - actions.disableMFA(); + disableMFA(); }, onCancel() {}, }); } + const disableMFA = async () => { + try { + await actions.disableMFA(); + } + catch(err) { + console.log(err); + modal.error({ + title: {state.strings.operationFailed}, + content: {state.strings.tryAgain}, + bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle }, + }); + } + } + + const enableMFA = async () => { + try { + await actions.enableMFA(); + } + catch(err) { + console.log(err); + modal.error({ + title: {state.strings.operationFailed}, + content: {state.strings.tryAgain}, + bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle }, + }); + } + } + + const confirmMFA = async () => { + try { + await actions.confirmMFA(); + } + catch(err) { + console.log(err); + modal.error({ + title: {state.strings.operationFailed}, + content: {state.strings.tryAgain}, + bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle }, + }); + } + } return ( @@ -55,13 +96,13 @@ export function Dashboard() { { (state.mfAuthSet && state.mfaAuthEnabled) && (
} - onClick={disableMFA}> + onClick={confirmDisableMFA}>
)} { (state.mfAuthSet && !state.mfaAuthEnabled) && (
} - onClick={actions.enableMFA}> + onClick={enableMFA}>
)}
@@ -97,7 +138,7 @@ export function Dashboard() {
} - onClick={disableMFA}> + onClick={confirmDisableMFA}>
)} @@ -105,7 +146,7 @@ export function Dashboard() {
} - onClick={actions.enableMFA}> + onClick={enableMFA}>
)} @@ -264,6 +305,32 @@ export function Dashboard() {
+ + +
{state.strings.mfaTitle}
+
{state.strings.mfaSteps}
+ QRCode +
+
{ state.mfaText }
+ await navigator.clipboard.writeText(state.mfaText)} /> +
+ +
+ { state.mfaError === '401' && ( + {state.strings.mfaError} + )} + { state.mfaError === '429' && ( + {state.strings.mfaDisabled} + )} +
+
+ + +
+
+
); diff --git a/net/web/src/dashboard/Dashboard.styled.js b/net/web/src/dashboard/Dashboard.styled.js index 6c990b0d..754cf3cc 100644 --- a/net/web/src/dashboard/Dashboard.styled.js +++ b/net/web/src/dashboard/Dashboard.styled.js @@ -163,3 +163,72 @@ export const CreateLayout = 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; + text-aling: center; + } + + .description { + font-size: 1.0rem; + padding-bottom: 8px; + text-align: center; + } + + .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}; + } + } + } +` diff --git a/net/web/src/dashboard/useDashboard.hook.js b/net/web/src/dashboard/useDashboard.hook.js index 7fcac88d..d0dfb906 100644 --- a/net/web/src/dashboard/useDashboard.hook.js +++ b/net/web/src/dashboard/useDashboard.hook.js @@ -9,7 +9,9 @@ import { AppContext } from 'context/AppContext'; import { SettingsContext } from 'context/SettingsContext'; import { getAdminMFAuth } from 'api/getAdminMFAuth'; - +import { addAdminMFAuth } from 'api/addAdminMFAuth'; +import { setAdminMFAuth } from 'api/setAdminMFAuth'; +import { removeAdminMFAuth } from 'api/removeAdminMFAuth'; export function useDashboard(token) { @@ -42,11 +44,13 @@ export function useDashboard(token) { menuStyle: {}, strings: {}, + mfaModal: false, mfAuthSet: false, mfAuthEnabled: false, mfAuthSecretText: null, mfAuthSecretImage: null, mfaAuthError: null, + mfaCode: '', }); const navigate = useNavigate(); @@ -149,9 +153,29 @@ export function useDashboard(token) { await syncConfig(); await syncAccounts(); }, + setCode: async (code) => { + updateState({ mfaCode: code }); + }, enableMFA: async () => { + const mfa = await addAdminMFAuth(app.state.adminToken); + updateState({ mfaModal: true, mfaError: false, mfaText: mfa.secretText, mfaImage: mfa.secretImage, mfaCode: '' }); }, disableMFA: async () => { + const mfa = await removeAdminMFAuth(app.state.adminToken); + updateState({ mfaAuthEnabled: false }); + }, + confirmMFA: async () => { + try { + await setAdminMFAuth(app.state.adminToken, state.mfaCode); + updateState({ mfaAuthEnabled: true, mfaModal: false }); + } + catch (err) { + const msg = err?.message; + updateState({ mfaError: msg }); + } + }, + dismissMFA: async () => { + updateState({ mfaModal: false }); }, setSettings: async () => { if (!state.busy) {