feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+132
View File
@@ -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
}