From 4237e84de34f441ed158b7fe14345af2557230c5 Mon Sep 17 00:00:00 2001 From: Trong Huu Nguyen Date: Mon, 6 Sep 2021 11:05:19 +0200 Subject: [PATCH] feat: add feature toggle for security level; allow user-defined levels --- pkg/config/config.go | 31 +++++++++++------- pkg/config/wellknown.go | 57 +++++++++++++++++++-------------- pkg/router/router.go | 66 ++++++++++++++++++++++++++++++--------- pkg/router/router_test.go | 50 +++++++++++++++++++++++++---- 4 files changed, 149 insertions(+), 55 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index ccf4060..58e66a2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,16 +24,21 @@ type Config struct { } type IDPorten struct { - ClientID string `json:"client-id"` - ClientJWK string `json:"client-jwk"` - RedirectURI string `json:"redirect-uri"` - WellKnownURL string `json:"well-known-url"` - WellKnown IDPortenWellKnown `json:"well-known"` - Locale string `json:"locale"` - SecurityLevel string `json:"security-level"` - PostLogoutRedirectURI string `json:"post-logout-redirect-uri"` - Scopes []string `json:"scopes"` - SessionMaxLifetime time.Duration `json:"session-max-lifetime"` + ClientID string `json:"client-id"` + ClientJWK string `json:"client-jwk"` + RedirectURI string `json:"redirect-uri"` + WellKnownURL string `json:"well-known-url"` + WellKnown IDPortenWellKnown `json:"well-known"` + Locale string `json:"locale"` + SecurityLevel IDPortenSecurityLevel `json:"security-level"` + PostLogoutRedirectURI string `json:"post-logout-redirect-uri"` + Scopes []string `json:"scopes"` + SessionMaxLifetime time.Duration `json:"session-max-lifetime"` +} + +type IDPortenSecurityLevel struct { + Enabled bool `json:"enabled"` + Value string `json:"value"` } const ( @@ -50,7 +55,8 @@ const ( IDPortenRedirectURI = "idporten.redirect-uri" IDPortenWellKnownURL = "idporten.well-known-url" IDPortenLocale = "idporten.locale" - IDPortenSecurityLevel = "idporten.security-level" + IDPortenSecurityLevelEnabled = "idporten.security-level.enabled" + IDPortenSecurityLevelValue = "idporten.security-level.value" IDPortenPostLogoutRedirectURI = "idporten.post-logout-redirect-uri" IDPortenScopes = "idporten.scopes" IDPortenSessionMaxLifetime = "idporten.session-max-lifetime" @@ -74,7 +80,8 @@ func Initialize() *Config { flag.String(UpstreamHost, "127.0.0.1:8080", "Address of upstream host.") flag.String(EncryptionKey, "", "Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store.") flag.String(Redis, "", "Address of Redis. An empty value will use in-memory session storage.") - flag.String(IDPortenSecurityLevel, "Level4", "Requested security level, either Level3 or Level4.") + flag.Bool(IDPortenSecurityLevelEnabled, true, "Set ID-Porten security level for authorization requests.") + flag.String(IDPortenSecurityLevelValue, "Level4", "Requested security level, either Level3 or Level4.") flag.String(IDPortenLocale, "nb", "Locale for OAuth2 consent screen.") flag.String(IDPortenPostLogoutRedirectURI, "https://nav.no", "URI for redirecting the user after successful logout at IDPorten.") flag.StringSlice(IDPortenScopes, []string{token.ScopeOpenID}, "List of scopes that should be used during the Auth Code flow.") diff --git a/pkg/config/wellknown.go b/pkg/config/wellknown.go index e33cd54..ba11a94 100644 --- a/pkg/config/wellknown.go +++ b/pkg/config/wellknown.go @@ -6,29 +6,40 @@ import ( ) type IDPortenWellKnown struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - EndSessionEndpoint string `json:"end_session_endpoint"` - RevocationEndpoint string `json:"revocation_endpoint"` - JwksURI string `json:"jwks_uri"` - ResponseTypesSupported []string `json:"response_types_supported"` - ResponseModesSupported []string `json:"response_modes_supported"` - SubjectTypesSupported []string `json:"subject_types_supported"` - IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - ScopesSupported []string `json:"scopes_supported"` - UILocalesSupported []string `json:"ui_locales_supported"` - ACRValuesSupported []string `json:"acr_values_supported"` - FrontchannelLogoutSupported bool `json:"frontchannel_logout_supported"` - FrontchannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"` - IntrospectionEndpoint string `json:"introspection_endpoint"` - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` - RequestParameterSupported bool `json:"request_parameter_supported"` - RequestURIParameterSupported bool `json:"request_uri_parameter_supported"` - RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + EndSessionEndpoint string `json:"end_session_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + JwksURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + ResponseModesSupported []string `json:"response_modes_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + UILocalesSupported []string `json:"ui_locales_supported"` + ACRValuesSupported ACRValuesSupported `json:"acr_values_supported"` + FrontchannelLogoutSupported bool `json:"frontchannel_logout_supported"` + FrontchannelLogoutSessionSupported bool `json:"frontchannel_logout_session_supported"` + IntrospectionEndpoint string `json:"introspection_endpoint"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + RequestParameterSupported bool `json:"request_parameter_supported"` + RequestURIParameterSupported bool `json:"request_uri_parameter_supported"` + RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` +} + +type ACRValuesSupported []string + +func (in ACRValuesSupported) Contains(value string) bool { + for _, allowed := range in { + if allowed == value { + return true + } + } + return false } func (c *Config) FetchWellKnownConfig() error { diff --git a/pkg/router/router.go b/pkg/router/router.go index 40fcc7b..13d7947 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -5,14 +5,16 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "errors" "fmt" - "github.com/nais/wonderwall/pkg/middleware" "io" "net/http" "net/url" "sync" "time" + "github.com/nais/wonderwall/pkg/middleware" + "github.com/lestrrat-go/jwx/jwt" "github.com/nais/wonderwall/pkg/config" @@ -28,15 +30,18 @@ import ( ) const ( - LoginCookieLifetime = 10 * time.Minute - SessionCookieName = "io.nais.wonderwall.session" - StateCookieName = "io.nais.wonderwall.state" - NonceCookieName = "io.nais.wonderwall.nonce" - CodeVerifierCookieName = "io.nais.wonderwall.code_verifier" - RedirectURLCookieName = "io.nais.wonderwall.redirect_url" - RedirectURLParameter = "redirect" + LoginCookieLifetime = 10 * time.Minute + SessionCookieName = "io.nais.wonderwall.session" + StateCookieName = "io.nais.wonderwall.state" + NonceCookieName = "io.nais.wonderwall.nonce" + CodeVerifierCookieName = "io.nais.wonderwall.code_verifier" + RedirectURLCookieName = "io.nais.wonderwall.redirect_url" + RedirectURLParameter = "redirect" + SecurityLevelURLParameter = "level" ) +var InvalidSecurityLevelError = errors.New("InvalidSecurityLevel") + type Handler struct { Config config.IDPorten Crypter cryptutil.Crypter @@ -92,7 +97,7 @@ type loginParams struct { nonce string } -func (h *Handler) LoginURL() (*loginParams, error) { +func (h *Handler) LoginURL(r *http.Request) (*loginParams, error) { codeVerifier := make([]byte, 64) nonce := make([]byte, 32) state := make([]byte, 32) @@ -130,11 +135,20 @@ func (h *Handler) LoginURL() (*loginParams, error) { v.Add("scope", token.ScopeOpenID) v.Add("state", base64.RawURLEncoding.EncodeToString(state)) v.Add("nonce", base64.RawURLEncoding.EncodeToString(nonce)) - v.Add("acr_values", h.Config.SecurityLevel) v.Add("response_mode", "query") v.Add("ui_locales", h.Config.Locale) v.Add("code_challenge", base64.RawURLEncoding.EncodeToString(codeVerifierHash)) v.Add("code_challenge_method", "S256") + + if h.Config.SecurityLevel.Enabled { + securityLevel, ok := h.securityLevel(r) + if ok { + v.Add("acr_values", securityLevel) + } else { + return nil, fmt.Errorf("%w: invalid value for %s=%s", InvalidSecurityLevelError, SecurityLevelURLParameter, securityLevel) + } + } + u.RawQuery = v.Encode() return &loginParams{ @@ -145,6 +159,21 @@ func (h *Handler) LoginURL() (*loginParams, error) { }, nil } +func (h *Handler) securityLevel(r *http.Request) (string, bool) { + level := h.Config.SecurityLevel.Value + + urlParam := r.URL.Query().Get(SecurityLevelURLParameter) + if len(urlParam) > 0 { + level = urlParam + } + + if !h.Config.WellKnown.ACRValuesSupported.Contains(level) { + return level, false + } + + return level, true +} + // redirect url back to application func CanonicalRedirectURL(r *http.Request) string { redirectURL := "/" @@ -166,10 +195,20 @@ func CanonicalRedirectURL(r *http.Request) string { } func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { - params, err := h.LoginURL() + params, err := h.LoginURL(r) if err != nil { - log.Error(err) - w.WriteHeader(http.StatusInternalServerError) + log.Errorf("login URL: %+v", err) + + status := func(err error) int { + switch { + case errors.Is(err, InvalidSecurityLevelError): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } + }(err) + + w.WriteHeader(status) return } @@ -270,7 +309,6 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { return } - // TODO: should probably redirect to desired path after login http.Redirect(w, r, cookies.Referer, http.StatusTemporaryRedirect) } diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 88b0c62..2d1ce90 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "net/http/cookiejar" @@ -55,9 +56,13 @@ func defaultConfig() config.IDPorten { WellKnown: config.IDPortenWellKnown{ Issuer: "issuer", AuthorizationEndpoint: "http://localhost:1234/authorize", + ACRValuesSupported: config.ACRValuesSupported{"Level3", "Level4"}, + }, + Locale: "nb", + SecurityLevel: config.IDPortenSecurityLevel{ + Enabled: true, + Value: "Level4", }, - Locale: "nb", - SecurityLevel: "Level4", PostLogoutRedirectURI: "", SessionMaxLifetime: time.Hour, } @@ -87,9 +92,42 @@ func handler(cfg config.IDPorten) *router.Handler { } func TestLoginURL(t *testing.T) { - handler := handler(defaultConfig()) - _, err := handler.LoginURL() - assert.NoError(t, err) + type loginURLTest struct { + url string + error error + } + + tests := []loginURLTest{ + { + url: "http://localhost:1234/oauth2/login?level=Level4", + error: nil, + }, + { + url: "http://localhost:1234/oauth2/login", + error: nil, + }, + { + url: "http://localhost:1234/oauth2/login?level=NoLevel", + error: router.InvalidSecurityLevelError, + }, + } + + for _, test := range tests { + t.Run(test.url, func(t *testing.T) { + cfg := defaultConfig() + req, err := http.NewRequest("GET", test.url, nil) + assert.NoError(t, err) + + handler := handler(cfg) + _, err = handler.LoginURL(req) + + if test.error != nil { + assert.True(t, errors.Is(err, test.error)) + } else { + assert.NoError(t, err) + } + }) + } } func TestHandler_Login(t *testing.T) { @@ -120,7 +158,7 @@ func TestHandler_Login(t *testing.T) { assert.Equal(t, idpserver.URL, fmt.Sprintf("%s://%s", u.Scheme, u.Host)) assert.Equal(t, "/authorize", u.Path) - assert.Equal(t, cfg.SecurityLevel, u.Query().Get("acr_values")) + assert.Equal(t, cfg.SecurityLevel.Value, u.Query().Get("acr_values")) assert.Equal(t, cfg.Locale, u.Query().Get("ui_locales")) assert.Equal(t, cfg.ClientID, u.Query().Get("client_id")) assert.Equal(t, cfg.RedirectURI, u.Query().Get("redirect_uri"))