mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 20:33:43 +08:00
fix: remove CLI credential timeout and password complexity limits (#20)
This commit is contained in:
@@ -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") {
|
||||
|
||||
+2
-4
@@ -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"},
|
||||
|
||||
+48
-12
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user