mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 00:16:40 +00:00
chore: scaffold database tooling
This commit is contained in:
parent
19d94a6349
commit
49d3722b44
10 changed files with 200 additions and 16 deletions
42
AUTH_PLAN.md
42
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.
|
||||
|
|
|
|||
31
Makefile
31
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)
|
||||
|
|
|
|||
45
README.md
45
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 <http://localhost:8000>) 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
6
internal/driver/db/goose.toml
Normal file
6
internal/driver/db/goose.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[goose]
|
||||
dir = "migrations"
|
||||
|
||||
[goose.envs.local]
|
||||
driver = "postgres"
|
||||
open = "postgres://localhost/auth_dev?sslmode=disable"
|
||||
|
|
@ -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;
|
||||
0
internal/driver/db/queries/.gitkeep
Normal file
0
internal/driver/db/queries/.gitkeep
Normal file
16
internal/driver/db/sqlc.yaml
Normal file
16
internal/driver/db/sqlc.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue