feat: add allowlisting of paths for autologin

This commit is contained in:
Trong Huu Nguyen
2022-07-17 20:11:55 +02:00
parent 9d32d100f0
commit ef649e7aaa
9 changed files with 259 additions and 29 deletions
+28 -27
View File
@@ -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:
+3
View File
@@ -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
+61
View File
@@ -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
}
+28
View File
@@ -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.")
+55
View File
@@ -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)
})
}
})
})
}
+5
View File
@@ -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,
+1 -1
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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 {