Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/vocat/bootstrap_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment on lines +26 to 34
adminUsername := strings.TrimSpace(*username)
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
Expand Down
6 changes: 2 additions & 4 deletions cmd/vocat/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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"},
Expand Down
60 changes: 48 additions & 12 deletions internal/auth/service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
29 changes: 28 additions & 1 deletion internal/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/server/general_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down