diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index dfd2d74..67dfb5c 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -55,10 +55,10 @@ func (s *Service) SetSecurityQuestion(userID int, question, answer string) error } question = strings.TrimSpace(norm.NFKC.String(question)) answer = normalizeAnswer(answer) - if len([]byte(question)) < 2 || len([]byte(question)) > 255 { + if len([]rune(question)) < 1 { return ErrInvalidQuestion } - if len([]byte(answer)) < 8 || len([]byte(answer)) > 256 { + if len([]rune(answer)) < 1 { return ErrInvalidAnswer } hash, err := password.Hash(answer) @@ -71,12 +71,8 @@ func (s *Service) SetSecurityQuestion(userID int, question, answer string) error return fmt.Errorf("load security question: %w", err) } if has { - version := item.Version + 1 - if version <= 0 { - version = 1 - } - item.Question, item.AnswerHash, item.Version = question, hash, version - if _, err := s.engine.ID(item.ID).Cols("question", "answer_hash", "version", "updated_at").Update(item); err != nil { + item.Question, item.AnswerHash = question, hash + if _, err := s.engine.ID(item.ID).Cols("question", "answer_hash", "updated_at").Update(item); err != nil { return fmt.Errorf("update security question: %w", err) } return nil diff --git a/backend/services/credentials/service_test.go b/backend/services/credentials/service_test.go index 204aeb6..1dce6bc 100644 --- a/backend/services/credentials/service_test.go +++ b/backend/services/credentials/service_test.go @@ -32,6 +32,36 @@ func newFixture(t *testing.T) *Service { return service } +func TestSecurityQuestionSupportsSingleCharacterAndUnlimited(t *testing.T) { + service := newFixture(t) + if err := service.SetSecurityQuestion(1, "Q", "A"); err != nil { + t.Fatalf("setting 1 char question and answer failed: %v", err) + } + question, ok, err := service.GetSecurityQuestion(1) + if err != nil || !ok || question != "Q" { + t.Fatalf("question = %q, ok=%v, err=%v", question, ok, err) + } + if verified, err := service.VerifySecurityQuestion(1, "A"); err != nil || !verified { + t.Fatalf("verification failed: %v, %v", verified, err) + } + if err := service.SetSecurityQuestion(1, " ", "A"); !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("want ErrInvalidQuestion, got: %v", err) + } + if err := service.SetSecurityQuestion(1, "Q", " "); !errors.Is(err, ErrInvalidAnswer) { + t.Fatalf("want ErrInvalidAnswer, got: %v", err) + } + if err := service.SetSecurityQuestion(1, "UpdatedQ", "UpdatedA"); err != nil { + t.Fatalf("update security question failed: %v", err) + } + updatedQuestion, ok, err := service.GetSecurityQuestion(1) + if err != nil || !ok || updatedQuestion != "UpdatedQ" { + t.Fatalf("updated question = %q, ok=%v, err=%v", updatedQuestion, ok, err) + } + if verified, err := service.VerifySecurityQuestion(1, "UpdatedA"); err != nil || !verified { + t.Fatalf("verification of updated answer failed: %v, %v", verified, err) + } +} + func TestSecurityQuestionIsCaseInsensitiveAndNeverReturnsHash(t *testing.T) { service := newFixture(t) if err := service.SetSecurityQuestion(1, "第一只宠物叫什么?", "BlueBird123"); err != nil { diff --git a/backend/utils/password/encode.go b/backend/utils/password/encode.go index 907bee3..ba2dde9 100644 --- a/backend/utils/password/encode.go +++ b/backend/utils/password/encode.go @@ -22,8 +22,7 @@ const ( saltLength = 16 keyLength = 32 maxHashSize = 255 - minSecretSize = 8 - maxSecretSize = 256 + minSecretSize = 1 minPasswordSize = 8 maxPasswordSize = 12 ) @@ -41,7 +40,7 @@ func ValidateUserPassword(plain string) error { // Hash 生成 Argon2id 密码哈希。 func Hash(plain string) (string, error) { plainSize := len([]byte(plain)) - if plainSize < minSecretSize || plainSize > maxSecretSize { + if plainSize < minSecretSize { return "", ErrInvalidLength } salt := make([]byte, saltLength) @@ -83,7 +82,7 @@ func HashUserPassword(plain string) (string, error) { // Verify 校验 Argon2id 密码哈希,不接受旧算法格式。 func Verify(encoded, plain string) bool { - if len([]byte(plain)) > maxSecretSize { + if len([]byte(plain)) < minSecretSize { return false } parts := strings.Split(encoded, "$") diff --git a/backend/utils/password/encode_test.go b/backend/utils/password/encode_test.go index 760f481..8508e6a 100644 --- a/backend/utils/password/encode_test.go +++ b/backend/utils/password/encode_test.go @@ -16,14 +16,21 @@ func TestHashAndVerify(t *testing.T) { } } -func TestHashRejectsInvalidLengths(t *testing.T) { - for _, plain := range []string{"short", strings.Repeat("a", maxSecretSize+1)} { - if _, err := Hash(plain); err == nil { - t.Fatalf("invalid secret length was accepted: %d", len([]byte(plain))) +func TestHashSupportsSingleCharacterAndLongInputs(t *testing.T) { + for _, plain := range []string{"a", "中", strings.Repeat("long-secret-key-123", 50)} { + encoded, err := Hash(plain) + if err != nil { + t.Fatalf("valid secret %q failed: %v", plain, err) + } + if !Verify(encoded, plain) { + t.Fatalf("verification failed for %q", plain) } } - if Verify(mustHash(t, "StrongPass123"), strings.Repeat("a", maxSecretSize+1)) { - t.Fatal("oversized verification input was accepted") + if _, err := Hash(""); !errors.Is(err, ErrInvalidLength) { + t.Fatalf("empty secret should be rejected, got: %v", err) + } + if Verify(mustHash(t, "StrongPass123"), "") { + t.Fatal("empty verification input was accepted") } } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index cd9c780..5d5356d 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -40,8 +40,8 @@

密保问题不能单独重置密码,必须与离线恢复码组合使用。

- - + +
{{ securityQuestion.configured ? '更新密保问题' : '设置密保问题' }}