# Conflicts: # ROADMAP.mdpull/3587/head
commit
a0fba90742
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