feat: backend service
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user