Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

139 lines
5.2 KiB
Go

package auth
import (
"errors"
"fmt"
"strings"
"github.com/timbutler/zxcvbn"
"golang.org/x/crypto/bcrypt"
)
// MinPasswordLength is the floor for any new password. LocalAI does not
// (yet) support a second factor, so the bar sits above NIST's 8-char
// recommendation for MFA-protected accounts.
const MinPasswordLength = 12
// MaxPasswordLength matches bcrypt's 72-byte truncation. Accepting longer
// inputs creates a confusing UX where two "different" passwords hash to
// the same value because bcrypt silently dropped the suffix.
const MaxPasswordLength = 72
// MinPasswordScore is the minimum zxcvbn score (0-4) we accept. 3 means
// "safely unguessable: moderate protection from offline slow-hash scenario"
// per Dropbox's scoring; 4 is the highest.
const MinPasswordScore = 3
// passwordContextHints are tokens fed to zxcvbn so it penalises passwords
// built from the application's own name or branding.
var passwordContextHints = []string{"localai", "local-ai", "admin"}
// ErrPasswordEmpty is returned for a zero-length password. Always rejected;
// not overridable — bcrypt comparison on an empty string is its own hazard
// and there's no realistic legitimate use.
var ErrPasswordEmpty = errors.New("password must not be empty")
// ErrPasswordTooShort is returned when the password is below
// MinPasswordLength. Overridable — short is a policy choice, not a
// technical constraint.
var ErrPasswordTooShort = fmt.Errorf("password is shorter than %d characters; pick a longer one or acknowledge the weak password to use it anyway", MinPasswordLength)
// ErrPasswordTooLong is returned when the password exceeds MaxPasswordLength.
// Not overridable — bcrypt silently truncates at 72 bytes.
var ErrPasswordTooLong = fmt.Errorf("password must be at most %d characters", MaxPasswordLength)
// ErrPasswordNullByte is returned when the password contains a NUL byte —
// some bcrypt callers truncate at the first NUL, which would let an
// attacker register "abc\x00garbage" and authenticate as "abc". Not
// overridable.
var ErrPasswordNullByte = errors.New("password must not contain null bytes")
// ErrPasswordTooWeak is returned when zxcvbn scores the password below
// MinPasswordScore. Overridable — an operator may legitimately want a
// known-weak password (kiosk demo, CI rig, false positive on zxcvbn).
var ErrPasswordTooWeak = errors.New("password is too easy to guess; pick a longer or less common one, or acknowledge the weak password to use it anyway")
// PasswordPolicy controls which checks ValidatePasswordStrength enforces.
// AllowWeak skips the policy-level checks (length floor, zxcvbn score) but
// the technical invariants (non-empty, max length, no NUL bytes) always
// apply.
type PasswordPolicy struct {
AllowWeak bool
}
// ValidatePasswordStrength enforces the password policy. Callers should use
// this for every register / change-password / admin-reset flow. Pass an
// optional PasswordPolicy{AllowWeak: true} to skip the policy-level checks;
// the technical invariants still apply.
func ValidatePasswordStrength(password string, policy ...PasswordPolicy) error {
// Hard rules — always enforced. These aren't policy, they're invariants
// the bcrypt layer below us depends on.
if len(password) == 0 {
return ErrPasswordEmpty
}
if len(password) > MaxPasswordLength {
return ErrPasswordTooLong
}
if strings.ContainsRune(password, 0) {
return ErrPasswordNullByte
}
allowWeak := false
if len(policy) > 0 {
allowWeak = policy[0].AllowWeak
}
if allowWeak {
return nil
}
// Policy-level checks — bypassable via AllowWeak.
if len(password) < MinPasswordLength {
return ErrPasswordTooShort
}
if zxcvbn.PasswordStrength(password, passwordContextHints).Score < MinPasswordScore {
return ErrPasswordTooWeak
}
return nil
}
// HashPassword returns a bcrypt hash of the given password.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword compares a bcrypt hash with a plaintext password.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// PasswordErrorResponse describes a password-policy rejection in a
// machine-readable form so the UI can choose whether to offer an "use
// this anyway" override (only when Overridable is true).
type PasswordErrorResponse struct {
Error string `json:"error"`
ErrorCode string `json:"error_code"`
Overridable bool `json:"overridable"`
}
// PasswordError returns a structured response for a ValidatePasswordStrength
// error. err must be one of the package-level password errors.
func PasswordError(err error) PasswordErrorResponse {
r := PasswordErrorResponse{Error: err.Error()}
switch {
case errors.Is(err, ErrPasswordEmpty):
r.ErrorCode = "password_empty"
case errors.Is(err, ErrPasswordTooShort):
r.ErrorCode = "password_too_short"
r.Overridable = true
case errors.Is(err, ErrPasswordTooLong):
r.ErrorCode = "password_too_long"
case errors.Is(err, ErrPasswordNullByte):
r.ErrorCode = "password_null_byte"
case errors.Is(err, ErrPasswordTooWeak):
r.ErrorCode = "password_too_weak"
r.Overridable = true
}
return r
}