diff --git a/README.md b/README.md index 0529137..30bd486 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ printf '%s\n' 'a-local-secret' | ./hoocloak hash | `realms[].clients[]` | no | Optional realm SPA and service clients; absent or empty is valid. | | `users[].id` | yes | Stable subject identifier; shares a per-realm namespace with client IDs. | | `users[].username` | yes | Password-login name; unique per realm after Unicode case folding. | -| `users[].password_hash` | yes | Valid bcrypt hash, including when process-wide select mode is used. | +| `users[].password_hash` | no | Valid bcrypt hash when set. Users without it sign in with the default password `hoo`. A configured hash takes precedence. | | `users[].name`, `email`, `email_verified` | no | Optional profile values. With the corresponding scope, empty or omitted `name`/`email` and false or omitted `email_verified` are left out of serialized UserInfo and ID-token claims; only non-zero configured values are emitted. | | `users[].roles`, `permissions` | no | Optional unique authorization values; absent or empty is valid. Permissions are custom OAuth scope tokens, never reserved OIDC scopes. | | `clients[].id`, `type` | yes | Stable client ID and either `spa` or `service`; ID shares the realm namespace with user IDs. | diff --git a/internal/config/config.go b/internal/config/config.go index 854569b..89284c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -209,8 +209,10 @@ func (c Config) Validate() error { return fmt.Errorf("realms[%d] has duplicate username %q", realmIndex, user.Username) } usernames[username] = struct{}{} - if err := validBcrypt(user.PasswordHash); err != nil { - return fmt.Errorf("%s.password_hash: %w", where, err) + if user.PasswordHash != "" { + if err := validBcrypt(user.PasswordHash); err != nil { + return fmt.Errorf("%s.password_hash: %w", where, err) + } } if err := validatePermissions(user.Permissions, where+".permissions"); err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f39ce49..be4abae 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -159,6 +159,26 @@ func TestLoadAcceptsSupportedBcryptMinorVersions(t *testing.T) { } } +func TestLoadAcceptsUsersWithoutPasswordHash(t *testing.T) { + t.Parallel() + for _, passwordHashLine := range []string{"", " password_hash: \"\"\n"} { + configYAML := strings.Replace(validConfigYAML, + " password_hash: \"$2a$10$vWq8DjfdBvihgDARWb4jaOyhhRpU6Vgygi49GnwKTTVP45M8nPylW\"\n", + passwordHashLine, 1) + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Realms[0].Users[0].PasswordHash != "" { + t.Fatal("user unexpectedly has a password hash") + } + } +} + func TestValidateOriginAcceptsCanonicalForms(t *testing.T) { t.Parallel() for _, origin := range []string{ diff --git a/internal/idp/storage.go b/internal/idp/storage.go index b700a9d..e1107b0 100644 --- a/internal/idp/storage.go +++ b/internal/idp/storage.go @@ -329,7 +329,8 @@ func (s *Store) Authenticate(requestID, username, password string) error { userID, userOK := s.usernames[config.CanonicalUsername(username)] user := s.users[userID] hash := dummyPasswordHash - if userOK { + defaultPassword := userOK && user.PasswordHash == "" + if userOK && !defaultPassword { hash = user.PasswordHash } s.mu.Unlock() @@ -337,6 +338,9 @@ func (s *Store) Authenticate(requestID, username, password string) error { if err != nil { return err } + if defaultPassword { + passwordOK = subtle.ConstantTimeCompare([]byte(password), []byte("hoo")) == 1 + } if !requestOK { return errors.New("authorization request is missing or expired") } diff --git a/internal/idp/storage_test.go b/internal/idp/storage_test.go index 2e36ac7..e4870a9 100644 --- a/internal/idp/storage_test.go +++ b/internal/idp/storage_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/openhoo/hoocloak/internal/config" "github.com/zitadel/oidc/v3/pkg/oidc" ) @@ -373,6 +374,41 @@ func TestAuthenticationIntersectsClientAndUserScopes(t *testing.T) { t.Fatalf("additional ID-token scopes = %v", idScopes) } } + +func TestAuthenticationUsesDefaultPasswordOnlyWithoutHash(t *testing.T) { + clock := &fakeClock{current: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)} + cfg := testConfig(t) + cfg.Realms[0].Users = append(cfg.Realms[0].Users, config.User{ID: "bob", Username: "bob"}) + if err := cfg.Validate(); err != nil { + t.Fatalf("config with a user without a hash: %v", err) + } + store := NewStore(cfg.Realms[0], cfg.Tokens, "/realms/development", nil, "test-kid", clock) + for _, tt := range []struct { + name, username, password string + wantSuccess bool + }{ + {"default password", "bob", "hoo", true}, + {"wrong default password", "bob", "other", false}, + {"default password does not override hash", "alice", "hoo", false}, + {"configured hash still works", "alice", "alice-password", true}, + {"unknown user", "unknown", "hoo", false}, + } { + t.Run(tt.name, func(t *testing.T) { + request := &AuthRequest{id: tt.name, clientID: "react-spa", expires: clock.Now().Add(5 * time.Minute)} + store.authRequests[request.id] = request + err := store.Authenticate(request.id, tt.username, tt.password) + if tt.wantSuccess && err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if !tt.wantSuccess && !errors.Is(err, errInvalidCredentials) { + t.Fatalf("Authenticate() error = %v, want invalid credentials", err) + } + if request.done != tt.wantSuccess { + t.Fatalf("request.done = %v, want %v", request.done, tt.wantSuccess) + } + }) + } +} func TestSelectIdentityCompletesAuthorizationWithoutPassword(t *testing.T) { clock := &fakeClock{current: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)} store := newTestStore(t, clock)