mirror of
https://github.com/nais/wonderwall.git
synced 2026-08-23 21:16:14 +00:00
feat: add allowlisting of paths for autologin
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+8
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user