Cause: ConfigFileProvider.Authenticate compared the stored plaintext password with != (timing side channel, passwords readable from the file), and config/auth.yaml shipped admin/admin, user1/user1 etc. as plaintext with nothing reporting that a default credential was active. Fix: auth.yaml passwords may be bcrypt hashes (golang.org/x/crypto/bcrypt, v0.23.0 as already pinned in go.mod, now vendored); plaintext entries are still accepted but compared in constant time (SHA-256 + subtle), and an unknown user costs the same bcrypt work. The provider reports startup warnings (SecurityWarner, logged by main) for every plaintext entry and for every shipped default credential still active. The shipped auth.yaml and auth.yaml.example now store bcrypt hashes of the same dev passwords, so tests/regression/run.sh (admin/admin) keeps working. tools/encrypt gains -action=hash to generate entries. Tests: TestConfigFileProvider_BcryptPassword, TestConfigFileProvider_SecurityWarnings, TestShippedAuthConfig_HashedDefaults. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
256 lines
7.1 KiB
Go
256 lines
7.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestConfigFileProvider_Authenticate(t *testing.T) {
|
|
yaml := `
|
|
provider: config_file
|
|
config_file:
|
|
users:
|
|
- username: admin
|
|
password: secret
|
|
roles: [cluster_admin]
|
|
- username: viewer
|
|
password: pass
|
|
roles: [basic_user]
|
|
`
|
|
|
|
provider, err := NewConfigFileProvider([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("NewConfigFileProvider: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
user string
|
|
pass string
|
|
wantErr bool
|
|
}{
|
|
{"valid admin", "admin", "secret", false},
|
|
{"valid viewer", "viewer", "pass", false},
|
|
{"wrong password", "admin", "wrong", true},
|
|
{"unknown user", "nobody", "pass", true},
|
|
{"empty user", "", "pass", true},
|
|
{"empty password", "admin", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := provider.Authenticate(tt.user, tt.pass)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigFileProvider_GetUserRoles(t *testing.T) {
|
|
yaml := `
|
|
provider: config_file
|
|
config_file:
|
|
users:
|
|
- username: admin
|
|
password: admin
|
|
roles: [cluster_admin, operator]
|
|
- username: guest
|
|
password: guest
|
|
roles: []
|
|
`
|
|
|
|
provider, err := NewConfigFileProvider([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("NewConfigFileProvider: %v", err)
|
|
}
|
|
|
|
if roles := provider.GetUserRoles("admin"); len(roles) != 2 {
|
|
t.Errorf("admin roles = %v, want 2", roles)
|
|
}
|
|
if roles := provider.GetUserRoles("guest"); len(roles) != 0 {
|
|
t.Errorf("guest roles = %v, want empty", roles)
|
|
}
|
|
if roles := provider.GetUserRoles("unknown"); roles != nil {
|
|
t.Errorf("unknown roles = %v, want nil", roles)
|
|
}
|
|
}
|
|
|
|
func TestConfigFileProvider_Name(t *testing.T) {
|
|
p := &ConfigFileProvider{}
|
|
if got := p.Name(); got != "config_file" {
|
|
t.Errorf("Name() = %q, want %q", got, "config_file")
|
|
}
|
|
}
|
|
|
|
func TestNewConfigFileProvider_Errors(t *testing.T) {
|
|
t.Run("invalid yaml", func(t *testing.T) {
|
|
_, err := NewConfigFileProvider([]byte("{{invalid"))
|
|
if err == nil {
|
|
t.Error("expected error for invalid yaml")
|
|
}
|
|
})
|
|
|
|
t.Run("no users", func(t *testing.T) {
|
|
_, err := NewConfigFileProvider([]byte("provider: config_file\nconfig_file:\n users: []"))
|
|
if err == nil {
|
|
t.Error("expected error for empty users list")
|
|
}
|
|
})
|
|
|
|
// bug-1951: an auth.yaml without a config_file block must be a clear
|
|
// configuration error, not a nil-pointer panic.
|
|
t.Run("missing config_file block", func(t *testing.T) {
|
|
_, err := NewConfigFileProvider([]byte("provider: config_file\n"))
|
|
if err == nil {
|
|
t.Fatal("expected error for missing config_file block")
|
|
}
|
|
if !strings.Contains(err.Error(), "config_file") {
|
|
t.Errorf("error %q should name the missing config_file block", err)
|
|
}
|
|
})
|
|
|
|
t.Run("empty username", func(t *testing.T) {
|
|
_, err := NewConfigFileProvider([]byte("provider: config_file\nconfig_file:\n users:\n - username: ''\n password: x"))
|
|
if err == nil {
|
|
t.Error("expected error for empty username")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPAMProvider_Name(t *testing.T) {
|
|
p := &PAMProvider{}
|
|
if got := p.Name(); got != "pam" {
|
|
t.Errorf("Name() = %q, want %q", got, "pam")
|
|
}
|
|
}
|
|
|
|
func TestLDAPProvider_Name(t *testing.T) {
|
|
p := &LDAPProvider{}
|
|
if got := p.Name(); got != "ldap" {
|
|
t.Errorf("Name() = %q, want %q", got, "ldap")
|
|
}
|
|
if err := p.Authenticate("u", "p"); err == nil {
|
|
t.Error("expected not-implemented error")
|
|
}
|
|
}
|
|
|
|
func TestOAuth2Provider_Name(t *testing.T) {
|
|
p := &OAuth2Provider{}
|
|
if got := p.Name(); got != "oauth2" {
|
|
t.Errorf("Name() = %q, want %q", got, "oauth2")
|
|
}
|
|
if err := p.Authenticate("u", "p"); err == nil {
|
|
t.Error("expected not-implemented error")
|
|
}
|
|
}
|
|
|
|
// bcrypt hashes (cost 10) used by the bug-1947 tests.
|
|
const (
|
|
testS3cretHash = "$2a$10$lvIFyyQJsUB5cZz68N0Yl.9sMCtqXARtlLnRmPc//gjDJfiOzplPq" // "s3cret"
|
|
testAdminHash = "$2a$10$WVwHd5Nd22UP1DfIpkekjum0yYVGctD2N.hheBP.5ZxGL92W/u5Ja" // "admin"
|
|
)
|
|
|
|
// bug-1947: auth.yaml passwords may be stored as bcrypt hashes.
|
|
func TestConfigFileProvider_BcryptPassword(t *testing.T) {
|
|
yaml := `
|
|
provider: config_file
|
|
config_file:
|
|
users:
|
|
- username: alice
|
|
password: "` + testS3cretHash + `"
|
|
roles: [basic_user]
|
|
`
|
|
provider, err := NewConfigFileProvider([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("NewConfigFileProvider: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pass string
|
|
wantErr bool
|
|
}{
|
|
{"correct password", "s3cret", false},
|
|
{"wrong password", "S3cret", true},
|
|
{"empty password", "", true},
|
|
{"the stored hash itself", testS3cretHash, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := provider.Authenticate("alice", tt.pass)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// bug-1947: startup warnings for plaintext entries and known default
|
|
// credentials; none for a bcrypt entry with a non-default password.
|
|
func TestConfigFileProvider_SecurityWarnings(t *testing.T) {
|
|
yaml := `
|
|
provider: config_file
|
|
config_file:
|
|
users:
|
|
- username: alice
|
|
password: "` + testS3cretHash + `"
|
|
roles: [basic_user]
|
|
- username: bob
|
|
password: plain-not-default
|
|
roles: [basic_user]
|
|
- username: admin
|
|
password: "` + testAdminHash + `"
|
|
roles: [cluster_admin]
|
|
`
|
|
provider, err := NewConfigFileProvider([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("NewConfigFileProvider: %v", err)
|
|
}
|
|
warnings := provider.SecurityWarnings()
|
|
joined := strings.Join(warnings, "\n")
|
|
if !strings.Contains(joined, `"bob"`) || !strings.Contains(joined, "plaintext") {
|
|
t.Errorf("expected a plaintext warning for bob, got %q", warnings)
|
|
}
|
|
if !strings.Contains(joined, `"admin"`) || !strings.Contains(joined, "default") {
|
|
t.Errorf("expected a default-credential warning for admin, got %q", warnings)
|
|
}
|
|
if strings.Contains(joined, `"alice"`) {
|
|
t.Errorf("alice has a bcrypt non-default password, got warning %q", warnings)
|
|
}
|
|
for _, w := range warnings {
|
|
if strings.Contains(w, "plain-not-default") || strings.Contains(w, "$2a$") {
|
|
t.Errorf("warning must not contain a password or hash: %q", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// bug-1947: the shipped auth.yaml (and its example) store only bcrypt
|
|
// hashes; the dev default admin/admin still logs in (tests/regression/run.sh
|
|
// relies on it) but is reported as a default credential at startup.
|
|
func TestShippedAuthConfig_HashedDefaults(t *testing.T) {
|
|
for _, path := range []string{"../../config/auth.yaml", "../../config/auth.yaml.example"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
provider, err := NewConfigFileProvider(data)
|
|
if err != nil {
|
|
t.Fatalf("NewConfigFileProvider: %v", err)
|
|
}
|
|
for name, u := range provider.users {
|
|
if !isBcryptHash(u.Password) {
|
|
t.Errorf("user %q: shipped password is not a bcrypt hash", name)
|
|
}
|
|
}
|
|
if err := provider.Authenticate("admin", "admin"); err != nil {
|
|
t.Errorf("admin/admin dev login must keep working: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(provider.SecurityWarnings(), "\n"), `"admin"`) {
|
|
t.Errorf("admin/admin must be reported as a default credential")
|
|
}
|
|
})
|
|
}
|
|
}
|