fix installer

This commit is contained in:
Daniel Noulet 2026-10-02 21:45:42 +02:00
parent 46030a5b14
commit c2091e746a
19 changed files with 264 additions and 73 deletions

View file

@ -1,6 +1,6 @@
GO_LOOSE_ADDR=:8080 GO_LOOSE_ADDR=:8080
GO_LOOSE_BASE_URL=http://localhost:8080 GO_LOOSE_BASE_URL=https://auth.dev
GO_LOOSE_AUTH_DOMAIN=auth.local GO_LOOSE_AUTH_DOMAIN=auth.dev
GO_LOOSE_DATABASE_URL=postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable 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_SESSION_SECRET=replace-with-at-least-32-random-characters
GO_LOOSE_DEV_LOGIN=false GO_LOOSE_DEV_LOGIN=false
@ -11,7 +11,7 @@ GO_LOOSE_LOCAL_ADMIN_PASSWORD=
GO_LOOSE_OIDC_ISSUER= GO_LOOSE_OIDC_ISSUER=
GO_LOOSE_OIDC_CLIENT_ID= GO_LOOSE_OIDC_CLIENT_ID=
GO_LOOSE_OIDC_CLIENT_SECRET= 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_ALLOWED_EMAIL_DOMAINS=
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=false GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=false
GO_LOOSE_BOOTSTRAP_TENANT=nmbs GO_LOOSE_BOOTSTRAP_TENANT=nmbs

View file

@ -8,14 +8,17 @@ Go Loose is a multi-tenant API-key control plane for Go HTTP services. It gives
docker compose up --build docker compose up --build
``` ```
Open <http://localhost:8080>. 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
<https://auth.dev>. 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 <http://localhost:8080/install>, 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 <https://auth.dev/install>, 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 <http://localhost:18080>. If PostgreSQL port 5433 is occupied, set `GO_LOOSE_POSTGRES_PORT=15432`.
If PostgreSQL port 5432 is occupied, also 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. 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.

View file

@ -23,15 +23,16 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
GO_LOOSE_ADDR: ":8080" GO_LOOSE_ADDR: ":8080"
GO_LOOSE_BASE_URL: "http://localhost:${GO_LOOSE_PORT:-8080}" GO_LOOSE_BASE_URL: "https://auth.dev"
GO_LOOSE_AUTH_DOMAIN: "auth.local" 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_DATABASE_URL: "postgres://goloose:goloose@postgres:5432/goloose?sslmode=disable"
GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now" GO_LOOSE_SESSION_SECRET: "local-development-secret-change-me-now"
GO_LOOSE_DEV_LOGIN: "false" GO_LOOSE_DEV_LOGIN: "false"
GO_LOOSE_LOCAL_LOGIN: "true" GO_LOOSE_LOCAL_LOGIN: "true"
GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true" GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS: "true"
ports: expose:
- "${GO_LOOSE_PORT:-8081}:8080" - "8080"
healthcheck: healthcheck:
test: ["CMD", "/go-loose", "-healthcheck"] test: ["CMD", "/go-loose", "-healthcheck"]
interval: 10s interval: 10s
@ -39,9 +40,15 @@ services:
retries: 5 retries: 5
start_period: 10s start_period: 10s
networks: networks:
- go_loose_network go_loose_network: {}
local_dev_edge:
aliases:
- go-loose
volumes: volumes:
postgres-data2: postgres-data2:
networks: networks:
go_loose_network: go_loose_network:
local_dev_edge:
external: true
name: local-dev-edge

View file

@ -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. 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. 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. 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://<tenant-slug>.<GO_LOOSE_AUTH_DOMAIN> https://<tenant-slug>.<GO_LOOSE_AUTH_DOMAIN>
``` ```
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 ```go
BaseURL: "http://nmbs.auth.local:18080" BaseURL: "http://nmbs.auth.dev:18080"
``` ```
For local development, add the tenant hostname to `/etc/hosts`: For local development, add the tenant hostname to `/etc/hosts`:
```text ```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: Production should configure wildcard DNS and TLS for `*.auth.example.com`, route those hosts to Go Loose, and use:

View file

@ -8,10 +8,15 @@ Start Go Loose with PostgreSQL and a stable `GO_LOOSE_SESSION_SECRET` of at leas
docker compose up --build 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 ```text
http://localhost:8080/auth/callback https://auth.dev/auth/callback
``` ```
The supplied **CMS Demo** client ID is prefilled: 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 | | Tenant | Email | Password | Role |
|---|---|---|---| |---|---|---|---|
| NMBS | `interview@nmbs.auth.local` | `admin123` | Tenant administrator | | NMBS | `interview@nmbs.auth.dev` | `admin123` | Tenant administrator |
| YPTO | `interview@ypto.auth.local` | `admin123` | Tenant administrator | | YPTO | `interview@ypto.auth.dev` | `admin123` | Tenant administrator |
| NMBS | `reviewer@nmbs.auth.local` | `Demo-Nmbs-2026!` | Tenant administrator | | NMBS | `reviewer@nmbs.auth.dev` | `Demo-Nmbs-2026!` | Tenant administrator |
| YPTO | `reviewer@ypto.auth.local` | `Demo-Ypto-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. 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: For local hosted-login testing, add:
```text ```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: The tenant login origins are then:
```text ```text
http://nmbs.auth.local:8080 https://nmbs.auth.dev
http://ypto.auth.local:8080 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. 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.

View file

@ -17,17 +17,17 @@ https://<go-loose-host>/auth/callback
The web installer is preferred. Environment configuration remains available for existing or fully automated deployments: The web installer is preferred. Environment configuration remains available for existing or fully automated deployments:
```dotenv ```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_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform
GO_LOOSE_OIDC_CLIENT_ID=go-loose GO_LOOSE_OIDC_CLIENT_ID=go-loose
GO_LOOSE_OIDC_CLIENT_SECRET=<from-secret-manager> GO_LOOSE_OIDC_CLIENT_SECRET=<from-secret-manager>
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_ALLOWED_EMAIL_DOMAINS=example.com
GO_LOOSE_DEV_LOGIN=false GO_LOOSE_DEV_LOGIN=false
``` ```
Client applications are served on `https://<tenant>.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://<tenant>.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. 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.

View file

@ -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: 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 ```text
interview@nmbs.auth.local / admin123 interview@nmbs.auth.dev / admin123
interview@ypto.auth.local / 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 ```text
http://nmbs.auth.local:8080 https://nmbs.auth.dev
``` ```
If alternate ports are needed: Use `https://auth.dev/login` for system-administrator SSO and
`https://nmbs.auth.dev/login` for tenant database login. Browser TLS is
```bash terminated by the shared infrastructure Traefik using its local CA certificate.
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`.
## Configuration ## Configuration
@ -35,7 +29,7 @@ GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@example.com # optional bootstrap
GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # optional bootstrap GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # 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 ## Creating client users
@ -45,7 +39,7 @@ GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # optional bootstrap
4. Choose **Manage** for that user and grant the required applications. 4. Choose **Manage** for that user and grant the required applications.
5. Give the initial credential to the user through a secure channel. 5. Give the initial credential to the user through a secure channel.
The user can then sign in on `<tenant>.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 `<tenant>.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 ## Brute-force and audit behavior

View file

@ -32,10 +32,11 @@ type Config struct {
} }
func Load() (Config, error) { func Load() (Config, error) {
baseURL := strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/")
cfg := Config{ cfg := Config{
Addr: env("GO_LOOSE_ADDR", ":8080"), Addr: env("GO_LOOSE_ADDR", ":8080"),
BaseURL: strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/"), BaseURL: baseURL,
AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.local")), "."), 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"), DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"), SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false), DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
@ -46,7 +47,7 @@ func Load() (Config, error) {
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"), OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"), 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")), AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")),
AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false), AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false),
BootstrapTenant: env("GO_LOOSE_BOOTSTRAP_TENANT", "default"), 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") return Config{}, errors.New("GO_LOOSE_SESSION_SECRET must contain at least 32 characters")
} }
if cfg.AuthDomain == "" || strings.ContainsAny(cfg.AuthDomain, "/:") { 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 != "" oidcConfigured := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != ""
if cfg.LocalAdminPassword != "" && len(cfg.LocalAdminPassword) < 12 { if cfg.LocalAdminPassword != "" && len(cfg.LocalAdminPassword) < 12 {

View file

@ -43,3 +43,17 @@ func TestLoadAllowsDatabaseLoginWithoutBootstrapAccount(t *testing.T) {
t.Fatalf("Load() error = %v", err) 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)
}
}

View file

@ -100,10 +100,10 @@ func buildDemoUsers() ([]store.DemoUser, error) {
specs := []struct { specs := []struct {
email, name, password, tenant string email, name, password, tenant string
}{ }{
{"interview@nmbs.auth.local", "NMBS Interview", "admin123", "nmbs"}, {"interview@nmbs.auth.dev", "NMBS Interview", "admin123", "nmbs"},
{"interview@ypto.auth.local", "YPTO Interview", "admin123", "ypto"}, {"interview@ypto.auth.dev", "YPTO Interview", "admin123", "ypto"},
{"reviewer@nmbs.auth.local", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"}, {"reviewer@nmbs.auth.dev", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"},
{"reviewer@ypto.auth.local", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"}, {"reviewer@ypto.auth.dev", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"},
} }
result := make([]store.DemoUser, 0, len(specs)) result := make([]store.DemoUser, 0, len(specs))
for _, spec := range specs { for _, spec := range specs {

View file

@ -1,6 +1,7 @@
package server package server
import ( import (
"net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -8,11 +9,11 @@ import (
) )
func TestOAuthStateCookieDomain(t *testing.T) { 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) tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.dev/auth/start", nil)
if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.local" { if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.dev" {
t.Fatalf("tenant cookie domain = %q, want auth.local", domain) t.Fatalf("tenant cookie domain = %q, want auth.dev", domain)
} }
localRequest := httptest.NewRequest("GET", "http://localhost:8080/auth/start", nil) 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) 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)
}
}

View file

@ -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)
}
}

View file

@ -70,6 +70,11 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
tenantSlug := s.requestTenantSlug(r.Host)
if tenantSlug == "" {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10) r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid login request") writeError(w, http.StatusBadRequest, "invalid login request")
@ -107,6 +112,14 @@ func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
password.Verify(s.loginGuard.dummyHash, passwordValue) 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 { if !valid {
s.loginGuard.fail(guardKey, now) s.loginGuard.fail(guardKey, now)
var userID *string var userID *string

View file

@ -1,8 +1,12 @@
package server package server
import ( import (
"net/http"
"net/http/httptest"
"testing" "testing"
"time" "time"
"github.com/danyel/go-loose/internal/config"
) )
func TestLoginGuardLocksAfterFiveFailures(t *testing.T) { func TestLoginGuardLocksAfterFiveFailures(t *testing.T) {
@ -22,3 +26,19 @@ func TestLoginGuardLocksAfterFiveFailures(t *testing.T) {
t.Fatal("successful login did not clear failures") 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)
}
}

View file

@ -195,15 +195,11 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
} }
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) { func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
target := "/" if r.URL.Query().Get("return") != "" || s.requestTenantSlug(r.Host) != "" {
if requested := r.URL.Query().Get("return"); requested != "" { http.NotFound(w, r)
validated, err := s.validateLoginReturn(r.Context(), requested) return
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login return URL")
return
}
target = validated
} }
target := "/"
state, err := s.newLoginState(target) state, err := s.newLoginState(target)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not start login") 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) sourceURL, sourceErr := url.Parse(source)
baseURL, baseErr := url.Parse(s.cfg.BaseURL) 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") writeError(w, http.StatusForbidden, "same-origin request required")
return return
} }

View file

@ -26,6 +26,22 @@ func (s *Server) tenantAuthURL(tenantSlug, requestURI string) string {
return base.Scheme + "://" + s.tenantAuthHost(tenantSlug) + requestURI 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) { func (s *Server) validateLoginReturn(ctx context.Context, raw string) (string, error) {
target, err := url.Parse(raw) target, err := url.Parse(raw)
if err != nil || !target.IsAbs() || target.Path != "/connect/authorize" || target.Fragment != "" || target.User != nil { 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), "{{RETURN_URL}}", html.EscapeString(validatedReturn),
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken), "{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin), "{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()), "{{SSO_STYLE}}", hiddenStyle(true),
).Replace(string(content)) ).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page)) 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") content, err := web.Files.ReadFile("login.html")
if err != nil { if err != nil {
http.Error(w, "login page unavailable", http.StatusInternalServerError) 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) http.Error(w, "could not prepare login", http.StatusInternalServerError)
return 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( page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken), "{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin), "{{LOGIN_CONTEXT}}", html.EscapeString(loginContext),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()), "{{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)) ).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page)) w.Write([]byte(page))

View file

@ -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)
}
})
}
}

View file

@ -34,6 +34,18 @@ func (s *Store) PasswordUser(ctx context.Context, email string) (User, string, e
return user, passwordHash.String, err 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 { func (s *Store) RecordLogin(ctx context.Context, userID *string, email, remoteAddress string, successful bool, reason string) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO user_login_events(user_id, email, remote_address, successful, reason) INSERT INTO user_login_events(user_id, email, remote_address, successful, reason)

View file

@ -12,15 +12,16 @@
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝ ██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗ ╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre> ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p class="eyebrow">{{LOGIN_CONTEXT}}</p>
<p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p> <p><span class="prompt">goloose@access-plane:~$</span> initialize secure session</p>
<p class="muted">Sign in with a database account or continue through your organization's identity provider.</p> <p class="muted">{{LOGIN_MESSAGE}}</p>
<form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post"> <form class="password-login" style="{{LOCAL_STYLE}}" action="/auth/password" method="post">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}"> <input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Email<input type="email" name="email" autocomplete="username" required></label> <label>Email<input type="email" name="email" autocomplete="username" required></label>
<label>Password<input type="password" name="password" autocomplete="current-password" required></label> <label>Password<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">LOGIN</button> <button type="submit">{{PASSWORD_BUTTON}}</button>
</form> </form>
<div style="{{SSO_STYLE}}"><p class="login-divider">or use your organization</p> <div style="{{SSO_STYLE}}">
<a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div> <a class="terminal-login" href="/auth/start"><span>›</span> LOGIN WITH SSO</a></div>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p> <p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
</div> </div>