Merge pull request #197 from Dvorinka/feat/2fa-backup-codes

feat: 2FA recovery codes (upstream #2729)
pull/3587/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 93f1e79752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -208,6 +208,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
- [x] Private space / vault (upstream #3447) — opt-in root folder flagged `sys:vault`; ancestry-based membership (zero flag maintenance; chain-less search results resolved lazily via `file_children`); `vaultNavigator` decorator gating `To`/`Children`/`Walk`/`ExecuteHook`; separate vault password (`salt:sha256`, sensitive) + 30-min cache-backed unlock session, unlock rate-limited 10/h; vault content never shareable and never direct-linkable; search filtered while locked; `vault_enabled`/`vault_unlocked` in user settings; unlock prompt in `ExplorerError`, Private space section in security settings, lock badge on vault folder (#195)
- [x] 2FA recovery codes (upstream #2729 item 3) — `user.two_factor_backup_codes` sensitive JSON of `salt:sha256` digests; `PUT /user/setting/2fa/backup` regenerates 10 one-time codes behind a valid TOTP (rate-limited 5/h); `Verify2FA` falls back to single-use code consumption on TOTP failure; codes invalidated on secret rotation/disable; login phase gains a recovery-code input mode; security settings show remaining count + regenerate dialog
## 6. Phase D — desktop, all platforms

File diff suppressed because one or more lines are too long

@ -789,6 +789,7 @@ var (
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "vault_password", Type: field.TypeString, Nullable: true},
{Name: "vault_folder", Type: field.TypeInt, Nullable: true, Default: 0},
{Name: "two_factor_backup_codes", Type: field.TypeJSON, Nullable: true},
{Name: "avatar", Type: field.TypeString, Nullable: true},
{Name: "settings", Type: field.TypeJSON, Nullable: true},
{Name: "group_users", Type: field.TypeInt},
@ -801,7 +802,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[18]},
Columns: []*schema.Column{UsersColumns[19]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -24343,74 +24343,76 @@ func (m *TaskMutation) ResetEdge(name string) error {
// UserMutation represents an operation that mutates the User nodes in the graph.
type UserMutation struct {
config
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
email *string
nick *string
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
credits *int64
addcredits *int64
two_factor_secret *string
vault_password *string
vault_folder *int
addvault_folder *int
avatar *string
settings **types.UserSetting
clearedFields map[string]struct{}
group *int
clearedgroup bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
dav_accounts map[int]struct{}
removeddav_accounts map[int]struct{}
cleareddav_accounts bool
shares map[int]struct{}
removedshares map[int]struct{}
clearedshares bool
passkey map[int]struct{}
removedpasskey map[int]struct{}
clearedpasskey bool
tasks map[int]struct{}
removedtasks map[int]struct{}
clearedtasks bool
fsevents map[int]struct{}
removedfsevents map[int]struct{}
clearedfsevents bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
oauth_grants map[int]struct{}
removedoauth_grants map[int]struct{}
clearedoauth_grants bool
credit_txns map[int]struct{}
removedcredit_txns map[int]struct{}
clearedcredit_txns bool
redeemed_codes map[int]struct{}
removedredeemed_codes map[int]struct{}
clearedredeemed_codes bool
grants map[int]struct{}
removedgrants map[int]struct{}
clearedgrants bool
share_purchases map[int]struct{}
removedshare_purchases map[int]struct{}
clearedshare_purchases bool
sso_bindings map[int]struct{}
removedsso_bindings map[int]struct{}
clearedsso_bindings bool
done bool
oldValue func(context.Context) (*User, error)
predicates []predicate.User
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
email *string
nick *string
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
credits *int64
addcredits *int64
two_factor_secret *string
vault_password *string
vault_folder *int
addvault_folder *int
two_factor_backup_codes *[]string
appendtwo_factor_backup_codes []string
avatar *string
settings **types.UserSetting
clearedFields map[string]struct{}
group *int
clearedgroup bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
dav_accounts map[int]struct{}
removeddav_accounts map[int]struct{}
cleareddav_accounts bool
shares map[int]struct{}
removedshares map[int]struct{}
clearedshares bool
passkey map[int]struct{}
removedpasskey map[int]struct{}
clearedpasskey bool
tasks map[int]struct{}
removedtasks map[int]struct{}
clearedtasks bool
fsevents map[int]struct{}
removedfsevents map[int]struct{}
clearedfsevents bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
oauth_grants map[int]struct{}
removedoauth_grants map[int]struct{}
clearedoauth_grants bool
credit_txns map[int]struct{}
removedcredit_txns map[int]struct{}
clearedcredit_txns bool
redeemed_codes map[int]struct{}
removedredeemed_codes map[int]struct{}
clearedredeemed_codes bool
grants map[int]struct{}
removedgrants map[int]struct{}
clearedgrants bool
share_purchases map[int]struct{}
removedshare_purchases map[int]struct{}
clearedshare_purchases bool
sso_bindings map[int]struct{}
removedsso_bindings map[int]struct{}
clearedsso_bindings bool
done bool
oldValue func(context.Context) (*User, error)
predicates []predicate.User
}
var _ ent.Mutation = (*UserMutation)(nil)
@ -25216,6 +25218,71 @@ func (m *UserMutation) ResetVaultFolder() {
delete(m.clearedFields, user.FieldVaultFolder)
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (m *UserMutation) SetTwoFactorBackupCodes(s []string) {
m.two_factor_backup_codes = &s
m.appendtwo_factor_backup_codes = nil
}
// TwoFactorBackupCodes returns the value of the "two_factor_backup_codes" field in the mutation.
func (m *UserMutation) TwoFactorBackupCodes() (r []string, exists bool) {
v := m.two_factor_backup_codes
if v == nil {
return
}
return *v, true
}
// OldTwoFactorBackupCodes returns the old "two_factor_backup_codes" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldTwoFactorBackupCodes(ctx context.Context) (v []string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTwoFactorBackupCodes is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTwoFactorBackupCodes requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTwoFactorBackupCodes: %w", err)
}
return oldValue.TwoFactorBackupCodes, nil
}
// AppendTwoFactorBackupCodes adds s to the "two_factor_backup_codes" field.
func (m *UserMutation) AppendTwoFactorBackupCodes(s []string) {
m.appendtwo_factor_backup_codes = append(m.appendtwo_factor_backup_codes, s...)
}
// AppendedTwoFactorBackupCodes returns the list of values that were appended to the "two_factor_backup_codes" field in this mutation.
func (m *UserMutation) AppendedTwoFactorBackupCodes() ([]string, bool) {
if len(m.appendtwo_factor_backup_codes) == 0 {
return nil, false
}
return m.appendtwo_factor_backup_codes, true
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (m *UserMutation) ClearTwoFactorBackupCodes() {
m.two_factor_backup_codes = nil
m.appendtwo_factor_backup_codes = nil
m.clearedFields[user.FieldTwoFactorBackupCodes] = struct{}{}
}
// TwoFactorBackupCodesCleared returns if the "two_factor_backup_codes" field was cleared in this mutation.
func (m *UserMutation) TwoFactorBackupCodesCleared() bool {
_, ok := m.clearedFields[user.FieldTwoFactorBackupCodes]
return ok
}
// ResetTwoFactorBackupCodes resets all changes to the "two_factor_backup_codes" field.
func (m *UserMutation) ResetTwoFactorBackupCodes() {
m.two_factor_backup_codes = nil
m.appendtwo_factor_backup_codes = nil
delete(m.clearedFields, user.FieldTwoFactorBackupCodes)
}
// SetAvatar sets the "avatar" field.
func (m *UserMutation) SetAvatar(s string) {
m.avatar = &s
@ -26126,7 +26193,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 18)
fields := make([]string, 0, 19)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -26172,6 +26239,9 @@ func (m *UserMutation) Fields() []string {
if m.vault_folder != nil {
fields = append(fields, user.FieldVaultFolder)
}
if m.two_factor_backup_codes != nil {
fields = append(fields, user.FieldTwoFactorBackupCodes)
}
if m.avatar != nil {
fields = append(fields, user.FieldAvatar)
}
@ -26219,6 +26289,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.VaultPassword()
case user.FieldVaultFolder:
return m.VaultFolder()
case user.FieldTwoFactorBackupCodes:
return m.TwoFactorBackupCodes()
case user.FieldAvatar:
return m.Avatar()
case user.FieldSettings:
@ -26264,6 +26336,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldVaultPassword(ctx)
case user.FieldVaultFolder:
return m.OldVaultFolder(ctx)
case user.FieldTwoFactorBackupCodes:
return m.OldTwoFactorBackupCodes(ctx)
case user.FieldAvatar:
return m.OldAvatar(ctx)
case user.FieldSettings:
@ -26384,6 +26458,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetVaultFolder(v)
return nil
case user.FieldTwoFactorBackupCodes:
v, ok := value.([]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTwoFactorBackupCodes(v)
return nil
case user.FieldAvatar:
v, ok := value.(string)
if !ok {
@ -26498,6 +26579,9 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldVaultFolder) {
fields = append(fields, user.FieldVaultFolder)
}
if m.FieldCleared(user.FieldTwoFactorBackupCodes) {
fields = append(fields, user.FieldTwoFactorBackupCodes)
}
if m.FieldCleared(user.FieldAvatar) {
fields = append(fields, user.FieldAvatar)
}
@ -26542,6 +26626,9 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldVaultFolder:
m.ClearVaultFolder()
return nil
case user.FieldTwoFactorBackupCodes:
m.ClearTwoFactorBackupCodes()
return nil
case user.FieldAvatar:
m.ClearAvatar()
return nil
@ -26601,6 +26688,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldVaultFolder:
m.ResetVaultFolder()
return nil
case user.FieldTwoFactorBackupCodes:
m.ResetTwoFactorBackupCodes()
return nil
case user.FieldAvatar:
m.ResetAvatar()
return nil

@ -690,7 +690,7 @@ func init() {
// user.DefaultVaultFolder holds the default value on creation for the vault_folder field.
user.DefaultVaultFolder = userDescVaultFolder.Default.(int)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[13].Descriptor()
userDescSettings := userFields[14].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
usergrantMixin := schema.UserGrant{}.Mixin()

@ -55,6 +55,11 @@ func (User) Fields() []ent.Field {
field.Int("vault_folder").
Optional().
Default(0),
// two_factor_backup_codes holds salt:sha256 digests of one-time
// recovery codes, same store format as account passwords.
field.JSON("two_factor_backup_codes", []string{}).
Sensitive().
Optional(),
field.String("avatar").
Optional(),
field.JSON("settings", &types.UserSetting{}).

@ -50,6 +50,8 @@ type User struct {
VaultPassword string `json:"-"`
// VaultFolder holds the value of the "vault_folder" field.
VaultFolder int `json:"vault_folder,omitempty"`
// TwoFactorBackupCodes holds the value of the "two_factor_backup_codes" field.
TwoFactorBackupCodes []string `json:"-"`
// Avatar holds the value of the "avatar" field.
Avatar string `json:"avatar,omitempty"`
// Settings holds the value of the "settings" field.
@ -232,7 +234,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldSettings:
case user.FieldTwoFactorBackupCodes, user.FieldSettings:
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
@ -354,6 +356,14 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.VaultFolder = int(value.Int64)
}
case user.FieldTwoFactorBackupCodes:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field two_factor_backup_codes", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &u.TwoFactorBackupCodes); err != nil {
return fmt.Errorf("unmarshal field two_factor_backup_codes: %w", err)
}
}
case user.FieldAvatar:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field avatar", values[i])
@ -528,6 +538,8 @@ func (u *User) String() string {
builder.WriteString("vault_folder=")
builder.WriteString(fmt.Sprintf("%v", u.VaultFolder))
builder.WriteString(", ")
builder.WriteString("two_factor_backup_codes=<sensitive>")
builder.WriteString(", ")
builder.WriteString("avatar=")
builder.WriteString(u.Avatar)
builder.WriteString(", ")

@ -47,6 +47,8 @@ const (
FieldVaultPassword = "vault_password"
// FieldVaultFolder holds the string denoting the vault_folder field in the database.
FieldVaultFolder = "vault_folder"
// FieldTwoFactorBackupCodes holds the string denoting the two_factor_backup_codes field in the database.
FieldTwoFactorBackupCodes = "two_factor_backup_codes"
// FieldAvatar holds the string denoting the avatar field in the database.
FieldAvatar = "avatar"
// FieldSettings holds the string denoting the settings field in the database.
@ -201,6 +203,7 @@ var Columns = []string{
FieldTwoFactorSecret,
FieldVaultPassword,
FieldVaultFolder,
FieldTwoFactorBackupCodes,
FieldAvatar,
FieldSettings,
FieldGroupUsers,

@ -945,6 +945,16 @@ func VaultFolderNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldVaultFolder))
}
// TwoFactorBackupCodesIsNil applies the IsNil predicate on the "two_factor_backup_codes" field.
func TwoFactorBackupCodesIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldTwoFactorBackupCodes))
}
// TwoFactorBackupCodesNotNil applies the NotNil predicate on the "two_factor_backup_codes" field.
func TwoFactorBackupCodesNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldTwoFactorBackupCodes))
}
// AvatarEQ applies the EQ predicate on the "avatar" field.
func AvatarEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldAvatar, v))

@ -231,6 +231,12 @@ func (uc *UserCreate) SetNillableVaultFolder(i *int) *UserCreate {
return uc
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uc *UserCreate) SetTwoFactorBackupCodes(s []string) *UserCreate {
uc.mutation.SetTwoFactorBackupCodes(s)
return uc
}
// SetAvatar sets the "avatar" field.
func (uc *UserCreate) SetAvatar(s string) *UserCreate {
uc.mutation.SetAvatar(s)
@ -675,6 +681,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldVaultFolder, field.TypeInt, value)
_node.VaultFolder = value
}
if value, ok := uc.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
_node.TwoFactorBackupCodes = value
}
if value, ok := uc.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
_node.Avatar = value
@ -1194,6 +1204,24 @@ func (u *UserUpsert) ClearVaultFolder() *UserUpsert {
return u
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsert) SetTwoFactorBackupCodes(v []string) *UserUpsert {
u.Set(user.FieldTwoFactorBackupCodes, v)
return u
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsert) UpdateTwoFactorBackupCodes() *UserUpsert {
u.SetExcluded(user.FieldTwoFactorBackupCodes)
return u
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsert) ClearTwoFactorBackupCodes() *UserUpsert {
u.SetNull(user.FieldTwoFactorBackupCodes)
return u
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsert) SetAvatar(v string) *UserUpsert {
u.Set(user.FieldAvatar, v)
@ -1560,6 +1588,27 @@ func (u *UserUpsertOne) ClearVaultFolder() *UserUpsertOne {
})
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsertOne) SetTwoFactorBackupCodes(v []string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetTwoFactorBackupCodes(v)
})
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateTwoFactorBackupCodes() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateTwoFactorBackupCodes()
})
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsertOne) ClearTwoFactorBackupCodes() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearTwoFactorBackupCodes()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertOne) SetAvatar(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -2105,6 +2154,27 @@ func (u *UserUpsertBulk) ClearVaultFolder() *UserUpsertBulk {
})
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsertBulk) SetTwoFactorBackupCodes(v []string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetTwoFactorBackupCodes(v)
})
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateTwoFactorBackupCodes() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateTwoFactorBackupCodes()
})
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsertBulk) ClearTwoFactorBackupCodes() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearTwoFactorBackupCodes()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertBulk) SetAvatar(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -10,6 +10,7 @@ import (
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/dialect/sql/sqljson"
"entgo.io/ent/schema/field"
"github.com/cloudreve/Cloudreve/v4/ent/credittxn"
"github.com/cloudreve/Cloudreve/v4/ent/davaccount"
@ -300,6 +301,24 @@ func (uu *UserUpdate) ClearVaultFolder() *UserUpdate {
return uu
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uu *UserUpdate) SetTwoFactorBackupCodes(s []string) *UserUpdate {
uu.mutation.SetTwoFactorBackupCodes(s)
return uu
}
// AppendTwoFactorBackupCodes appends s to the "two_factor_backup_codes" field.
func (uu *UserUpdate) AppendTwoFactorBackupCodes(s []string) *UserUpdate {
uu.mutation.AppendTwoFactorBackupCodes(s)
return uu
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (uu *UserUpdate) ClearTwoFactorBackupCodes() *UserUpdate {
uu.mutation.ClearTwoFactorBackupCodes()
return uu
}
// SetAvatar sets the "avatar" field.
func (uu *UserUpdate) SetAvatar(s string) *UserUpdate {
uu.mutation.SetAvatar(s)
@ -988,6 +1007,17 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if uu.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uu.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
}
if value, ok := uu.mutation.AppendedTwoFactorBackupCodes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldTwoFactorBackupCodes, value)
})
}
if uu.mutation.TwoFactorBackupCodesCleared() {
_spec.ClearField(user.FieldTwoFactorBackupCodes, field.TypeJSON)
}
if value, ok := uu.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}
@ -1891,6 +1921,24 @@ func (uuo *UserUpdateOne) ClearVaultFolder() *UserUpdateOne {
return uuo
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) SetTwoFactorBackupCodes(s []string) *UserUpdateOne {
uuo.mutation.SetTwoFactorBackupCodes(s)
return uuo
}
// AppendTwoFactorBackupCodes appends s to the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) AppendTwoFactorBackupCodes(s []string) *UserUpdateOne {
uuo.mutation.AppendTwoFactorBackupCodes(s)
return uuo
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) ClearTwoFactorBackupCodes() *UserUpdateOne {
uuo.mutation.ClearTwoFactorBackupCodes()
return uuo
}
// SetAvatar sets the "avatar" field.
func (uuo *UserUpdateOne) SetAvatar(s string) *UserUpdateOne {
uuo.mutation.SetAvatar(s)
@ -2609,6 +2657,17 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if uuo.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uuo.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
}
if value, ok := uuo.mutation.AppendedTwoFactorBackupCodes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldTwoFactorBackupCodes, value)
})
}
if uuo.mutation.TwoFactorBackupCodesCleared() {
_spec.ClearField(user.FieldTwoFactorBackupCodes, field.TypeJSON)
}
if value, ok := uuo.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}

@ -70,7 +70,10 @@
"sso_token_invalid": "Failed to verify the identity response.",
"sso_no_email": "The identity provider did not return an email address.",
"sso_account_unavailable": "No account is available for this identity. Registration may be restricted."
}
},
"inputBackupCode": "Enter one of your recovery codes",
"useBackupCode": "Use a recovery code instead",
"use2FACode": "Use authenticator code instead"
},
"oauth": {
"authorize": "Authorize Application",
@ -1144,7 +1147,11 @@
"deleteTagDes": "Remove tag \"{{name}}\" from {{count}} file(s)? The files themselves are kept.",
"tagUpdated": "Tag updated",
"tagDeleted": "Tag deleted",
"tagNameInvalid": "Tag name cannot be empty or contain \":\""
"tagNameInvalid": "Tag name cannot be empty or contain \":\"",
"backup2FACodes": "Recovery codes",
"backupCodesDes": "Recovery codes let you sign in when your authenticator is unavailable. Generating a new batch invalidates all previous codes.",
"backupCodesWarning": "Save these codes now. Each can be used once and they will not be shown again.",
"copyCodes": "Copy codes"
},
"vas": {
"points": "Points",
@ -1179,4 +1186,4 @@
"title": "Announcement",
"dontShowAgain": "Don't show this again"
}
}
}

@ -70,7 +70,10 @@
"sso_token_invalid": "身份响应验证失败。",
"sso_no_email": "身份提供方未返回邮箱地址。",
"sso_account_unavailable": "此身份没有可用账号,注册可能受限。"
}
},
"inputBackupCode": "请输入您的备用恢复代码",
"useBackupCode": "改用备用恢复代码",
"use2FACode": "改用验证器验证码"
},
"oauth": {
"authorize": "授权应用",
@ -1144,7 +1147,11 @@
"deleteTagDes": "将标签「{{name}}」从 {{count}} 个文件中移除?文件本身不会被删除。",
"tagUpdated": "标签已更新",
"tagDeleted": "标签已删除",
"tagNameInvalid": "标签名不能为空或包含「:」"
"tagNameInvalid": "标签名不能为空或包含「:」",
"backup2FACodes": "备用恢复代码",
"backupCodesDes": "当验证器不可用时,可使用备用恢复代码登录。重新生成将使所有旧代码失效。",
"backupCodesWarning": "请立即保存这些代码。每个代码仅可使用一次,且不会再次显示。",
"copyCodes": "复制代码"
},
"vas": {
"points": "积分",
@ -1179,4 +1186,4 @@
"title": "公告",
"dontShowAgain": "不再显示"
}
}
}

@ -1519,6 +1519,23 @@ export function get2FAInitSecret(): ThunkResponse<string> {
};
}
export function regenerate2FABackupCodes(two_fa_code: string): ThunkResponse<string[]> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/setting/2fa/backup`,
{
method: "PUT",
data: { two_fa_code },
},
{
...defaultOpts,
},
),
);
};
}
export function sendPreparePasskeyRegistration(): ThunkResponse<PasskeyCredentialOption> {
return async (dispatch, _getState) => {
return await dispatch(

@ -142,6 +142,7 @@ export interface UserSettings {
version_retention_max?: number;
passwordless: boolean;
two_fa_enabled: boolean;
two_factor_backup_count?: number;
passkeys?: Passkey[];
disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel;

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../../../../redux/hooks.ts";
import { Control } from "../Signin/SignIn.tsx";
import { FormControl, styled, Typography } from "@mui/material";
import { Button, FormControl, styled, TextField, Typography } from "@mui/material";
import { MuiOtpInput } from "mui-one-time-password-input";
import { useState } from "react";
interface Phase2FAProps {
control?: Control;
@ -20,19 +20,43 @@ const MuiOtpInputStyled = styled(MuiOtpInput)`
const Phase2FA = ({ control, otp, onOtpChange, loading }: Phase2FAProps) => {
const { t } = useTranslation();
const regEnabled = useAppSelector((state) => state.siteConfig.login.config.register_enabled);
const [useBackup, setUseBackup] = useState(false);
const toggleMode = () => {
setUseBackup((v) => !v);
onOtpChange("");
};
return (
<>
<Typography color={"text.secondary"}>{t("login.input2FACode")}</Typography>
<Typography color={"text.secondary"}>
{useBackup ? t("login.inputBackupCode") : t("login.input2FACode")}
</Typography>
<FormControl variant="standard" margin="normal" required fullWidth>
<MuiOtpInputStyled
TextFieldsProps={{ disabled: loading }}
autoFocus
length={6}
value={otp}
onChange={onOtpChange}
/>
{useBackup ? (
<TextField
variant="standard"
autoFocus
fullWidth
disabled={loading}
placeholder="xxxx-xxxx"
value={otp}
onChange={(e) => onOtpChange(e.target.value)}
slotProps={{ htmlInput: { style: { textAlign: "center", letterSpacing: 2 } } }}
/>
) : (
<MuiOtpInputStyled
TextFieldsProps={{ disabled: loading }}
autoFocus
length={6}
value={otp}
onChange={onOtpChange}
/>
)}
</FormControl>
<Button size="small" onClick={toggleMode} disabled={loading} sx={{ mb: 1 }}>
{useBackup ? t("login.use2FACode") : t("login.useBackupCode")}
</Button>
{control?.submit}
{control?.back}

@ -297,7 +297,9 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
};
useEffect(() => {
if (otp.length === 6) {
// Auto-submit only a complete numeric TOTP; alphanumeric recovery
// codes are submitted via the button.
if (/^\d{6}$/.test(otp)) {
submit();
}
}, [otp]);

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

@ -42,10 +42,12 @@ import AppsListOutlined from "../../../Icons/AppsListOutlined.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Edit from "../../../Icons/Edit.tsx";
import LockClosed from "../../../Icons/LockClosed.tsx";
import LockClosedKey from "../../../Icons/LockClosedKey.tsx";
import Open from "../../../Icons/Open.tsx";
import ShieldLock from "../../../Icons/ShieldLock.tsx";
import { ProfileSettingProps } from "../ProfileSetting.tsx";
import SettingForm from "../SettingForm.tsx";
import Backup2FACodesDialog from "./Backup2FACodesDialog.tsx";
import Disable2FADialog from "./Disable2FADialog.tsx";
import Enable2FADialog from "./Enable2FADialog.tsx";
import PasskeyList from "./PasskeyList.tsx";
@ -415,6 +417,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const [repeatPassword, setRepeatPassword] = useState("");
const [enable2FAOpen, setEnable2FAOpen] = useState(false);
const [disable2FAOpen, setDisable2FAOpen] = useState(false);
const [backup2FAOpen, setBackup2FAOpen] = useState(false);
const submitResetPassword = () => {
if (!resetPwdFormRef.current) {
@ -581,6 +584,17 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
>
{t(`setting.${setting.two_fa_enabled ? "disable" : "enable"}2FA`)}
</SecondaryButton>
{setting.two_fa_enabled && (
<SecondaryButton
sx={{ mt: 1, ml: 1 }}
variant={"outlined"}
startIcon={<LockClosedKey />}
onClick={() => setBackup2FAOpen(true)}
>
{t("setting.backup2FACodes")}
{typeof setting.two_factor_backup_count === "number" && ` (${setting.two_factor_backup_count})`}
</SecondaryButton>
)}
</SettingForm>
{authEnabled && (
<SettingForm title={t("setting.hardwareAuthenticator")}>
@ -624,6 +638,11 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
onClose={() => setDisable2FAOpen(false)}
on2FADisabled={on2FAChange(false)}
/>
<Backup2FACodesDialog
open={backup2FAOpen}
onClose={() => setBackup2FAOpen(false)}
onCodesRegenerated={(count) => setSetting({ ...setting, two_factor_backup_count: count })}
/>
</Stack>
);
};

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

@ -100,6 +100,12 @@ type (
UpdateVault(ctx context.Context, u *ent.User, passwordDigest string, folderID int) (*ent.User, error)
// UpdateTwoFASecret updates user two factor secret.
UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error)
// UpdateTwoFABackupCodes replaces the stored one-time recovery code
// digests (salt:digest store format, same as account passwords).
UpdateTwoFABackupCodes(ctx context.Context, u *ent.User, digests []string) (*ent.User, error)
// ConsumeTwoFABackupCode verifies a plaintext recovery code against the
// stored digests; on match the digest is removed and true is returned.
ConsumeTwoFABackupCode(ctx context.Context, u *ent.User, code string) (bool, error)
// ListPasskeys list user's passkeys.
ListPasskeys(ctx context.Context, uid int) ([]*ent.Passkey, error)
// AddPasskey add passkey to user.
@ -180,10 +186,55 @@ func (c *userClient) UpdateAvatar(ctx context.Context, u *ent.User, avatar strin
}
func (c *userClient) UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error) {
// Recovery codes are bound to the 2FA lifecycle: they are invalidated
// whenever the secret is cleared or rotated.
stm := c.client.User.UpdateOne(u).ClearTwoFactorBackupCodes()
if secret == "" {
return c.client.User.UpdateOne(u).ClearTwoFactorSecret().Save(ctx)
return stm.ClearTwoFactorSecret().Save(ctx)
}
return c.client.User.UpdateOne(u).SetTwoFactorSecret(secret).Save(ctx)
return stm.SetTwoFactorSecret(secret).Save(ctx)
}
func (c *userClient) UpdateTwoFABackupCodes(ctx context.Context, u *ent.User, digests []string) (*ent.User, error) {
if len(digests) == 0 {
return c.client.User.UpdateOne(u).ClearTwoFactorBackupCodes().Save(ctx)
}
return c.client.User.UpdateOne(u).SetTwoFactorBackupCodes(digests).Save(ctx)
}
func (c *userClient) ConsumeTwoFABackupCode(ctx context.Context, u *ent.User, code string) (bool, error) {
normalized := normalizeBackupCode(code)
if normalized == "" {
return false, nil
}
remaining := make([]string, 0, len(u.TwoFactorBackupCodes))
matched := false
for _, digest := range u.TwoFactorBackupCodes {
if !matched && CheckPassword(&ent.User{Password: digest}, normalized) == nil {
matched = true
continue
}
remaining = append(remaining, digest)
}
if !matched {
return false, nil
}
if _, err := c.UpdateTwoFABackupCodes(ctx, u, remaining); err != nil {
return false, err
}
return true, nil
}
// normalizeBackupCode strips separators and case so users can enter codes
// with or without the display hyphenation.
func normalizeBackupCode(code string) string {
return strings.ToLower(strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return -1
}
return r
}, code))
}
func (c *userClient) UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error) {
@ -769,9 +820,10 @@ func withUserEagerLoading(ctx context.Context, q *ent.UserQuery) *ent.UserQuery
return q
}
// DigestPassword returns the salt:digest store format used for account and
// private-space passwords. Exported for credential writers outside this
// package; verification goes through CheckPassword.
// DigestPassword returns the salt:digest store format used for account
// passwords, private-space passwords, and 2FA recovery codes. Exported for
// credential writers outside this package; verification goes through
// CheckPassword.
func DigestPassword(password string) (string, error) {
return digestPassword(password)
}

@ -323,6 +323,20 @@ func UserInit2FA(c *gin.Context) {
})
}
// UserBackup2FA regenerates one-time 2FA recovery codes. Plaintext codes are
// returned once; only digests are persisted.
func UserBackup2FA(c *gin.Context) {
service := c.MustGet(user.Backup2FAParameterCtx{}).(*user.Backup2FAService)
codes, err := service.Process(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: codes,
})
}
// UserPerformCopySession copy to create new session or refresh current session
func UserPerformCopySession(c *gin.Context) {
//var service user.CopySessionService

@ -1541,6 +1541,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
// 获得二步验证初始化信息
setting.GET("2fa", controllers.UserInit2FA)
// 重新生成二步验证备用代码
setting.PUT("2fa/backup",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
middleware.RateLimitByIP("backup_2fa", 5, time.Hour),
controllers.FromJSON[usersvc.Backup2FAService](usersvc.Backup2FAParameterCtx{}),
controllers.UserBackup2FA,
)
// 请求更换邮箱(向新地址发送确认链接)
setting.POST("email",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),

@ -62,6 +62,7 @@ func TestMasterRouteWiring(t *testing.T) {
"PUT /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault",
"PUT /api/v4/user/setting/2fa/backup",
"GET /f/:id/:name",
}
for _, e := range expected {

@ -268,8 +268,14 @@ func (service *OtpValidationService) Verify2FA(c *gin.Context) (*ent.User, error
if expectedUser.TwoFactorSecret != "" {
if !totp.Validate(service.OTP, expectedUser.TwoFactorSecret) {
err := serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
return nil, err
// TOTP failed — fall back to one-time recovery codes.
ok, err := dep.UserClient().ConsumeTwoFABackupCode(c, expectedUser, service.OTP)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to verify recovery code", err)
}
if !ok {
return nil, serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
}
}

@ -29,6 +29,7 @@ type UserSettings struct {
VersionRetentionMax int `json:"version_retention_max,omitempty"`
Paswordless bool `json:"passwordless"`
TwoFAEnabled bool `json:"two_fa_enabled"`
TwoFABackupCount int `json:"two_factor_backup_count"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
@ -57,6 +58,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
VersionRetentionExt: u.Settings.VersionRetentionExt,
VersionRetentionMax: u.Settings.VersionRetentionMax,
TwoFAEnabled: u.TwoFactorSecret != "",
TwoFABackupCount: len(u.TwoFactorBackupCodes),
Paswordless: u.Password == "",
Passkeys: lo.Map(passkeys, func(item *ent.Passkey, index int) Passkey {
return BuildPasskey(item)

@ -3,6 +3,7 @@ package user
import (
"context"
"crypto/md5"
"crypto/rand"
"fmt"
"io"
"net/http"
@ -33,6 +34,10 @@ import (
const (
twoFaEnableSessionKey = "2fa_init_"
// backupCodeCount is the number of one-time recovery codes issued per batch.
backupCodeCount = 10
// backupCodeAlphabet excludes ambiguous glyphs (0/o, 1/l/i).
backupCodeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
)
// Init2FA 初始化二步验证
@ -55,6 +60,72 @@ func Init2FA(c *gin.Context) (string, error) {
return key.Secret(), nil
}
type (
// Backup2FAService regenerates one-time 2FA recovery codes.
Backup2FAService struct {
TwoFACode string `json:"two_fa_code" binding:"required"`
}
Backup2FAParameterCtx struct{}
)
// Process generates a fresh batch of recovery codes for the current user.
// Requires 2FA to be enabled and a valid current TOTP code, the same trust
// bar as disabling 2FA. Plaintext codes are returned once and only their
// digests are persisted.
func (service *Backup2FAService) Process(c *gin.Context) ([]string, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return nil, err
}
if u.TwoFactorSecret == "" {
return nil, serializer.NewError(serializer.CodeFeatureNotEnabled, "2FA is not enabled", nil)
}
if !totp.Validate(service.TwoFACode, u.TwoFactorSecret) {
return nil, serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
codes := make([]string, 0, backupCodeCount)
digests := make([]string, 0, backupCodeCount)
for i := 0; i < backupCodeCount; i++ {
code, err := generateBackupCode()
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to generate recovery codes", err)
}
digest, err := inventory.DigestPassword(code)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to hash recovery codes", err)
}
codes = append(codes, code[:4]+"-"+code[4:])
digests = append(digests, digest)
}
if _, err := dep.UserClient().UpdateTwoFABackupCodes(c, u, digests); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to store recovery codes", err)
}
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventEnable2FA,
activity.Extra(map[string]any{"recovery_codes": true}))
return codes, nil
}
// generateBackupCode returns an 8-char code drawn from an unambiguous
// alphabet (~41 bits of entropy per code).
func generateBackupCode() (string, error) {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "", err
}
code := make([]byte, 8)
for i, b := range buf {
code[i] = backupCodeAlphabet[int(b)%len(backupCodeAlphabet)]
}
return string(code), nil
}
type (
// AvatarService Service to get avatar
GetAvatarService struct {

@ -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…
Cancel
Save