diff --git a/README.md b/README.md index f4b32af..d59be9e 100644 --- a/README.md +++ b/README.md @@ -76,33 +76,34 @@ openid.client-id -> WONDERWALL_OPENID_CLIENT_ID The following flags are available: ```shell ---auto-login Automatically redirect user to login if the user does not have a valid session for all proxied downstream requests. ---bind-address string Listen address for public connections. (default "127.0.0.1:3000") ---encryption-key string Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. ---error-redirect-uri string URI to redirect user to on errors for custom error handling. ---features.loginstatus.cookie-domain string The domain that the cookie should be set for. ---features.loginstatus.cookie-name string The name of the cookie. ---features.loginstatus.enabled Feature toggle for Loginstatus, a separate service that should provide an opaque token to indicate that a user has been authenticated previously, e.g. by another application in another subdomain. ---features.loginstatus.resource-indicator string The resource indicator that should be included in the authorization request to get an audience-restricted token that Loginstatus accepts. Empty means no resource indicator. ---features.loginstatus.token-url string The URL to the Loginstatus service that returns an opaque token. ---ingress string Ingress used to access the main application. ---log-format string Log format, either 'json' or 'text'. (default "json") ---log-level string Logging verbosity level. (default "debug") ---metrics-bind-address string Listen address for metrics only. (default "127.0.0.1:3001") ---openid.acr-values string Space separated string that configures the default security level (acr_values) parameter for authorization requests. ---openid.client-id string Client ID for the OpenID client. ---openid.client-jwk string JWK containing the private key for the OpenID client in string format. ---openid.post-logout-redirect-uri string URI for redirecting the user after successful logout at the Identity Provider. ---openid.provider string Provider configuration to load and use, either 'openid', 'azure', 'idporten'. (default "openid") ---openid.scopes strings List of additional scopes (other than 'openid') that should be used during the login flow. ---openid.ui-locales string Space-separated string that configures the default UI locale (ui_locales) parameter for OAuth2 consent screen. ---openid.well-known-url string URI to the well-known OpenID Configuration metadata document. ---redis.address string Address of Redis. An empty value will use in-memory session storage. ---redis.password string Password for Redis. ---redis.tls Whether or not to use TLS for connecting to Redis. (default true) ---redis.username string Username for Redis. ---session-max-lifetime duration Max lifetime for user sessions. (default 1h0m0s) ---upstream-host string Address of upstream host. (default "127.0.0.1:8080") +--auto-login Automatically redirect user to login if the user does not have a valid session for all proxied downstream requests. +--auto-login-skip-paths strings Comma separated list of paths to ignore when 'auto-login' is enabled. Paths are evaluated as regular expressions. +--bind-address string Listen address for public connections. (default "127.0.0.1:3000") +--encryption-key string Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. +--error-redirect-uri string URI to redirect user to on errors for custom error handling. +--ingress string Ingress used to access the main application. +--log-format string Log format, either 'json' or 'text'. (default "json") +--log-level string Logging verbosity level. (default "debug") +--loginstatus.cookie-domain string The domain that the cookie should be set for. +--loginstatus.cookie-name string The name of the cookie. +--loginstatus.enabled Feature toggle for Loginstatus, a separate service that should provide an opaque token to indicate that a user has been authenticated previously, e.g. by another application in another subdomain. +--loginstatus.resource-indicator string The resource indicator that should be included in the authorization request to get an audience-restricted token that Loginstatus accepts. Empty means no resource indicator. +--loginstatus.token-url string The URL to the Loginstatus service that returns an opaque token. +--metrics-bind-address string Listen address for metrics only. (default "127.0.0.1:3001") +--openid.acr-values string Space separated string that configures the default security level (acr_values) parameter for authorization requests. +--openid.client-id string Client ID for the OpenID client. +--openid.client-jwk string JWK containing the private key for the OpenID client in string format. +--openid.post-logout-redirect-uri string URI for redirecting the user after successful logout at the Identity Provider. +--openid.provider string Provider configuration to load and use, either 'openid', 'azure', 'idporten'. (default "openid") +--openid.scopes strings List of additional scopes (other than 'openid') that should be used during the login flow. +--openid.ui-locales string Space-separated string that configures the default UI locale (ui_locales) parameter for OAuth2 consent screen. +--openid.well-known-url string URI to the well-known OpenID Configuration metadata document. +--redis.address string Address of Redis. An empty value will use in-memory session storage. +--redis.password string Password for Redis. +--redis.tls Whether or not to use TLS for connecting to Redis. (default true) +--redis.username string Username for Redis. +--session-max-lifetime duration Max lifetime for user sessions. (default 1h0m0s) +--upstream-host string Address of upstream host. (default "127.0.0.1:8080") ``` At minimum, the following configuration must be provided: diff --git a/cmd/wonderwall/main.go b/cmd/wonderwall/main.go index a6e525e..8a815d3 100644 --- a/cmd/wonderwall/main.go +++ b/cmd/wonderwall/main.go @@ -32,6 +32,9 @@ func run() error { if err := conftools.Load(cfg); err != nil { return err } + if err := cfg.Validate(); err != nil { + return err + } if err := logging.Setup(cfg.LogLevel, cfg.LogFormat); err != nil { return err diff --git a/pkg/autologin/autologin.go b/pkg/autologin/autologin.go new file mode 100644 index 0000000..6d8d7e4 --- /dev/null +++ b/pkg/autologin/autologin.go @@ -0,0 +1,61 @@ +package autologin + +import ( + "net/http" + "regexp" + + "github.com/nais/wonderwall/pkg/config" +) + +type Options struct { + Enabled bool + SkipRoutes []Route +} + +func (o Options) NeedsLogin(r *http.Request, isAuthenticated bool) bool { + if isAuthenticated || !o.Enabled { + return false + } + + for _, route := range o.SkipRoutes { + if route.Regexp.MatchString(r.URL.Path) { + return false + } + } + + return true +} + +type Route struct { + Path string + Regexp *regexp.Regexp +} + +func NewOptions(cfg *config.Config) (*Options, error) { + routes, err := skippedRoutes(cfg) + if err != nil { + return nil, err + } + + return &Options{ + Enabled: cfg.AutoLogin, + SkipRoutes: routes, + }, nil +} + +func skippedRoutes(cfg *config.Config) ([]Route, error) { + routes := make([]Route, 0) + for _, path := range cfg.AutoLoginSkipPaths { + re, err := regexp.Compile(path) + if err != nil { + return nil, err + } + + routes = append(routes, Route{ + Path: path, + Regexp: re, + }) + } + + return routes, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7d029a6..d1cdb86 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,8 @@ package config import ( + "fmt" + "regexp" "time" "github.com/nais/liberator/pkg/conftools" @@ -15,6 +17,7 @@ type Config struct { MetricsBindAddress string `json:"metrics-bind-address"` AutoLogin bool `json:"auto-login"` + AutoLoginSkipPaths []string `json:"auto-login-skip-paths"` EncryptionKey string `json:"encryption-key"` ErrorRedirectURI string `json:"error-redirect-uri"` Ingress string `json:"ingress"` @@ -27,6 +30,29 @@ type Config struct { Loginstatus Loginstatus `json:"loginstatus"` } +func (in Config) Validate() error { + if err := in.validateAutoLoginSkipPaths(); err != nil { + return fmt.Errorf("validating '%s': %w", AutoLoginSkipPaths, err) + } + + return nil +} + +func (in Config) validateAutoLoginSkipPaths() error { + for _, path := range in.AutoLoginSkipPaths { + if len(path) <= 0 { + return fmt.Errorf("path cannot be empty") + } + + _, err := regexp.Compile(path) + if err != nil { + return fmt.Errorf("could not compile regex for path '%s': %w", path, err) + } + } + + return nil +} + type Loginstatus struct { Enabled bool `json:"enabled"` CookieDomain string `json:"cookie-domain"` @@ -46,6 +72,7 @@ const ( MetricsBindAddress = "metrics-bind-address" AutoLogin = "auto-login" + AutoLoginSkipPaths = "auto-login-skip-paths" EncryptionKey = "encryption-key" ErrorRedirectURI = "error-redirect-uri" Ingress = "ingress" @@ -68,6 +95,7 @@ func Initialize() (*Config, error) { flag.String(MetricsBindAddress, "127.0.0.1:3001", "Listen address for metrics only.") flag.Bool(AutoLogin, false, "Automatically redirect user to login if the user does not have a valid session for all proxied downstream requests.") + flag.StringSlice(AutoLoginSkipPaths, []string{}, "Comma separated list of paths to ignore when 'auto-login' is enabled. Paths are evaluated as regular expressions.") flag.String(EncryptionKey, "", "Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store.") flag.String(ErrorRedirectURI, "", "URI to redirect user to on errors for custom error handling.") flag.String(Ingress, "", "Ingress used to access the main application.") diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..a1014f0 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,55 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/nais/wonderwall/pkg/config" +) + +func TestConfig_Validate(t *testing.T) { + t.Run("auto-login-skip-paths", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + paths := []string{ + "/some/path", + "^/some/path$", + "/some/.+/static/.+$", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + cfg := config.Config{ + AutoLoginSkipPaths: []string{path}, + } + + err := cfg.Validate() + assert.NoError(t, err) + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + paths := []string{ + "[/some/path", + "^)/some/path$", + "[/some/.*$", + "", + "\\", + "/some/path\\", + "*", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + cfg := config.Config{ + AutoLoginSkipPaths: []string{path}, + } + + err := cfg.Validate() + assert.Error(t, err) + }) + } + }) + }) +} diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index eb90ef0..7ba07b0 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -6,6 +6,7 @@ import ( "github.com/rs/zerolog" + "github.com/nais/wonderwall/pkg/autologin" "github.com/nais/wonderwall/pkg/config" "github.com/nais/wonderwall/pkg/cookie" "github.com/nais/wonderwall/pkg/crypto" @@ -17,6 +18,7 @@ import ( ) type Handler struct { + AutoLogin autologin.Options Cfg openidconfig.Config Client client.Client CookieOptions cookie.Options @@ -45,11 +47,14 @@ func NewHandler( } openidClient := client.NewClient(cfg) + + autoLogin, err := autologin.NewOptions(cfg.Wonderwall()) if err != nil { return nil, err } return &Handler{ + AutoLogin: *autoLogin, Client: openidClient, CookieOptions: cookieOpts, Crypter: crypter, diff --git a/pkg/handler/handler_default.go b/pkg/handler/handler_default.go index 6111780..17e20fa 100644 --- a/pkg/handler/handler_default.go +++ b/pkg/handler/handler_default.go @@ -28,7 +28,7 @@ func (h *Handler) Default(w http.ResponseWriter, r *http.Request) { } } - if !isAuthenticated && h.Cfg.Wonderwall().AutoLogin { + if h.AutoLogin.NeedsLogin(r, isAuthenticated) { r.Header.Add("Referer", r.URL.String()) h.Login(w, r) return diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go index e398816..9e708c0 100644 --- a/pkg/handler/handler_test.go +++ b/pkg/handler/handler_test.go @@ -189,6 +189,7 @@ func TestHandler_Default(t *testing.T) { // initial request without session resp, err := rpClient.Get(idp.RelyingPartyServer.URL) assert.NoError(t, err) + defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) body, err := ioutil.ReadAll(resp.Body) @@ -266,6 +267,75 @@ func TestHandler_Default(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "ok", string(body)) }) + + t.Run("with auto-login and skipped paths", func(t *testing.T) { + cfg := mock.Config() + cfg.UpstreamHost = upstreamURL.Host + cfg.AutoLogin = true + cfg.AutoLoginSkipPaths = []string{ + "^/exact/match$", + "^/allowed(/?|/.*)$", + "/partial/(yup|yes)", + } + err := cfg.Validate() + assert.NoError(t, err) + + idp := mock.NewIdentityProvider(cfg) + defer idp.Close() + + rpClient := idp.RelyingPartyClient() + + t.Run("matched paths", func(t *testing.T) { + matched := []string{ + "/exact/match", + "/allowed", + "/allowed/", + "/allowed/very", + "/allowed/very/cool", + "/partial/yes", + "/partial/yup", + "/partial/yes/no", + "/partial/yup/no", + "/parent/partial/yup/no", + } + for _, path := range matched { + t.Run(path, func(t *testing.T) { + resp, err := rpClient.Get(idp.RelyingPartyServer.URL + path) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, "not ok", string(body)) + }) + } + }) + + t.Run("non-matched paths", func(t *testing.T) { + nonMatched := []string{ + "", + "/", + "/exact/match/", + "/exact/match/huh", + "/not-allowed", + "/not-allowed/allowed", + "/alloweded", + "/nope/partial/", + "/nope/partial/child", + } + for _, path := range nonMatched { + t.Run(path, func(t *testing.T) { + resp, err := rpClient.Get(idp.RelyingPartyServer.URL + path) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + }) + } + }) + }) } func localLogin(t *testing.T, rpClient *http.Client, idp mock.IdentityProvider) *http.Response { diff --git a/pkg/mock/config.go b/pkg/mock/config.go index 0ce1f0f..951d57e 100644 --- a/pkg/mock/config.go +++ b/pkg/mock/config.go @@ -8,7 +8,7 @@ import ( ) func Config() *config.Config { - return &config.Config{ + cfg := &config.Config{ EncryptionKey: `G8Roe6AcoBpdr5GhO3cs9iORl4XIC8eq`, // 256 bits AES Ingress: "/", OpenID: config.OpenID{ @@ -18,6 +18,13 @@ func Config() *config.Config { }, SessionMaxLifetime: time.Hour, } + + err := cfg.Validate() + if err != nil { + panic(err) + } + + return cfg } type Configuration struct {