Rebuild the schema and API on a working, multi-user foundation
Steps 1-4 of the review plan (docs/plans not checked in; see conversation):
Step 1 - make it boot from a clean checkout:
- Replace the old schema.sql + single ALTER-TABLE migration (which never
created the tables) with one init migration: users, sessions, platform,
game, all with UUIDv7-ready uuid ids, soft deletes (deleted_at), and a
shared change_seq sequence + trigger for a future sync feature.
- sqlc now reads the schema from db/migrations directly.
- Migrations are embedded in the binary (db/migrations/embed.go) instead of
being copied around and found via path fallbacks.
- ensureDatabase/runMigrations failures are now fatal, and main pings the
pool after connecting, instead of starting an API with no schema.
- Switched both the app and golang-migrate to pgx/v5 (dropped lib/pq).
- Fixed the Dockerfile's Go version (1.23 -> 1.25) and build target.
- Added a postgres healthcheck to docker-compose so the app waits for a
ready database instead of racing it.
- go.mod tidied, bin/ added to .gitignore, Justfile's docker-compose calls
switched to the v2 `docker compose` plugin.
Step 2 - auth and per-user data:
- internal/auth: argon2id password hashing, random session tokens stored
only as a hash.
- POST /api/auth/{register,login,logout}, GET /api/auth/me. Sessions work
via either an Authorization: Bearer header or an httpOnly cookie.
- Every platform/game query is scoped to the authenticated user.
Step 3 - API correctness:
- internal/model holds the DTOs, with no server-side dependencies, so
clients can import it later without pulling in Huma/Echo.
- dbError() maps ErrNoRows -> 404, unique violation -> 409, FK violation ->
409, instead of leaking Postgres error text with a 500.
- Soft-delete meant the FK on game.platform_id no longer blocks deleting a
referenced platform on its own; added a NOT EXISTS guard plus an
application-level check so that case now correctly returns 409 instead of
silently succeeding (caught by testing the live server, not by a mock).
- Added GET /games/{id} and GET /platforms/{id}. All JSON routes moved
under /api, freeing / for a future web client.
- ListGames/GetGame join platform_name in.
- cmd/openapi writes the generated spec to api/openapi.json.
Step 4 - tests and CI:
- Rewrote the mocks and handler tests for the new Querier shape; added
coverage for 401 (no session), 404 (not found / another user's row),
409 (duplicate email, platform referenced by games), 422 (score out of
range), and that queries are actually scoped by the authenticated user's
id, not just that the right status code comes back.
- .github/workflows/ci.yml: go vet, go test, go mod tidy -diff, sqlc diff.
Everything above was exercised against a real local Postgres (migrations
up/down, the trigger, constraints) and through the running server via curl
(register, login, scoping, every status-code case), not just unit tests.
Known regression, intentionally deferred to the next step: cmd/tui and
cmd/gui still call the old unauthenticated GET /games, which no longer
exists. They need internal/client and a login flow before they work again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47a9719eeb
commit
5072919b61
34 files changed
+1913
-512
No files matched your search
@@ -0,0 +1,69 @@
|
||||
// Package auth handles password hashing and session tokens.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters, taken from the RFC 9106 "low memory" recommendation
|
||||
// (2nd choice) as a reasonable default for a server handling interactive
|
||||
// logins without dedicated hardware.
|
||||
const (
|
||||
argonTime = 2
|
||||
argonMemory = 19 * 1024 // KiB
|
||||
argonThreads = 1
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// HashPassword returns a self-describing hash string: algorithm, parameters,
|
||||
// salt and key are all encoded together so the parameters can change later
|
||||
// without invalidating hashes already stored.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Key := base64.RawStdEncoding.EncodeToString(key)
|
||||
return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
argonMemory, argonTime, argonThreads, b64Salt, b64Key), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks password against an encoded hash produced by
|
||||
// HashPassword. It returns an error only on malformed input; a wrong
|
||||
// password is reported via the bool return.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "argon2id" {
|
||||
return false, errors.New("unrecognized hash format")
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, fmt.Errorf("parse hash params: %w", err)
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
wantKey, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode key: %w", err)
|
||||
}
|
||||
|
||||
gotKey := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(wantKey)))
|
||||
return subtle.ConstantTimeCompare(gotKey, wantKey) == 1, nil
|
||||
}
|
||||
Reference in new issue
Block a user