commit ebafb6ce5aa3be680bbc94082c31356dd79cb1d2 Author: William Dillon Date: Wed Dec 31 11:51:48 2025 -0500 first commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..573d604 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.wmdillon.com/wmdillon/simpleauth + +go 1.25.5 diff --git a/password.go b/password.go new file mode 100644 index 0000000..d24f263 --- /dev/null +++ b/password.go @@ -0,0 +1,86 @@ +package simpleauth + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "math/rand" +) + +var ( + // All password storage functions are set here to allow a user to write their + // own implementation if required. + SaltAndHashPasswordString func(password []byte) (salt, hash []byte) = DefaultSaltAndHashPasswordString + SaltPasswordString func(password, salt []byte) []byte = DefaultSaltPasswordString + HashSaltedPasswordString func(password []byte) []byte = DefaultHashSaltedPasswordString + + NumberChars = "0123456789" + SpecialChars = "~`!@#$%^&*()_-+={}[]|\\:;\"'<>,./?" + LowercaseChars = "abcdefghijklmnopqrstuvwxyz" + UppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + MinPasswordLength = 8 + MaxPasswordLength = 256 + + PasswordIsComplexEnough func([]byte) error = DefaultPasswordIsComplexEnough + + ErrInvalidPassword = errors.New("invalid password") +) + +type Password struct { + Hash []byte + Salt []byte +} + +func (p Password) String() string { + return "cowardly refusal to print password hash/salt to stdout" +} + +func (p Password) Matches(password []byte) bool { + return bytes.Equal(p.Hash, HashSaltedPasswordString(SaltPasswordString(password, p.Salt))) +} + +func NewPassword(password []byte) (*Password, error) { + if err := PasswordIsComplexEnough(password); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPassword, err) + } + salt, hash := SaltAndHashPasswordString(password) + return &Password{Hash: hash, Salt: salt}, nil +} + +func DefaultHashSaltedPasswordString(saltedPassword []byte) []byte { + return []byte(fmt.Sprintf("%064x", sha256.Sum256([]byte(saltedPassword)))) +} + +func DefaultSaltPasswordString(password, salt []byte) []byte { + return []byte(fmt.Sprintf("%s:%s", salt, password)) +} + +func DefaultSaltAndHashPasswordString(password []byte) (salt, hash []byte) { + salt = []byte(fmt.Sprintf("%016x", rand.Uint64())) + hash = HashSaltedPasswordString(SaltPasswordString(password, salt)) + return salt, hash +} + +func DefaultPasswordIsComplexEnough(password []byte) error { + errs := make([]error, 0, 5) + if len := len(password); len < MinPasswordLength { + errs = append(errs, fmt.Errorf("requires >= %d chars (got %d)", MinPasswordLength, len)) + } else if len > MaxPasswordLength { + errs = append(errs, fmt.Errorf("requires <= %d chars (got %d)", MaxPasswordLength, len)) + } + if !bytes.ContainsAny(password, LowercaseChars) { + errs = append(errs, fmt.Errorf("requires 1+ lowercase char (%s)", LowercaseChars)) + } + if !bytes.ContainsAny(password, UppercaseChars) { + errs = append(errs, fmt.Errorf("requires 1+ uppercase char (%s)", UppercaseChars)) + } + if !bytes.ContainsAny(password, NumberChars) { + errs = append(errs, fmt.Errorf("requires 1+ number (%s)", NumberChars)) + } + if !bytes.ContainsAny(password, SpecialChars) { + errs = append(errs, fmt.Errorf("requires 1+ special char (%s)", SpecialChars)) + } + return errors.Join(errs...) +} diff --git a/password_test.go b/password_test.go new file mode 100644 index 0000000..d015c74 --- /dev/null +++ b/password_test.go @@ -0,0 +1,68 @@ +package simpleauth + +import ( + "strings" + "testing" +) + +func TestNewPassword(t *testing.T) { + ThePassword := "ASimplePassword*23" + password, err := NewPassword([]byte(ThePassword)) + if err != nil { + t.Fatalf("error: %v\n", err) + } + if !password.Matches([]byte(ThePassword)) { + t.Fatalf("error: '%s' != '%+v'\n", ThePassword, password) + } + + ThePassword = "notcomplexenough" + password, err = NewPassword([]byte(ThePassword)) + if err == nil { + t.Fatalf("got nil error...\n") + } else if password != nil { + t.Fatalf("wanted nil Password; got %s %s\n", password.Hash, password.Salt) + } + + ThePassword = "NOTCOMPLEXENOUGH" + password, err = NewPassword([]byte(ThePassword)) + if err == nil { + t.Fatalf("got nil error...\n") + } else if password != nil { + t.Fatalf("wanted nil Password; got %s %s\n", password.Hash, password.Salt) + } + + ThePassword = "" + password, err = NewPassword([]byte(ThePassword)) + if err == nil { + t.Fatalf("got nil error...\n") + } else if password != nil { + t.Fatalf("wanted nil Password; got %s %s\n", password.Hash, password.Salt) + } + + ThePassword = strings.Repeat("hello", 256) + password, err = NewPassword([]byte(ThePassword)) + if err == nil { + t.Fatalf("got nil error...\n") + } else if password != nil { + t.Fatalf("wanted nil Password; got %s %s\n", password.Hash, password.Salt) + } + + ThePassword = "*sdbksldKdf" + password, err = NewPassword([]byte(ThePassword)) + if err == nil { + t.Fatalf("got nil error...\n") + } else if password != nil { + t.Fatalf("wanted nil Password; got %s %s\n", password.Hash, password.Salt) + } + +} + +func TestPasswordStringMethod(t *testing.T) { + password, err := NewPassword([]byte("*123ABcd")) + if err != nil { + t.Fatalf("error: %v\n", err) + } + if got := password.String(); !strings.Contains(got, "cowardly refusal") { + t.Fatalf("error: got '%s' instead of 'cowardly refusal' message\n", got) + } +}