Merge pull request #197 from Dvorinka/feat/2fa-backup-codes
feat: 2FA recovery codes (upstream #2729)pull/3587/head
commit
93f1e79752
File diff suppressed because one or more lines are too long
@ -0,0 +1,148 @@
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, DialogContent, FormControl, Grid2, IconButton, Stack, styled, Tooltip, Typography } from "@mui/material";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import AutoHeight from "../../../Common/AutoHeight.tsx";
|
||||
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
|
||||
import { regenerate2FABackupCodes } from "../../../../api/api.ts";
|
||||
import { copyToClipboard } from "../../../../util";
|
||||
import { MuiOtpInput } from "mui-one-time-password-input";
|
||||
import CopyOutlined from "../../../Icons/CopyOutlined.tsx";
|
||||
|
||||
export interface Backup2FACodesDialogProps {
|
||||
open?: boolean;
|
||||
onClose: () => void;
|
||||
onCodesRegenerated: (count: number) => void;
|
||||
}
|
||||
|
||||
const MuiOtpInputStyled = styled(MuiOtpInput)`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 650px;
|
||||
margin-inline: auto;
|
||||
`;
|
||||
|
||||
const Backup2FACodesDialog = ({ open, onClose, onCodesRegenerated }: Backup2FACodesDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [codes, setCodes] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLoading(false);
|
||||
setCode("");
|
||||
setCodes(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === 6 && !codes) {
|
||||
setLoading(true);
|
||||
dispatch(regenerate2FABackupCodes(code))
|
||||
.then((res) => {
|
||||
setCodes(res);
|
||||
onCodesRegenerated(res.length);
|
||||
})
|
||||
.catch(() => {
|
||||
setCode("");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:setting.backup2FACodes")}
|
||||
showCancel
|
||||
hideOk
|
||||
showActions
|
||||
dialogProps={{
|
||||
open: !!open,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
<SwitchTransition>
|
||||
<CSSTransition
|
||||
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
|
||||
classNames="fade"
|
||||
key={`${loading}-${codes != null}`}
|
||||
>
|
||||
<Box>
|
||||
{loading && (
|
||||
<Box
|
||||
sx={{
|
||||
pt: 3,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FacebookCircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{!loading && !codes && (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant={"body2"}>{t("setting.backupCodesDes")}</Typography>
|
||||
<Typography variant={"body2"}>{t("setting.inputCurrent2FACode")}</Typography>
|
||||
<FormControl variant="standard" margin="normal" required>
|
||||
<MuiOtpInputStyled
|
||||
TextFieldsProps={{ disabled: loading }}
|
||||
autoFocus
|
||||
length={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
)}
|
||||
{!loading && codes && (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant={"body2"} color="warning.main">
|
||||
{t("setting.backupCodesWarning")}
|
||||
</Typography>
|
||||
<Grid2 container spacing={0.5} sx={{ fontFamily: "monospace" }}>
|
||||
{codes.map((c) => (
|
||||
<Grid2 key={c} size={6}>
|
||||
{c}
|
||||
</Grid2>
|
||||
))}
|
||||
</Grid2>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Tooltip title={t("setting.copyCodes")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
copyToClipboard(codes.join("\n"));
|
||||
enqueueSnackbar({ message: t("setting.copied"), variant: "success" });
|
||||
}}
|
||||
>
|
||||
<CopyOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Backup2FACodesDialog;
|
||||
@ -0,0 +1,95 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func backupCodeUser(t *testing.T, client *ent.Client) *ent.User {
|
||||
t.Helper()
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(context.Background())
|
||||
return client.User.Create().
|
||||
SetEmail(fmt.Sprintf("%s@example.com", t.Name())).
|
||||
SetNick("u").
|
||||
SetGroup(group).
|
||||
SetTwoFactorSecret("SECRET").
|
||||
SaveX(context.Background())
|
||||
}
|
||||
|
||||
func storeCodes(t *testing.T, c UserClient, u *ent.User, codes ...string) *ent.User {
|
||||
t.Helper()
|
||||
digests := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
d, err := DigestPassword(code)
|
||||
require.NoError(t, err)
|
||||
digests = append(digests, d)
|
||||
}
|
||||
u, err := c.UpdateTwoFABackupCodes(context.Background(), u, digests)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestTwoFABackupCodeConsume(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
defer client.Close()
|
||||
c := NewUserClient(client)
|
||||
ctx := context.Background()
|
||||
u := backupCodeUser(t, client)
|
||||
|
||||
u = storeCodes(t, c, u, "abcd1234", "wxyz5678")
|
||||
require.Len(t, u.TwoFactorBackupCodes, 2)
|
||||
|
||||
// Wrong code consumes nothing.
|
||||
ok, err := c.ConsumeTwoFABackupCode(ctx, u, "nope0000")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
// Correct code consumes exactly once; separators and case are normalized.
|
||||
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "ABCD-1234")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
u = client.User.GetX(ctx, u.ID)
|
||||
require.Len(t, u.TwoFactorBackupCodes, 1)
|
||||
|
||||
// Replay is rejected.
|
||||
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "abcd1234")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
// Sibling code still works.
|
||||
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "wxyz5678")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
u = client.User.GetX(ctx, u.ID)
|
||||
require.Empty(t, u.TwoFactorBackupCodes)
|
||||
}
|
||||
|
||||
func TestTwoFABackupCodesClearedWithSecret(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
defer client.Close()
|
||||
c := NewUserClient(client)
|
||||
ctx := context.Background()
|
||||
u := backupCodeUser(t, client)
|
||||
|
||||
u = storeCodes(t, c, u, "abcd1234")
|
||||
|
||||
// Disabling 2FA invalidates outstanding recovery codes.
|
||||
u, err := c.UpdateTwoFASecret(ctx, u, "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, u.TwoFactorBackupCodes)
|
||||
|
||||
// Rotating the secret also invalidates them.
|
||||
u = storeCodes(t, c, u, "wxyz5678")
|
||||
u, err = c.UpdateTwoFASecret(ctx, u, "NEWSECRET")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "NEWSECRET", u.TwoFactorSecret)
|
||||
require.Empty(t, u.TwoFactorBackupCodes)
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBackup2FAProcess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
logger := logging.NewConsoleLogger(logging.LevelError)
|
||||
cfg, err := conf.NewIniConfigProvider(t.TempDir()+"/conf.ini", logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: "test", AccountName: "b@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
u := client.User.Create().
|
||||
SetEmail("b@example.com").SetNick("b").SetGroup(group).
|
||||
SetTwoFactorSecret(key.Secret()).
|
||||
SaveX(ctx)
|
||||
no2fa := client.User.Create().
|
||||
SetEmail("n@example.com").SetNick("n").SetGroup(group).
|
||||
SaveX(ctx)
|
||||
|
||||
dep := dependency.NewDependency(
|
||||
dependency.WithDbClient(client),
|
||||
dependency.WithConfigProvider(cfg),
|
||||
dependency.WithLogger(logger),
|
||||
dependency.WithSettingProvider(cronSettingProvider{}),
|
||||
)
|
||||
|
||||
newCtx := func(u *ent.User) *gin.Context {
|
||||
engine := gin.New()
|
||||
engine.ContextWithFallback = true
|
||||
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
|
||||
c.Request = httptest.NewRequest("PUT", "/", nil)
|
||||
util.WithValue(c, dependency.DepCtx{}, dep)
|
||||
util.WithValue(c, inventory.UserCtx{}, u)
|
||||
return c
|
||||
}
|
||||
|
||||
appErr := func(err error) serializer.AppError {
|
||||
t.Helper()
|
||||
var ae serializer.AppError
|
||||
require.ErrorAs(t, err, &ae)
|
||||
return ae
|
||||
}
|
||||
|
||||
// 2FA not enabled -> rejected before TOTP validation.
|
||||
_, err = (&Backup2FAService{TwoFACode: "000000"}).Process(newCtx(no2fa))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, serializer.CodeFeatureNotEnabled, appErr(err).Code)
|
||||
|
||||
// Wrong TOTP -> rejected, no codes stored.
|
||||
_, err = (&Backup2FAService{TwoFACode: "000000"}).Process(newCtx(u))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, serializer.Code2FACodeErr, appErr(err).Code)
|
||||
|
||||
// Valid TOTP -> 10 hyphenated codes, digests persisted.
|
||||
valid, err := totp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
codes, err := (&Backup2FAService{TwoFACode: valid}).Process(newCtx(u))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, codes, 10)
|
||||
for _, code := range codes {
|
||||
require.Len(t, code, 9)
|
||||
require.Equal(t, '-', rune(code[4]))
|
||||
}
|
||||
require.Len(t, client.User.GetX(ctx, u.ID).TwoFactorBackupCodes, 10)
|
||||
}
|
||||
Loading…
Reference in new issue