133 lines
4.3 KiB
Go
133 lines
4.3 KiB
Go
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
|
|
}
|