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_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

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
```
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 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.

View file

@ -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:
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.
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://<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
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:

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
```
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.

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:
```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=<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_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.

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:
```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=<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
@ -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.
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

View file

@ -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 {

View file

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

View file

@ -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 {

View file

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

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

View file

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

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

View file

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

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

View file

@ -12,15 +12,16 @@
██║ ██║██║ ██║ ██║ ██║ ██║██║ ██║╚════██║██╔══╝
╚██████╔╝╚██████╔╝ ███████╗╚██████╔╝╚██████╔╝███████║███████╗
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝</pre>
<p class="eyebrow">{{LOGIN_CONTEXT}}</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">
<input type="hidden" name="csrf_token" value="{{CSRF_TOKEN}}">
<label>Email<input type="email" name="email" autocomplete="username" 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>
<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>
<p class="cursor-line"><span class="prompt">goloose@access-plane:~$</span> <span class="cursor">█</span></p>
</div>