package admin import ( "context" "errors" "strings" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) // Verify implements `basicauth.Verifier`. The middleware in // `internal/server/middleware/basicauth/basicauth.go:84` invokes this // method on every admin request. // // Behaviour: // // 1. Empty username rejects fast. // 2. Cache lookup; on miss, fall back to a direct Postgres read and // populate the cache. Lookup misses return (false, nil) — no // account-existence leak. // 3. Disabled accounts (`disabled_at IS NOT NULL`) reject without // hitting bcrypt. // 4. `bcrypt.CompareHashAndPassword` runs constant-time on the // matching path; a mismatch returns (false, nil) so the // middleware emits 401 with the standard envelope. // 5. On match a fire-and-forget goroutine bumps `last_used_at` and // refreshes the cached entry. Errors on the bump are logged but // never block the request. // 6. Any other error returned by the lookup path surfaces to the // middleware which maps it to 500. func (s *Service) Verify(ctx context.Context, username, password string) (bool, error) { username = strings.TrimSpace(username) if username == "" { return false, nil } admin, hash, err := s.lookupForVerify(ctx, username) if err != nil { if errors.Is(err, ErrNotFound) { return false, nil } return false, err } if admin.DisabledAt != nil { return false, nil } switch err := bcrypt.CompareHashAndPassword(hash, []byte(password)); { case err == nil: s.touchLastUsedAsync(username, hash) return true, nil case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): return false, nil default: return false, err } } // lookupForVerify reads the cache first and falls back to Postgres on // miss, populating the cache so subsequent requests skip the round // trip. The returned hash slice is the cached entry's reference; // callers must not mutate it. func (s *Service) lookupForVerify(ctx context.Context, username string) (Admin, []byte, error) { if admin, hash, ok := s.deps.Cache.Get(username); ok { return admin, hash, nil } admin, hash, err := s.deps.Store.Lookup(ctx, username) if err != nil { return Admin{}, nil, err } s.deps.Cache.Put(admin, hash) return admin, hash, nil } // touchLastUsedAsync schedules a fire-and-forget UPDATE on // `last_used_at`. The update uses a fresh background context so the // write survives the request lifecycle even when the caller // disconnects mid-response. On success the cached entry is refreshed // in place so subsequent reads see the new timestamp; failures are // logged at warn level and the cache stays at the old value. // // `last_used_at` is observability-only: it never gates authentication. // The fire-and-forget pattern keeps the request path single-digit // milliseconds even under transient Postgres latency. func (s *Service) touchLastUsedAsync(username string, hash []byte) { now := s.deps.Now().UTC() go func() { // Background context — the request may complete before the // goroutine reaches Postgres. The store query carries no // timeout of its own; the pool's default operation timeout // applies instead. ctx := context.Background() if err := s.deps.Store.TouchLastUsed(ctx, username, now); err != nil { s.deps.Logger.Warn("touch last_used_at failed", zap.String("admin_username", username), zap.Error(err), ) return } // Refresh the cached entry. We re-read so the cache reflects // any concurrent disable/enable that happened between the // successful Verify and the bump. if admin, freshHash, ok := s.deps.Cache.Get(username); ok { admin.LastUsedAt = &now // Prefer the slice that was just verified; if the cache // rotated to a different hash (concurrent password // reset), keep the cached one to avoid clobbering it. if hashesEqual(freshHash, hash) { s.deps.Cache.Put(admin, hash) } else { s.deps.Cache.Put(admin, freshHash) } } }() } // hashesEqual reports whether two bcrypt hashes are byte-identical. // The caller cares only about staleness detection — bcrypt hashes are // not secret in the cache (the cache lives in process memory), so a // timing-leaking comparison is acceptable. func hashesEqual(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }