integrated admin login with mfa

This commit is contained in:
Roland Osborne 2024-05-21 15:54:29 -07:00
parent 51306e92c4
commit 0001f6c8c9
16 changed files with 352 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
</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 === '403' && (
<span>{state.strings.mfaError}</span>
)}
{ state.mfaError === '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>
</AdminWrapper>
);
};

View File

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

View File

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

View File

@ -93,7 +93,6 @@ console.log(state.mfaError);
<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

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

View File

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

View File

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

View File

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

View File

@ -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: <span style={state.menuStyle}>{state.strings.confirmDisable}</span>,
content: <span style={state.menuStyle}>{state.strings.disablePrompt}</span>,
@ -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: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
});
}
}
const enableMFA = async () => {
try {
await actions.enableMFA();
}
catch(err) {
console.log(err);
modal.error({
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
});
}
}
const confirmMFA = async () => {
try {
await actions.confirmMFA();
}
catch(err) {
console.log(err);
modal.error({
title: <span style={state.menuStyle}>{state.strings.operationFailed}</span>,
content: <span style={state.menuStyle}>{state.strings.tryAgain}</span>,
bodyStyle: { borderRadius: 8, padding: 16, ...state.menuStyle },
});
}
}
return (
<ThemeProvider theme={state.colors}>
@ -55,13 +96,13 @@ export function Dashboard() {
{ (state.mfAuthSet && state.mfaAuthEnabled) && (
<div className="settings">
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
onClick={disableMFA}></SettingsButton>
onClick={confirmDisableMFA}></SettingsButton>
</div>
)}
{ (state.mfAuthSet && !state.mfaAuthEnabled) && (
<div className="settings">
<SettingsButton type="text" size="small" icon={<LockOutlined />}
onClick={actions.enableMFA}></SettingsButton>
onClick={enableMFA}></SettingsButton>
</div>
)}
<div className="settings">
@ -97,7 +138,7 @@ export function Dashboard() {
<div className="settings">
<Tooltip placement="topRight" title={state.strings.disableMultifactor}>
<SettingsButton type="text" size="small" icon={<LockOutlined />}
onClick={disableMFA}></SettingsButton>
onClick={confirmDisableMFA}></SettingsButton>
</Tooltip>
</div>
)}
@ -105,7 +146,7 @@ export function Dashboard() {
<div className="settings">
<Tooltip placement="topRight" title={state.strings.enableMultifactor}>
<SettingsButton type="text" size="small" icon={<UnlockOutlined />}
onClick={actions.enableMFA}></SettingsButton>
onClick={enableMFA}></SettingsButton>
</Tooltip>
</div>
)}
@ -264,6 +305,32 @@ export function Dashboard() {
</div>
</CreateLayout>
</Modal>
<Modal bodyStyle={{ borderRadius: 8, padding: 16, ...state.menuStyle }} closable={false} visible={state.mfaModal} centered width="fitContent"
destroyOnClose={true} footer={null} onCancel={actions.dismissMFA}>
<MFAModal>
<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.mfaText }</div>
<CopyButton onCopy={async () => await navigator.clipboard.writeText(state.mfaText)} />
</div>
<Input.OTP onChange={actions.setCode} />
<div className="alert">
{ state.mfaError === '401' && (
<span>{state.strings.mfaError}</span>
)}
{ state.mfaError === '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={confirmMFA}
disabled={!state.mfaCode} loading={state.busy}>{state.strings.mfaConfirm}</Button>
</div>
</MFAModal>
</Modal>
</DashboardWrapper>
</ThemeProvider>
);

View File

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

View File

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