diff --git a/AUTH_PLAN.md b/AUTH_PLAN.md index 3e4e524..f4c3acc 100644 --- a/AUTH_PLAN.md +++ b/AUTH_PLAN.md @@ -40,3 +40,45 @@ - Add structured logging: text encoder for development, JSON for production deployments. - Consolidate templates with a base layout to remove duplication across pages. - Introduce configuration loading that sources environment variables, validates them, and exposes typed settings at startup. + +## Database Roadmap + +- **users** + - `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` + - `email citext NOT NULL UNIQUE` + - `display_name text` + - `created_at timestamptz NOT NULL DEFAULT now()` + - `updated_at timestamptz NOT NULL DEFAULT now()` +- **user_passwords** + - `user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE` + - `password_hash bytea NOT NULL` + - `password_salt bytea NOT NULL` + - `algorithm text NOT NULL` + - `created_at timestamptz NOT NULL DEFAULT now()` + - `updated_at timestamptz NOT NULL DEFAULT now()` +- **user_oauth_accounts** + - `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` + - `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE` + - `provider text NOT NULL` + - `subject text NOT NULL` + - `email text` + - `email_verified boolean NOT NULL DEFAULT false` + - `profile jsonb` + - `created_at timestamptz NOT NULL DEFAULT now()` + - `updated_at timestamptz NOT NULL DEFAULT now()` + - Indexes: `UNIQUE(provider, subject)` and `INDEX(user_id)` +- **login_events** + - `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` + - `user_id uuid REFERENCES users(id)` + - `provider text` + - `success boolean NOT NULL` + - `ip inet` + - `user_agent text` + - `created_at timestamptz NOT NULL DEFAULT now()` + - Indexes: `INDEX(user_id)`, `INDEX(created_at)` + +Notes: + +- All timestamps default to UTC via `now()`. +- Authentication data stays normalized; optional password/OAuth records live in dedicated tables for clarity and extension. +- `login_events` remains a standard logged table to preserve audit history; revisit storage strategy if write volume demands partitioning later. diff --git a/Makefile b/Makefile index 2bba225..6ffecde 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,15 @@ BIN_DIR := bin BIN_NAME := auth-server FMT_PATHS := $(shell go list -f '{{.Dir}}' ./...) +MIGRATIONS_DIR := internal/driver/db/migrations +SQLC_CONFIG := internal/driver/db/sqlc.yaml +DB_URL ?= $(AUTH_DATABASE_URL) +DB_URL := $(strip $(DB_URL)) +ifeq ($(DB_URL),) +DB_URL := postgres://localhost/auth_dev?sslmode=disable +endif -.PHONY: run dev build test fmt lint tidy clean +.PHONY: run dev build test fmt lint tidy clean migrate-status migrate-up migrate-down migrate-reset migrate-new sqlc-generate run: go run ./cmd/server @@ -28,3 +35,25 @@ tidy: clean: rm -rf $(BIN_DIR) + +migrate-status: + goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" status + +migrate-up: + goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" up + +migrate-down: + goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" down + +migrate-reset: + goose -dir $(MIGRATIONS_DIR) postgres "$(DB_URL)" reset + +migrate-new: + @if [ -z "$(name)" ]; then \ + echo "usage: make migrate-new name=add_feature"; \ + exit 1; \ + fi + goose -dir $(MIGRATIONS_DIR) create $(name) sql + +sqlc-generate: + sqlc generate -f $(SQLC_CONFIG) diff --git a/README.md b/README.md index 8586e63..8ae76a3 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,18 @@ templates/assets for single-binary deployment. run `set -a; . ./.env; set +a`. 2. Use the targets in the [Makefile](./Makefile): - | Target | Description | - | ------------ | --------------------------------------------------------------------------------------- | - | `make run` | Start the HTTP server with the current environment. | - | `make dev` | Launch [Air](https://github.com/cosmtrek/air) for live reload (requires `air` on PATH). | - | `make build` | Compile to `./bin/auth-server`. | - | `make test` | Run `go test ./... -cover -count=1`. | + | Target | Description | + | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | + | `make run` | Start the HTTP server with the current environment. | + | `make dev` | Launch [Air](https://github.com/cosmtrek/air) for live reload (requires `air` on PATH). | + | `make build` | Compile to `./bin/auth-server`. | + | `make test` | Run `go test ./... -cover -count=1`. | + | `make migrate-status` | Show Goose migration status for the configured database. | + | `make migrate-up` | Apply pending migrations to the database at `AUTH_DATABASE_URL` (defaults to `postgres://localhost/auth_dev?sslmode=disable`). | + | `make migrate-down` | Roll back the most recent migration in the target database. | + | `make migrate-reset` | Reset the schema by rolling back all migrations, then re-applying them. | + | `make migrate-new name=` | Create a timestamped SQL migration (e.g. `make migrate-new name=add_users`). | + | `make sqlc-generate` | Regenerate data-access code from SQL queries via `sqlc`. | 3. Visit the login page (default ) and authenticate with the demo credentials displayed on screen. @@ -33,15 +39,24 @@ templates/assets for single-binary deployment. Settings are sourced from environment variables (see [.env](./.env)). -| Variable | Required | Default | Description | -| --------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------- | -| `AUTH_SESSION_SECRET` | Yes | — | Base64-encoded secret used to sign session cookies. | -| `AUTH_LISTEN_ADDR` | No | `:8000` | Address the HTTP server binds to. | -| `AUTH_ENV` | No | `development` | Environment label, controls logger source annotation. | -| `AUTH_LOG_MODE` | No | `text` | Structured log encoder (`text` or `json`). | -| `AUTH_GOOGLE_CLIENT_ID` | Conditional | — | Google OAuth 2.0 client ID; required when enabling Google social login. | -| `AUTH_GOOGLE_CLIENT_SECRET` | Conditional | — | Google OAuth 2.0 client secret matching the ID above. | -| `AUTH_GOOGLE_REDIRECT_URL` | Conditional | — | Registered redirect URL (e.g. `http://localhost:8000/login/google/callback`). | +| Variable | Required | Default | Description | +| --------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------ | +| `AUTH_SESSION_SECRET` | Yes | — | Base64-encoded secret used to sign session cookies. | +| `AUTH_DATABASE_URL` | Yes | — | PostgreSQL connection string (e.g. `postgres://localhost/auth_dev?sslmode=disable`). | +| `AUTH_LISTEN_ADDR` | No | `:8000` | Address the HTTP server binds to. | +| `AUTH_ENV` | No | `development` | Environment label, controls logger source annotation. | +| `AUTH_LOG_MODE` | No | `text` | Structured log encoder (`text` or `json`). | +| `AUTH_GOOGLE_CLIENT_ID` | Conditional | — | Google OAuth 2.0 client ID; required when enabling Google social login. | +| `AUTH_GOOGLE_CLIENT_SECRET` | Conditional | — | Google OAuth 2.0 client secret matching the ID above. | +| `AUTH_GOOGLE_REDIRECT_URL` | Conditional | — | Registered redirect URL (e.g. `http://localhost:8000/login/google/callback`). | + +## Database Tooling + +Migrations live in [`internal/driver/db/migrations`](./internal/driver/db/migrations) and are managed with +[Goose](https://github.com/pressly/goose). Point `AUTH_DATABASE_URL` at your PostgreSQL instance—`postgres://localhost/auth_dev?sslmode=disable` +is a good local default—then use the Makefile helpers (`make migrate-up`, `make migrate-status`, etc.) to evolve the schema. The same DSN drives +[`sqlc`](https://sqlc.dev/) generation with `make sqlc-generate`, which reads [`internal/driver/db/sqlc.yaml`](./internal/driver/db/sqlc.yaml) and +emits typed data-access code alongside the queries. ## Project Layout diff --git a/internal/config/config.go b/internal/config/config.go index 473a587..4053373 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ const ( envLogMode = "AUTH_LOG_MODE" envEnvironment = "AUTH_ENV" envSessionSecret = "AUTH_SESSION_SECRET" + envDatabaseURL = "AUTH_DATABASE_URL" envGoogleClientID = "AUTH_GOOGLE_CLIENT_ID" envGoogleClientSecret = "AUTH_GOOGLE_CLIENT_SECRET" envGoogleRedirectURL = "AUTH_GOOGLE_REDIRECT_URL" @@ -29,6 +30,7 @@ type Config struct { LogMode logging.Mode Environment string SessionSecret []byte + DatabaseURL string GoogleOAuth GoogleOAuthConfig } @@ -63,6 +65,11 @@ func New() (*Config, error) { return nil, fmt.Errorf("invalid %s: %w", envSessionSecret, err) } + databaseURL := strings.TrimSpace(os.Getenv(envDatabaseURL)) + if databaseURL == "" { + return nil, fmt.Errorf("missing required configuration: set %s", envDatabaseURL) + } + googleOAuth := GoogleOAuthConfig{ ClientID: strings.TrimSpace(os.Getenv(envGoogleClientID)), ClientSecret: strings.TrimSpace(os.Getenv(envGoogleClientSecret)), @@ -78,6 +85,7 @@ func New() (*Config, error) { LogMode: logMode, Environment: environment, SessionSecret: secret, + DatabaseURL: databaseURL, GoogleOAuth: googleOAuth, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9305cf0..061c174 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,7 @@ import ( func TestNewDefaults(t *testing.T) { t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32))) + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") cfg, err := New() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -30,6 +31,7 @@ func TestNewDefaults(t *testing.T) { func TestNewOverrides(t *testing.T) { secret := base64.StdEncoding.EncodeToString(bytesOfLength(40)) t.Setenv("AUTH_SESSION_SECRET", secret) + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") t.Setenv("AUTH_LISTEN_ADDR", "127.0.0.1:9000") t.Setenv("AUTH_LOG_MODE", "json") t.Setenv("AUTH_ENV", "production") @@ -54,6 +56,7 @@ func TestNewOverrides(t *testing.T) { func TestNewMissingSecret(t *testing.T) { t.Setenv("AUTH_SESSION_SECRET", "") + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") if _, err := New(); err == nil { t.Fatalf("expected error for missing secret") } @@ -61,6 +64,7 @@ func TestNewMissingSecret(t *testing.T) { func TestNewInvalidSecret(t *testing.T) { t.Setenv("AUTH_SESSION_SECRET", "not-base64") + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") if _, err := New(); err == nil { t.Fatalf("expected error for invalid secret") } @@ -68,6 +72,7 @@ func TestNewInvalidSecret(t *testing.T) { func TestNewShortSecretAccepted(t *testing.T) { t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(16))) + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") if _, err := New(); err != nil { t.Fatalf("expected short secret to pass config load, got %v", err) } @@ -77,6 +82,7 @@ func TestNewGoogleOAuthPartialConfiguration(t *testing.T) { t.Setenv("AUTH_SESSION_SECRET", base64.StdEncoding.EncodeToString(bytesOfLength(32))) t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client") t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "") + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") if _, err := New(); err == nil { t.Fatalf("expected error for partial google oauth config") } @@ -87,6 +93,7 @@ func TestNewGoogleOAuthConfigured(t *testing.T) { t.Setenv("AUTH_GOOGLE_CLIENT_ID", "client") t.Setenv("AUTH_GOOGLE_CLIENT_SECRET", "secret") t.Setenv("AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8000/login/google/callback") + t.Setenv("AUTH_DATABASE_URL", "postgres://localhost/auth_test?sslmode=disable") cfg, err := New() if err != nil { diff --git a/internal/driver/db/goose.toml b/internal/driver/db/goose.toml new file mode 100644 index 0000000..c493c1c --- /dev/null +++ b/internal/driver/db/goose.toml @@ -0,0 +1,6 @@ +[goose] +dir = "migrations" + +[goose.envs.local] +driver = "postgres" +open = "postgres://localhost/auth_dev?sslmode=disable" diff --git a/internal/driver/db/migrations/202509210001_initial_auth_schema.sql b/internal/driver/db/migrations/202509210001_initial_auth_schema.sql new file mode 100644 index 0000000..686adf2 --- /dev/null +++ b/internal/driver/db/migrations/202509210001_initial_auth_schema.sql @@ -0,0 +1,59 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email CITEXT NOT NULL UNIQUE, + display_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_passwords ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash BYTEA NOT NULL, + password_salt BYTEA NOT NULL, + algorithm TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_oauth_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + subject TEXT NOT NULL, + email TEXT, + email_verified BOOLEAN NOT NULL DEFAULT false, + profile JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX user_oauth_accounts_provider_subject_idx + ON user_oauth_accounts (provider, subject); + +CREATE INDEX user_oauth_accounts_user_id_idx + ON user_oauth_accounts (user_id); + +CREATE TABLE login_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + provider TEXT, + success BOOLEAN NOT NULL, + ip INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX login_events_user_id_idx ON login_events (user_id); +CREATE INDEX login_events_created_at_idx ON login_events (created_at); + +-- +goose Down +DROP TABLE IF EXISTS login_events; +DROP TABLE IF EXISTS user_oauth_accounts; +DROP TABLE IF EXISTS user_passwords; +DROP TABLE IF EXISTS users; +DROP EXTENSION IF EXISTS citext; +DROP EXTENSION IF EXISTS pgcrypto; diff --git a/internal/driver/db/queries/.gitkeep b/internal/driver/db/queries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/driver/db/sqlc.yaml b/internal/driver/db/sqlc.yaml new file mode 100644 index 0000000..41f7619 --- /dev/null +++ b/internal/driver/db/sqlc.yaml @@ -0,0 +1,16 @@ +version: "2" +sql: + - engine: "postgresql" + schema: migrations + queries: + - queries + gen: + go: + package: db + out: internal/driver/db/sqlc + emit_json_tags: true + sql_package: pgx/v5 + emit_interface: false + overrides: + - db_type: "uuid" + go_type: github.com/google/uuid.UUID diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8bedde6..ecd9d84 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -21,6 +21,7 @@ func newTestServer(t *testing.T) *Server { LogMode: logging.ModeText, Environment: "test", SessionSecret: bytes.Repeat([]byte("s"), 32), + DatabaseURL: "postgres://localhost/auth_test?sslmode=disable", } logger := logging.New(io.Discard, logging.ModeText, nil) @@ -45,6 +46,7 @@ func newGoogleTestServer(t *testing.T) *Server { ClientSecret: "secret", RedirectURL: "http://localhost/login/google/callback", }, + DatabaseURL: "postgres://localhost/auth_test?sslmode=disable", } logger := logging.New(io.Discard, logging.ModeText, nil)