feat: add feature toggle for security level; allow user-defined levels

This commit is contained in:
Trong Huu Nguyen
2021-09-06 11:05:19 +02:00
parent e819cc0de1
commit 4237e84de3
4 changed files with 149 additions and 55 deletions

View File

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

View File

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

View File

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

View File

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