diff --git a/cmd/vocat/bootstrap_admin.go b/cmd/vocat/bootstrap_admin.go index 2d4e876..7a447e7 100644 --- a/cmd/vocat/bootstrap_admin.go +++ b/cmd/vocat/bootstrap_admin.go @@ -23,14 +23,14 @@ func runBootstrapAdmin(args []string) error { if err := flags.Parse(args); err != nil || flags.NArg() != 0 { return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]") } - reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049)) + reader := bufio.NewReader(os.Stdin) password, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return fmt.Errorf("read password: %w", err) } password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r") - if len(password) < 6 || len(password) > 1024 { - return errors.New("bootstrap password must contain between 6 and 1024 characters") + if password == "" { + return errors.New("bootstrap password cannot be empty") } adminUsername := strings.TrimSpace(*username) if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") { diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index bb8cf1f..7807a20 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -198,8 +198,7 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error { if err != nil { return fmt.Errorf("%w: %v", errMenuConfig, err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + ctx := context.Background() database, err := store.Open(ctx, cfg.DatabasePath) if err != nil { @@ -215,7 +214,6 @@ func menuResetAdminCredentials(reader *bufio.Reader, m *menu) error { if err != nil { return fmt.Errorf("%w: %v", errMenuStore, err) } - fmt.Print(m.newUsername(admin.Username)) username, err := reader.ReadString('\n') if err != nil { @@ -596,7 +594,7 @@ func (m *menu) msg(key string) string { "prompt": {"请选择: ", "Select: "}, "invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."}, "new_username": {"新用户名(直接回车保留 %s): ", "New username (Enter to keep %s): "}, - "new_pw": {"新密码 (至少 6 位): ", "New password (min 6 chars): "}, + "new_pw": {"新密码: ", "New password: "}, "confirm_pw": {"确认新密码: ", "Confirm new password: "}, "pw_changed": {"管理员账号密码已修改,现有 Web 会话已退出。", "Administrator credentials changed; existing Web sessions were signed out."}, "current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"}, diff --git a/internal/auth/service.go b/internal/auth/service.go index 9b2fb4a..d450b80 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "crypto/rand" "crypto/sha256" @@ -20,8 +21,13 @@ var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUnauthorized = errors.New("unauthorized") ErrInvalidCSRF = errors.New("invalid csrf token") + ErrEmptyPassword = errors.New("password cannot be empty") ) +const bcryptPasswordLimit = 72 + +var longPasswordHashPrefix = []byte("$vocat-sha256$") + type Options struct { SessionTTL time.Duration BcryptCost int @@ -85,14 +91,14 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str current, err := s.store.CurrentAdmin(ctx) if err == nil && current.Username == username && - bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil { + comparePassword(current.PasswordHash, password) == nil { return nil } if err != nil && !errors.Is(err, store.ErrNotFound) { return fmt.Errorf("auth: read configured admin: %w", err) } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost) + passwordHash, err := hashPassword(password, s.bcryptCost) if err != nil { return fmt.Errorf("auth: hash admin password: %w", err) } @@ -127,8 +133,8 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa if len(username) < 1 || len(username) > 64 || strings.ContainsAny(username, "\r\n\t") { return errors.New("administrator username must contain between 1 and 64 characters without control whitespace") } - if len(password) < 6 || len(password) > 1024 { - return errors.New("administrator password must contain between 6 and 1024 characters") + if password == "" { + return ErrEmptyPassword } if err := s.EnsureAdmin(ctx, username, password); err != nil { return fmt.Errorf("auth: reset administrator credentials: %w", err) @@ -139,13 +145,13 @@ func (s *Service) ResetAdminCredentials(ctx context.Context, username string, pa func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) { admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) if errors.Is(err, store.ErrNotFound) { - _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password)) + _ = comparePassword(s.dummyHash, password) return Credentials{}, ErrInvalidCredentials } if err != nil { return Credentials{}, fmt.Errorf("auth: find admin: %w", err) } - if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil { + if comparePassword(admin.PasswordHash, password) != nil { return Credentials{}, ErrInvalidCredentials } @@ -284,24 +290,24 @@ func (s *Service) ChangePassword( currentPassword string, newPassword string, ) error { - if len(newPassword) < 6 || len(newPassword) > 1024 { - return errors.New("new password must contain between 6 and 1024 characters") + if newPassword == "" { + return ErrEmptyPassword } admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) if errors.Is(err, store.ErrNotFound) { - _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword)) + _ = comparePassword(s.dummyHash, currentPassword) return ErrInvalidCredentials } if err != nil { return fmt.Errorf("auth: find admin: %w", err) } - if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil { + if comparePassword(admin.PasswordHash, currentPassword) != nil { return ErrInvalidCredentials } - if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil { + if comparePassword(admin.PasswordHash, newPassword) == nil { return errors.New("new password must differ from the current password") } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost) + passwordHash, err := hashPassword(newPassword, s.bcryptCost) if err != nil { return fmt.Errorf("auth: hash new password: %w", err) } @@ -311,6 +317,36 @@ func (s *Service) ChangePassword( return nil } +// hashPassword keeps ordinary bcrypt hashes compatible with existing +// installations. bcrypt rejects inputs longer than 72 bytes, so only longer +// passwords use a tagged SHA-256 pre-hash before bcrypt. +func hashPassword(password string, cost int) ([]byte, error) { + material := []byte(password) + longPassword := len(material) > bcryptPasswordLimit + if longPassword { + digest := sha256.Sum256(material) + material = digest[:] + } + passwordHash, err := bcrypt.GenerateFromPassword(material, cost) + if err != nil { + return nil, err + } + if !longPassword { + return passwordHash, nil + } + return append(append([]byte(nil), longPasswordHashPrefix...), passwordHash...), nil +} + +func comparePassword(passwordHash []byte, password string) error { + material := []byte(password) + if bytes.HasPrefix(passwordHash, longPasswordHashPrefix) { + digest := sha256.Sum256(material) + material = digest[:] + passwordHash = passwordHash[len(longPasswordHashPrefix):] + } + return bcrypt.CompareHashAndPassword(passwordHash, material) +} + func randomToken() (string, error) { buffer := make([]byte, 32) if _, err := rand.Read(buffer); err != nil { diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index 7a9f028..96c4ec1 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "strings" "testing" "time" @@ -128,7 +129,7 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) { }{ {name: "empty username", password: "replacement-password"}, {name: "control whitespace", username: "bad\tname", password: "replacement-password"}, - {name: "short password", username: "admin", password: "short"}, + {name: "empty password", username: "admin", password: ""}, } { t.Run(test.name, func(t *testing.T) { if err := service.ResetAdminCredentials(context.Background(), test.username, test.password); err == nil { @@ -138,6 +139,32 @@ func TestResetAdminCredentialsValidatesInput(t *testing.T) { } } +func TestResetAdminCredentialsAcceptsPasswordsWithoutComplexityRules(t *testing.T) { + ctx := context.Background() + for _, password := range []string{"1", strings.Repeat("x", 256)} { + service := newTestService(t) + if err := service.ResetAdminCredentials(ctx, "admin", password); err != nil { + t.Fatalf("ResetAdminCredentials(%d-byte password) error = %v", len(password), err) + } + if _, err := service.Login(ctx, "admin", password); err != nil { + t.Fatalf("Login(%d-byte password) error = %v", len(password), err) + } + } +} + +func TestChangePasswordAcceptsPasswordsWithoutComplexityRules(t *testing.T) { + ctx := context.Background() + for _, password := range []string{"1", strings.Repeat("long-password-", 32)} { + service := newTestService(t) + if err := service.ChangePassword(ctx, "admin", "correct-password", password); err != nil { + t.Fatalf("ChangePassword(%d-byte password) error = %v", len(password), err) + } + if _, err := service.Login(ctx, "admin", password); err != nil { + t.Fatalf("Login(%d-byte password) error = %v", len(password), err) + } + } +} + func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) { ctx := context.Background() service := newTestService(t) diff --git a/internal/server/general_api.go b/internal/server/general_api.go index d4a742e..c3e5249 100644 --- a/internal/server/general_api.go +++ b/internal/server/general_api.go @@ -506,7 +506,7 @@ func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { switch { case errors.Is(err, auth.ErrInvalidCredentials): writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect") - case strings.Contains(err.Error(), "between 6 and 1024"): + case errors.Is(err, auth.ErrEmptyPassword): writeError(w, http.StatusBadRequest, "weak_password", err.Error()) case strings.Contains(err.Error(), "must differ"): writeError(w, http.StatusBadRequest, "password_reused", err.Error())