From c2091e746a14e7c95f49d35490502a67d43c0b8d Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Fri, 2 Oct 2026 21:45:42 +0200 Subject: [PATCH] fix installer --- .env.example | 6 ++-- README.md | 13 +++++---- docker-compose.yaml | 19 +++++++++---- docs/client-login.md | 8 +++--- docs/installation.md | 23 ++++++++++------ docs/operations.md | 6 ++-- docs/password-authentication.md | 24 ++++++---------- internal/config/config.go | 9 +++--- internal/config/config_test.go | 14 ++++++++++ internal/server/installer.go | 8 +++--- internal/server/oidc_test.go | 33 +++++++++++++++++++--- internal/server/origin_test.go | 41 ++++++++++++++++++++++++++++ internal/server/passwordauth.go | 13 +++++++++ internal/server/passwordauth_test.go | 20 ++++++++++++++ internal/server/server.go | 15 ++++------ internal/server/tenantauth.go | 37 ++++++++++++++++++++++--- internal/server/tenantauth_test.go | 29 ++++++++++++++++++++ internal/store/passwordauth.go | 12 ++++++++ web/login.html | 7 +++-- 19 files changed, 264 insertions(+), 73 deletions(-) create mode 100644 internal/server/origin_test.go create mode 100644 internal/server/tenantauth_test.go diff --git a/.env.example b/.env.example index 5b69aec..8e92c2f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ GO_LOOSE_ADDR=:8080 -GO_LOOSE_BASE_URL=http://localhost:8080 -GO_LOOSE_AUTH_DOMAIN=auth.local +GO_LOOSE_BASE_URL=https://auth.dev +GO_LOOSE_AUTH_DOMAIN=auth.dev GO_LOOSE_DATABASE_URL=postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable GO_LOOSE_SESSION_SECRET=replace-with-at-least-32-random-characters GO_LOOSE_DEV_LOGIN=false @@ -11,7 +11,7 @@ GO_LOOSE_LOCAL_ADMIN_PASSWORD= GO_LOOSE_OIDC_ISSUER= GO_LOOSE_OIDC_CLIENT_ID= GO_LOOSE_OIDC_CLIENT_SECRET= -GO_LOOSE_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback +GO_LOOSE_OIDC_REDIRECT_URL=https://auth.dev/auth/callback GO_LOOSE_ALLOWED_EMAIL_DOMAINS= GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=false GO_LOOSE_BOOTSTRAP_TENANT=nmbs diff --git a/README.md b/README.md index f40822e..57d8aea 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ Go Loose is a multi-tenant API-key control plane for Go HTTP services. It gives docker compose up --build ``` -Open . The Compose setup deliberately enables database login and private OpenAPI URL scanning for local development. Never use unrestricted private URL scanning in production. +Start the shared TLS proxy using `/home/dnoulet/go/infrasctruture`, add +`127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev` to `/etc/hosts`, then open +. The Compose setup deliberately enables database login and +private OpenAPI URL scanning for local development. Never use unrestricted +private URL scanning in production. -On a fresh database, open , enter the Google OAuth client secret from Google Cloud Console, and optionally seed the NMBS and YPTO Guess demonstration data. The CMS Demo client ID and callback URL are prefilled. See the [installation guide](docs/installation.md) for Google setup, first-admin behavior, waiting-room approval, and all demo credentials. +On a fresh database, open , enter the Google OAuth client secret from Google Cloud Console, and optionally seed the NMBS and YPTO Guess demonstration data. The CMS Demo client ID and callback URL are prefilled. See the [installation guide](docs/installation.md) for Google setup, first-admin behavior, waiting-room approval, and all demo credentials. -Hosted user login for that tenant is served from `http://nmbs.auth.local:8080`; add `127.0.0.1 nmbs.auth.local` to `/etc/hosts` for local browser testing. +The bare `auth.dev` host is reserved for system-administrator SSO. Tenant administrators use database credentials on their tenant host, such as `https://nmbs.auth.dev`. -If port 8080 is occupied, run `GO_LOOSE_PORT=18080 docker compose up --build` and open . -If PostgreSQL port 5432 is occupied, also set `GO_LOOSE_POSTGRES_PORT=15432`. +If PostgreSQL port 5433 is occupied, set `GO_LOOSE_POSTGRES_PORT=15432`. The first Google SSO user becomes system administrator. Later SSO users remain in a waiting room until the system administrator assigns a tenant, role, and application access. Tenant administrators can manage only their own tenants. diff --git a/docker-compose.yaml b/docker-compose.yaml index 614f47b..9636f50 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,15 +23,16 @@ services: condition: service_healthy environment: GO_LOOSE_ADDR: ":8080" - GO_LOOSE_BASE_URL: "http://localhost:${GO_LOOSE_PORT:-8080}" - GO_LOOSE_AUTH_DOMAIN: "auth.local" + GO_LOOSE_BASE_URL: "https://auth.dev" + GO_LOOSE_AUTH_DOMAIN: "auth.dev" + GO_LOOSE_OIDC_REDIRECT_URL: "https://auth.dev/auth/callback" GO_LOOSE_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable" GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now" GO_LOOSE_DEV_LOGIN: "false" GO_LOOSE_LOCAL_LOGIN: "true" GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" - ports: - - "${GO_LOOSE_PORT:-8081}:8080" + expose: + - "8080" healthcheck: test: ["CMD", "/go-loose", "-healthcheck"] interval: 10s @@ -39,9 +40,15 @@ services: retries: 5 start_period: 10s networks: - - go_loose_network + go_loose_network: {} + local_dev_edge: + aliases: + - go-loose volumes: postgres-data2: networks: - go_loose_network: \ No newline at end of file + go_loose_network: + local_dev_edge: + external: true + name: local-dev-edge \ No newline at end of file diff --git a/docs/client-login.md b/docs/client-login.md index 5750565..6ba2891 100644 --- a/docs/client-login.md +++ b/docs/client-login.md @@ -9,7 +9,7 @@ Go Loose can host login for browser-facing Go applications. Your application nev 3. Save and copy the client secret. It is shown once. 4. Open **Users**, invite the user by the email returned by the IdP, and choose **Manage** to grant access to the application. -For database login, the invitation also requires an initial password. The same tenant login screen supports both database credentials and SSO. +The invitation requires an initial password. Tenant login hosts accept database credentials only; SSO is reserved for system administration on the bare authentication domain. Saving client configuration rotates the secret. Callback URLs require HTTPS, except `localhost` and `127.0.0.1` while Go Loose development login is enabled. @@ -80,16 +80,16 @@ Each tenant has its own login origin: https://. ``` -For tenant `nmbs` with `GO_LOOSE_AUTH_DOMAIN=auth.local`, configure the Go client with: +For tenant `nmbs` with `GO_LOOSE_AUTH_DOMAIN=auth.dev`, configure the Go client with: ```go -BaseURL: "http://nmbs.auth.local:18080" +BaseURL: "http://nmbs.auth.dev:18080" ``` For local development, add the tenant hostname to `/etc/hosts`: ```text -127.0.0.1 nmbs.auth.local +127.0.0.1 nmbs.auth.dev ``` Production should configure wildcard DNS and TLS for `*.auth.example.com`, route those hosts to Go Loose, and use: diff --git a/docs/installation.md b/docs/installation.md index 2615587..04d557a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,10 +8,15 @@ Start Go Loose with PostgreSQL and a stable `GO_LOOSE_SESSION_SECRET` of at leas docker compose up --build ``` -Open `http://localhost:8080/install`. The installer asks for a Google OAuth client ID and client secret. In Google Cloud Console, configure this authorized redirect URI before submitting: +Start the shared Traefik and certificate setup documented in +`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, add +`127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev` to `/etc/hosts`, then open +`https://auth.dev/install`. The installer asks for a Google OAuth client ID and +client secret. In Google Cloud Console, configure this authorized redirect URI +before submitting: ```text -http://localhost:8080/auth/callback +https://auth.dev/auth/callback ``` The supplied **CMS Demo** client ID is prefilled: @@ -44,10 +49,10 @@ The installer selects the demonstration seed by default. It creates the `nmbs` a | Tenant | Email | Password | Role | |---|---|---|---| -| NMBS | `interview@nmbs.auth.local` | `admin123` | Tenant administrator | -| YPTO | `interview@ypto.auth.local` | `admin123` | Tenant administrator | -| NMBS | `reviewer@nmbs.auth.local` | `Demo-Nmbs-2026!` | Tenant administrator | -| YPTO | `reviewer@ypto.auth.local` | `Demo-Ypto-2026!` | Tenant administrator | +| NMBS | `interview@nmbs.auth.dev` | `admin123` | Tenant administrator | +| YPTO | `interview@ypto.auth.dev` | `admin123` | Tenant administrator | +| NMBS | `reviewer@nmbs.auth.dev` | `Demo-Nmbs-2026!` | Tenant administrator | +| YPTO | `reviewer@ypto.auth.dev` | `Demo-Ypto-2026!` | Tenant administrator | All four users receive access only to their tenant's Guess application. Passwords are stored as Argon2id hashes; plaintext credentials are never written to PostgreSQL. @@ -56,14 +61,14 @@ These accounts and especially `admin123` are intentionally non-production creden For local hosted-login testing, add: ```text -127.0.0.1 nmbs.auth.local ypto.auth.local +127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev ``` The tenant login origins are then: ```text -http://nmbs.auth.local:8080 -http://ypto.auth.local:8080 +https://nmbs.auth.dev +https://ypto.auth.dev ``` An application must first have exact redirect URIs and a generated client secret configured in the management console before `/connect/authorize` can start its login flow. diff --git a/docs/operations.md b/docs/operations.md index 2ef140a..9caf281 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -17,17 +17,17 @@ https:///auth/callback The web installer is preferred. Environment configuration remains available for existing or fully automated deployments: ```dotenv -GO_LOOSE_BASE_URL=https://nmbs.goloose.local +GO_LOOSE_BASE_URL=https://auth.example.com GO_LOOSE_AUTH_DOMAIN=auth.example.com GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform GO_LOOSE_OIDC_CLIENT_ID=go-loose GO_LOOSE_OIDC_CLIENT_SECRET= -GO_LOOSE_OIDC_REDIRECT_URL=https://nmbs.goloose.local/auth/callback +GO_LOOSE_OIDC_REDIRECT_URL=https://auth.example.com/auth/callback GO_LOOSE_ALLOWED_EMAIL_DOMAINS=example.com GO_LOOSE_DEV_LOGIN=false ``` -Client applications are served on `https://.auth.example.com`; configure wildcard DNS and TLS for `*.auth.example.com`. The OIDC callback host and `GO_LOOSE_BASE_URL` must be inside the configured auth domain so the callback can issue the shared tenant-login cookie. +System-administrator SSO is served on `https://auth.example.com`. Client applications and tenant password login use `https://.auth.example.com`; configure wildcard DNS and TLS for `*.auth.example.com`. Database authentication is controlled by `GO_LOOSE_LOCAL_LOGIN`. It may remain enabled for seeded or invited users without creating a bootstrap account. Set `GO_LOOSE_LOCAL_ADMIN_PASSWORD` only when compatibility bootstrap behavior is explicitly required, and provide it through a secret manager. diff --git a/docs/password-authentication.md b/docs/password-authentication.md index 0496ac4..823c3e5 100644 --- a/docs/password-authentication.md +++ b/docs/password-authentication.md @@ -7,25 +7,19 @@ Go Loose supports local email/password accounts stored in PostgreSQL. Passwords On a fresh Compose deployment, use `/install` and keep **Seed NMBS and YPTO Guess demo data** selected. The complete credential table is in the [installation guide](installation.md). The two primary interview accounts are: ```text -interview@nmbs.auth.local / admin123 -interview@ypto.auth.local / admin123 +interview@nmbs.auth.dev / admin123 +interview@ypto.auth.dev / admin123 ``` -For tenant application login, add `127.0.0.1 nmbs.auth.local ypto.auth.local` to `/etc/hosts`. +For system-administrator and tenant login, add `127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev` to `/etc/hosts`. ```text -http://nmbs.auth.local:8080 +https://nmbs.auth.dev ``` -If alternate ports are needed: - -```bash -GO_LOOSE_PORT=18080 \ -GO_LOOSE_POSTGRES_PORT=15432 \ -docker compose up --build -``` - -Then use `http://localhost:18080/login` and `http://nmbs.auth.local:18080`. +Use `https://auth.dev/login` for system-administrator SSO and +`https://nmbs.auth.dev/login` for tenant database login. Browser TLS is +terminated by the shared infrastructure Traefik using its local CA certificate. ## Configuration @@ -35,7 +29,7 @@ GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@example.com # optional bootstrap GO_LOOSE_LOCAL_ADMIN_PASSWORD= # optional bootstrap ``` -`GO_LOOSE_LOCAL_LOGIN=true` enables all database-backed users. A bootstrap account is created only when `GO_LOOSE_LOCAL_ADMIN_PASSWORD` is non-empty; its password is applied only when the account has no password hash. Changing the environment variable later does not silently overwrite an existing database password. If a bootstrap account is required, inject its password from a secret manager and never document a production credential. +`GO_LOOSE_LOCAL_LOGIN=true` enables database-backed users on tenant hostnames. A bootstrap account is created only when `GO_LOOSE_LOCAL_ADMIN_PASSWORD` is non-empty; its password is applied only when the account has no password hash. Changing the environment variable later does not silently overwrite an existing database password. If a bootstrap account is required, inject its password from a secret manager and never document a production credential. ## Creating client users @@ -45,7 +39,7 @@ GO_LOOSE_LOCAL_ADMIN_PASSWORD= # optional bootstrap 4. Choose **Manage** for that user and grant the required applications. 5. Give the initial credential to the user through a secure channel. -The user can then sign in on `.auth.local` or the production tenant auth hostname. Re-inviting an existing email explicitly replaces its password and display name. +The user can then sign in on `.auth.dev` or the production tenant auth hostname. Re-inviting an existing email explicitly replaces its password and display name. ## Brute-force and audit behavior diff --git a/internal/config/config.go b/internal/config/config.go index 5d6a729..9b570e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,10 +32,11 @@ type Config struct { } func Load() (Config, error) { + baseURL := strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/") cfg := Config{ Addr: env("GO_LOOSE_ADDR", ":8080"), - BaseURL: strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/"), - AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.local")), "."), + BaseURL: baseURL, + AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.dev")), "."), DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"), SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"), DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false), @@ -46,7 +47,7 @@ func Load() (Config, error) { OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"), OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"), - OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", "http://localhost:8080/auth/callback"), + OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", baseURL+"/auth/callback"), AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")), AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false), BootstrapTenant: env("GO_LOOSE_BOOTSTRAP_TENANT", "default"), @@ -57,7 +58,7 @@ func Load() (Config, error) { return Config{}, errors.New("GO_LOOSE_SESSION_SECRET must contain at least 32 characters") } if cfg.AuthDomain == "" || strings.ContainsAny(cfg.AuthDomain, "/:") { - return Config{}, errors.New("GO_LOOSE_AUTH_DOMAIN must be a hostname such as auth.local") + return Config{}, errors.New("GO_LOOSE_AUTH_DOMAIN must be a hostname such as auth.dev") } oidcConfigured := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" if cfg.LocalAdminPassword != "" && len(cfg.LocalAdminPassword) < 12 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bceebba..d23dd06 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -43,3 +43,17 @@ func TestLoadAllowsDatabaseLoginWithoutBootstrapAccount(t *testing.T) { t.Fatalf("Load() error = %v", err) } } + +func TestLoadDerivesOIDCRedirectURLFromBaseURL(t *testing.T) { + t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901") + t.Setenv("GO_LOOSE_BASE_URL", "http://auth.dev:18080/") + t.Setenv("GO_LOOSE_OIDC_REDIRECT_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.OIDCRedirectURL != "http://auth.dev:18080/auth/callback" { + t.Fatalf("OIDC redirect URL = %q", cfg.OIDCRedirectURL) + } +} diff --git a/internal/server/installer.go b/internal/server/installer.go index b2e44b2..7486508 100644 --- a/internal/server/installer.go +++ b/internal/server/installer.go @@ -100,10 +100,10 @@ func buildDemoUsers() ([]store.DemoUser, error) { specs := []struct { email, name, password, tenant string }{ - {"interview@nmbs.auth.local", "NMBS Interview", "admin123", "nmbs"}, - {"interview@ypto.auth.local", "YPTO Interview", "admin123", "ypto"}, - {"reviewer@nmbs.auth.local", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"}, - {"reviewer@ypto.auth.local", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"}, + {"interview@nmbs.auth.dev", "NMBS Interview", "admin123", "nmbs"}, + {"interview@ypto.auth.dev", "YPTO Interview", "admin123", "ypto"}, + {"reviewer@nmbs.auth.dev", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"}, + {"reviewer@ypto.auth.dev", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"}, } result := make([]store.DemoUser, 0, len(specs)) for _, spec := range specs { diff --git a/internal/server/oidc_test.go b/internal/server/oidc_test.go index bb19bcb..e2c19db 100644 --- a/internal/server/oidc_test.go +++ b/internal/server/oidc_test.go @@ -1,6 +1,7 @@ package server import ( + "net/http" "net/http/httptest" "testing" @@ -8,11 +9,11 @@ import ( ) func TestOAuthStateCookieDomain(t *testing.T) { - server := &Server{cfg: config.Config{AuthDomain: "auth.local"}} + server := &Server{cfg: config.Config{AuthDomain: "auth.dev"}} - tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.local/auth/start", nil) - if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.local" { - t.Fatalf("tenant cookie domain = %q, want auth.local", domain) + tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.dev/auth/start", nil) + if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.dev" { + t.Fatalf("tenant cookie domain = %q, want auth.dev", domain) } localRequest := httptest.NewRequest("GET", "http://localhost:8080/auth/start", nil) @@ -20,3 +21,27 @@ func TestOAuthStateCookieDomain(t *testing.T) { t.Fatalf("local cookie domain = %q, want host-only cookie", domain) } } + +func TestManagementSSOIsRejectedOnTenantHost(t *testing.T) { + server := &Server{cfg: config.Config{AuthDomain: "auth.dev"}} + request := httptest.NewRequest(http.MethodGet, "http://nmbs.auth.dev/auth/start", nil) + response := httptest.NewRecorder() + + server.authStart(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound) + } +} + +func TestClientSSOIsRejectedOnSystemHost(t *testing.T) { + server := &Server{cfg: config.Config{AuthDomain: "auth.dev"}} + request := httptest.NewRequest(http.MethodGet, "http://auth.dev/auth/start?return=client", nil) + response := httptest.NewRecorder() + + server.authStart(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound) + } +} diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go new file mode 100644 index 0000000..25266a5 --- /dev/null +++ b/internal/server/origin_test.go @@ -0,0 +1,41 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/danyel/go-loose/internal/config" +) + +func TestRequireSameOriginAcceptsTenantHost(t *testing.T) { + server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + request := httptest.NewRequest(http.MethodPost, "https://nmbs.auth.dev/api/v1/keys", nil) + request.Header.Set("Origin", "https://nmbs.auth.dev") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } +} + +func TestRequireSameOriginRejectsDifferentTenantHost(t *testing.T) { + server := &Server{cfg: config.Config{BaseURL: "https://auth.dev"}} + handler := server.requireSameOrigin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("handler reached for cross-origin request") + })) + request := httptest.NewRequest(http.MethodPost, "https://nmbs.auth.dev/api/v1/keys", nil) + request.Header.Set("Origin", "https://ypto.auth.dev") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } +} diff --git a/internal/server/passwordauth.go b/internal/server/passwordauth.go index 36df6d3..76844e7 100644 --- a/internal/server/passwordauth.go +++ b/internal/server/passwordauth.go @@ -70,6 +70,11 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + tenantSlug := s.requestTenantSlug(r.Host) + if tenantSlug == "" { + http.NotFound(w, r) + return + } r.Body = http.MaxBytesReader(w, r.Body, 16<<10) if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid login request") @@ -107,6 +112,14 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) { if err != nil { password.Verify(s.loginGuard.dummyHash, passwordValue) } + if valid { + tenantAllowed, accessErr := s.store.HasTenantAccess(r.Context(), user.ID, tenantSlug) + if accessErr != nil { + s.internalError(w, "check password user tenant access", accessErr) + return + } + valid = tenantAllowed + } if !valid { s.loginGuard.fail(guardKey, now) var userID *string diff --git a/internal/server/passwordauth_test.go b/internal/server/passwordauth_test.go index 2c471aa..a7775b8 100644 --- a/internal/server/passwordauth_test.go +++ b/internal/server/passwordauth_test.go @@ -1,8 +1,12 @@ package server import ( + "net/http" + "net/http/httptest" "testing" "time" + + "github.com/danyel/go-loose/internal/config" ) func TestLoginGuardLocksAfterFiveFailures(t *testing.T) { @@ -22,3 +26,19 @@ func TestLoginGuardLocksAfterFiveFailures(t *testing.T) { t.Fatal("successful login did not clear failures") } } + +func TestPasswordLoginIsRejectedOnSystemHost(t *testing.T) { + server := &Server{ + cfg: config.Config{AuthDomain: "auth.dev", LocalLogin: true}, + loginGuard: newLoginGuard(), + } + server.installed.Store(true) + request := httptest.NewRequest(http.MethodPost, "http://auth.dev/auth/password", nil) + response := httptest.NewRecorder() + + server.passwordLogin(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 95defdc..8d4c3ee 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -195,15 +195,11 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) { } func (s *Server) authStart(w http.ResponseWriter, r *http.Request) { - target := "/" - if requested := r.URL.Query().Get("return"); requested != "" { - validated, err := s.validateLoginReturn(r.Context(), requested) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid client login return URL") - return - } - target = validated + if r.URL.Query().Get("return") != "" || s.requestTenantSlug(r.Host) != "" { + http.NotFound(w, r) + return } + target := "/" state, err := s.newLoginState(target) if err != nil { writeError(w, http.StatusInternalServerError, "could not start login") @@ -610,7 +606,8 @@ func (s *Server) requireSameOrigin(next http.Handler) http.Handler { } sourceURL, sourceErr := url.Parse(source) baseURL, baseErr := url.Parse(s.cfg.BaseURL) - if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || sourceURL.Host != baseURL.Host { + if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || + !strings.EqualFold(sourceURL.Host, r.Host) { writeError(w, http.StatusForbidden, "same-origin request required") return } diff --git a/internal/server/tenantauth.go b/internal/server/tenantauth.go index 9f9414e..0161de6 100644 --- a/internal/server/tenantauth.go +++ b/internal/server/tenantauth.go @@ -26,6 +26,22 @@ func (s *Server) tenantAuthURL(tenantSlug, requestURI string) string { return base.Scheme + "://" + s.tenantAuthHost(tenantSlug) + requestURI } +func (s *Server) requestTenantSlug(host string) string { + hostname := strings.ToLower(host) + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + hostname = strings.ToLower(parsedHost) + } + suffix := "." + s.cfg.AuthDomain + if !strings.HasSuffix(hostname, suffix) { + return "" + } + slug := strings.TrimSuffix(hostname, suffix) + if slug == "" || strings.Contains(slug, ".") { + return "" + } + return slug +} + func (s *Server) validateLoginReturn(ctx context.Context, raw string) (string, error) { target, err := url.Parse(raw) if err != nil || !target.IsAbs() || target.Path != "/connect/authorize" || target.Fragment != "" || target.User != nil { @@ -103,13 +119,13 @@ func (s *Server) renderClientLogin(w http.ResponseWriter, r *http.Request) { "{{RETURN_URL}}", html.EscapeString(validatedReturn), "{{CSRF_TOKEN}}", html.EscapeString(csrfToken), "{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin), - "{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()), + "{{SSO_STYLE}}", hiddenStyle(true), ).Replace(string(content)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(page)) } -func (s *Server) renderManagementLogin(w http.ResponseWriter, _ *http.Request) { +func (s *Server) renderManagementLogin(w http.ResponseWriter, r *http.Request) { content, err := web.Files.ReadFile("login.html") if err != nil { http.Error(w, "login page unavailable", http.StatusInternalServerError) @@ -120,10 +136,23 @@ func (s *Server) renderManagementLogin(w http.ResponseWriter, _ *http.Request) { http.Error(w, "could not prepare login", http.StatusInternalServerError) return } + tenantSlug := s.requestTenantSlug(r.Host) + tenantLogin := tenantSlug != "" + loginContext := "SYSTEM ADMINISTRATION" + loginMessage := "Sign in through the configured identity provider. System administration is not tied to a tenant." + passwordButton := "LOGIN" + if tenantLogin { + loginContext = strings.ToUpper(tenantSlug) + " · TENANT ADMINISTRATION" + loginMessage = "Sign in with your tenant database account." + passwordButton = "LOGIN TO " + strings.ToUpper(tenantSlug) + } page := strings.NewReplacer( "{{CSRF_TOKEN}}", html.EscapeString(csrfToken), - "{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin), - "{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()), + "{{LOGIN_CONTEXT}}", html.EscapeString(loginContext), + "{{LOGIN_MESSAGE}}", html.EscapeString(loginMessage), + "{{PASSWORD_BUTTON}}", html.EscapeString(passwordButton), + "{{LOCAL_STYLE}}", hiddenStyle(!tenantLogin || !s.cfg.LocalLogin), + "{{SSO_STYLE}}", hiddenStyle(tenantLogin || !s.hasOIDC()), ).Replace(string(content)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(page)) diff --git a/internal/server/tenantauth_test.go b/internal/server/tenantauth_test.go new file mode 100644 index 0000000..ea85a0c --- /dev/null +++ b/internal/server/tenantauth_test.go @@ -0,0 +1,29 @@ +package server + +import ( + "testing" + + "github.com/danyel/go-loose/internal/config" +) + +func TestRequestTenantSlug(t *testing.T) { + server := &Server{cfg: config.Config{AuthDomain: "auth.dev"}} + tests := []struct { + host string + want string + }{ + {host: "auth.dev", want: ""}, + {host: "auth.dev:8080", want: ""}, + {host: "nmbs.auth.dev", want: "nmbs"}, + {host: "NMBS.auth.dev:8080", want: "nmbs"}, + {host: "nested.nmbs.auth.dev", want: ""}, + {host: "localhost:8080", want: ""}, + } + for _, test := range tests { + t.Run(test.host, func(t *testing.T) { + if got := server.requestTenantSlug(test.host); got != test.want { + t.Fatalf("requestTenantSlug(%q) = %q, want %q", test.host, got, test.want) + } + }) + } +} diff --git a/internal/store/passwordauth.go b/internal/store/passwordauth.go index f9672c4..9727ab8 100644 --- a/internal/store/passwordauth.go +++ b/internal/store/passwordauth.go @@ -34,6 +34,18 @@ func (s *Store) PasswordUser(ctx context.Context, email string) (User, string, e return user, passwordHash.String, err } +func (s *Store) HasTenantAccess(ctx context.Context, userID, tenantSlug string) (bool, error) { + var allowed bool + err := s.db.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM memberships m + JOIN tenants t ON t.id = m.tenant_id + WHERE m.user_id = $1 AND t.slug = $2 + )`, userID, tenantSlug).Scan(&allowed) + return allowed, err +} + func (s *Store) RecordLogin(ctx context.Context, userID *string, email, remoteAddress string, successful bool, reason string) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO user_login_events(user_id, email, remote_address, successful, reason) diff --git a/web/login.html b/web/login.html index dd13da0..f98f06b 100644 --- a/web/login.html +++ b/web/login.html @@ -12,15 +12,16 @@ ██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝ ╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗ ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝ +

{{LOGIN_CONTEXT}}

goloose@access-plane:~$ initialize secure session

-

Sign in with a database account or continue through your organization's identity provider.

+

{{LOGIN_MESSAGE}}

-
+

goloose@access-plane:~$ █