diff --git a/Makefile b/Makefile index 196e438..fdeb191 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ export APP_ENV ?= local export HTTP_ADDR ?= :8080 export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable +export REDIS_URL ?= redis://localhost:6379/0 +export AUTH_USERNAME ?= nomadcode export GOOSE_DRIVER ?= postgres export GOOSE_DBSTRING ?= $(DATABASE_URL) export OUTPUT ?= .build/nomadcode-core diff --git a/README.md b/README.md index 8eb10f7..b1d2587 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - River dummy job - Plane Adapter stub - Mattermost Adapter stub -- 선택적 Docker Compose 실행 환경 +- 선택적 Docker Compose 실행 환경(PostgreSQL, Redis) ## 현재 구현하지 않은 범위 @@ -60,7 +60,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 ## 실행 방법 -로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. +로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. Redis는 이후 event fan-out / realtime push 경로를 위한 선택 구성으로, Docker Compose에서는 `redis://redis:6379/0`로 노출합니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. PostgreSQL 사용자와 DB 생성 예시: @@ -81,6 +81,13 @@ Migration 실행: ./bin/run ``` +간단한 암호를 걸고 실행: + +```bash +AUTH_PASSWORD="change-me" ./bin/run +curl -u nomadcode:change-me localhost:8080/api/tasks +``` + 다른 DB 주소를 사용할 경우: ```bash @@ -102,10 +109,10 @@ sqlc 실행: DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으로 `internal/db/`에 생성합니다. `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 생성 파일이므로 직접 수정하지 않습니다. -Docker Compose 실행은 통합 확인이 필요할 때 선택적으로 사용합니다. +Docker Compose 실행은 PostgreSQL과 Redis를 함께 띄워 통합 확인이 필요할 때 선택적으로 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다. ```bash -./bin/docker-up +AUTH_PASSWORD="change-me" ./bin/docker-up ``` Makefile은 같은 명령을 감싸는 얇은 alias입니다. diff --git a/bin/run b/bin/run index d024278..a02d319 100755 --- a/bin/run +++ b/bin/run @@ -7,5 +7,7 @@ cd "$ROOT_DIR" export APP_ENV="${APP_ENV:-local}" export HTTP_ADDR="${HTTP_ADDR:-:8080}" export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" +export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" +export AUTH_USERNAME="${AUTH_USERNAME:-nomadcode}" exec go run ./cmd/server "$@" diff --git a/cmd/server/main.go b/cmd/server/main.go index 8c102f1..54bf5f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -62,7 +62,7 @@ func run(logger *slog.Logger) error { handler := apphttp.NewHandler(pool, workflowService, logger) server := &http.Server{ Addr: cfg.HTTPAddr, - Handler: apphttp.NewRouter(handler, logger), + Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}), ReadHeaderTimeout: 5 * time.Second, } diff --git a/docker-compose.yml b/docker-compose.yml index 094239b..f44a4c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,12 +15,28 @@ services: volumes: - postgres-data:/var/lib/postgresql/data + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - redis-data:/data + nomadcode-core: build: . environment: APP_ENV: local HTTP_ADDR: :8080 DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable + REDIS_URL: redis://redis:6379/0 + AUTH_USERNAME: ${AUTH_USERNAME:-nomadcode} + AUTH_PASSWORD: ${AUTH_PASSWORD:-} MATTERMOST_BASE_URL: "" MATTERMOST_TOKEN: "" PLANE_BASE_URL: "" @@ -28,8 +44,11 @@ services: depends_on: postgres: condition: service_healthy + redis: + condition: service_healthy ports: - "8080:8080" volumes: postgres-data: + redis-data: diff --git a/internal/config/config.go b/internal/config/config.go index 5961d76..8d9a6fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,9 @@ type Config struct { AppEnv string HTTPAddr string DatabaseURL string + RedisURL string + AuthUsername string + AuthPassword string MattermostBaseURL string MattermostToken string PlaneBaseURL string @@ -17,6 +20,9 @@ func Load() Config { AppEnv: getEnv("APP_ENV", "local"), HTTPAddr: getEnv("HTTP_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), + RedisURL: os.Getenv("REDIS_URL"), + AuthUsername: getEnv("AUTH_USERNAME", "nomadcode"), + AuthPassword: os.Getenv("AUTH_PASSWORD"), MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"), MattermostToken: os.Getenv("MATTERMOST_TOKEN"), PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), diff --git a/internal/http/middleware.go b/internal/http/middleware.go index d08d153..fe6b8b1 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -1,6 +1,7 @@ package http import ( + "crypto/subtle" "log/slog" stdhttp "net/http" "time" @@ -35,3 +36,31 @@ func loggingMiddleware(logger *slog.Logger) func(stdhttp.Handler) stdhttp.Handle }) } } + +func basicAuthMiddleware(auth AuthConfig) func(stdhttp.Handler) stdhttp.Handler { + if auth.Password == "" { + return func(next stdhttp.Handler) stdhttp.Handler { + return next + } + } + if auth.Username == "" { + auth.Username = "nomadcode" + } + + return func(next stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + username, password, ok := r.BasicAuth() + if !ok || !secureCompare(username, auth.Username) || !secureCompare(password, auth.Password) { + w.Header().Set("WWW-Authenticate", `Basic realm="nomadcode-core", charset="UTF-8"`) + writeError(w, stdhttp.StatusUnauthorized, "authentication required") + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func secureCompare(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} diff --git a/internal/http/middleware_test.go b/internal/http/middleware_test.go new file mode 100644 index 0000000..c93a6de --- /dev/null +++ b/internal/http/middleware_test.go @@ -0,0 +1,73 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestBasicAuthMiddlewareDisabledWithoutPassword(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestBasicAuthMiddlewareRejectsMissingCredentials(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got == "" { + t.Fatal("expected WWW-Authenticate header") + } +} + +func TestBasicAuthMiddlewareAcceptsValidCredentials(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + req.SetBasicAuth("nomadcode", "secret") + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestRouterKeepsHealthzPublicAndProtectsAPI(t *testing.T) { + router := NewRouter(NewHandler(nil, nil, nil), nil, AuthConfig{ + Username: "nomadcode", + Password: "secret", + }) + + healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil) + healthRec := httptest.NewRecorder() + router.ServeHTTP(healthRec, healthReq) + + if healthRec.Code != http.StatusOK { + t.Fatalf("expected /healthz status %d, got %d", http.StatusOK, healthRec.Code) + } + + apiReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + apiRec := httptest.NewRecorder() + router.ServeHTTP(apiRec, apiReq) + + if apiRec.Code != http.StatusUnauthorized { + t.Fatalf("expected /api/tasks status %d, got %d", http.StatusUnauthorized, apiRec.Code) + } +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} diff --git a/internal/http/router.go b/internal/http/router.go index 8de899c..bf1c2ca 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -8,7 +8,12 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler { +type AuthConfig struct { + Username string + Password string +} + +func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.Handler { r := chi.NewRouter() r.Use(chimiddleware.RequestID) r.Use(chimiddleware.RealIP) @@ -16,14 +21,18 @@ func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler { r.Use(chimiddleware.Recoverer) r.Get("/healthz", handler.Healthz) - r.Get("/readyz", handler.Readyz) - r.Route("/api", func(r chi.Router) { + protected := chi.NewRouter() + protected.Use(basicAuthMiddleware(auth)) + protected.Get("/readyz", handler.Readyz) + + protected.Route("/api", func(r chi.Router) { r.Post("/tasks", handler.CreateTask) r.Get("/tasks", handler.ListTasks) r.Get("/tasks/{id}", handler.GetTask) r.Post("/tasks/{id}/enqueue", handler.EnqueueTask) }) + r.Mount("/", protected) return r }