diff --git a/.defaults.yml b/.defaults.yml index 468c1c07..11d09996 100644 --- a/.defaults.yml +++ b/.defaults.yml @@ -10,6 +10,8 @@ vouch: listen: 0.0.0.0 port: 9090 # domains: + case_insensitive_emails: false + # case_insensitive_email_domains: allowAllUsers: false publicAccess: false # whiteList: diff --git a/config/config.yml_example b/config/config.yml_example index 25b6b430..258937aa 100644 --- a/config/config.yml_example +++ b/config/config.yml_example @@ -32,6 +32,19 @@ vouch: - yourdomain.com - yourotherdomain.com + # case_insensitive_emails - VOUCH_CASE_INSENSITIVE_EMAILS + # Setting this to true will treat all email-based usernames returned by the + # service provider case insensitively when validating against the whitelist + case_insensitive_emails: false + + # case_insensitive_email_domains - VOUCH_CASE_INSENSITIVE_EMAIL_DOMAINS + # Any email-based username returned by the service provider that belongs to + # any of the following domains will be treated case insensitively when + # validating against the whitelist. Subdomains of a case insensitive domain + # will not be assumed case insensitive. + # Comment `case_insensitive_email_domains:` out if you set case_insensitive_emails:true + # case_insensitive_email_domains: + # Set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider - VOUCH_ALLOWALLUSERS # allowAllUsers: false # vouch.cookie.domain must be set below when enabling allowAllUsers diff --git a/config/testing/handler_case_insensitive_email_domains.yml b/config/testing/handler_case_insensitive_email_domains.yml new file mode 100644 index 00000000..9cee56a7 --- /dev/null +++ b/config/testing/handler_case_insensitive_email_domains.yml @@ -0,0 +1,23 @@ +vouch: + logLevel: debug + + case_insensitive_emails: false + + case_insensitive_email_domains: + - example1.com + + whiteList: + # these should work if cases do not match with incoming email + - test@example1.com + - test@sub.example1.com + # these should not work if cases do not match with incoming email + - test@example2.com + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/config/testing/handler_case_insensitive_emails.yml b/config/testing/handler_case_insensitive_emails.yml new file mode 100644 index 00000000..b800d5db --- /dev/null +++ b/config/testing/handler_case_insensitive_emails.yml @@ -0,0 +1,19 @@ +vouch: + logLevel: debug + + case_insensitive_emails: true + + whiteList: + # these should work if cases do not match with incoming email + - test@example.com + - test@sub.example.com + - test_username + + jwt: + secret: testingsecret + +oauth: + provider: indieauth + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/handlers/auth.go b/handlers/auth.go index b277bdcb..4c6437c3 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -13,6 +13,8 @@ package handlers import ( "fmt" "net/http" + "regexp" + "strings" "github.com/vouch/vouch-proxy/pkg/cfg" "github.com/vouch/vouch-proxy/pkg/cookie" @@ -24,6 +26,17 @@ import ( "golang.org/x/oauth2" ) +// From https://golangcode.com/validate-an-email-address/ +var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + +// isEmailValid checks if the email provided passes the required structure and length. +func isEmailValid(e string) bool { + if len(e) < 3 || len(e) > 254 { + return false + } + return emailRegex.MatchString(e) +} + // CallbackHandler /auth // - redirects to /auth/{state}/ with the state coming from the query parameter func CallbackHandler(w http.ResponseWriter, r *http.Request) { @@ -125,6 +138,24 @@ func AuthStateHandler(w http.ResponseWriter, r *http.Request) { responses.RenderIndex(w, "/auth "+tokenstring) } +func isUsernameCaseInsensitive(user *structs.User) bool { + if cfg.Cfg.CaseInsensitiveEmails { + return true + } + + lowerUsername := strings.ToLower(user.Username) + for _, caseInsensitiveDomain := range cfg.Cfg.CaseInsensitiveEmailDomains { + // Guarantees that + // 1) the username is an email + // 2) the username should be treated case-insensitively + if strings.HasSuffix(lowerUsername, "@"+strings.ToLower(caseInsensitiveDomain)) { + return true + } + } + + return false +} + // verifyUser validates that the domains match for the user func verifyUser(u interface{}) (bool, error) { @@ -139,8 +170,12 @@ func verifyUser(u interface{}) (bool, error) { // WhiteList case len(cfg.Cfg.WhiteList) != 0: + // If the username is from a case insensitive domain then we should perform case insensitive checks on the whitelist + usernameIsCaseInsensitive := isUsernameCaseInsensitive(&user) + for _, wl := range cfg.Cfg.WhiteList { - if user.Username == wl { + // Case sensitivity should only apply to email-based usernames + if user.Username == wl || (isEmailValid(user.Username) && usernameIsCaseInsensitive && strings.ToLower(user.Username) == strings.ToLower(wl)) { log.Debugf("verifyUser: Success! found user.Username in WhiteList: %s", user.Username) return true, nil } diff --git a/handlers/handlers_test.go b/handlers/handlers_test.go index 76877167..31f33297 100644 --- a/handlers/handlers_test.go +++ b/handlers/handlers_test.go @@ -111,6 +111,82 @@ func TestVerifyUserNegative(t *testing.T) { assert.False(t, ok) assert.NotNil(t, err) } +func TestVerifyUserPositiveCaseInsensitiveEmailDomains(t *testing.T) { + setUp("/config/testing/handler_case_insensitive_email_domains.yml") + tests := []struct { + name string + user *structs.User + }{ + {name: "case insensitive domain having caps domain", user: &structs.User{Username: "test@EXAMPLE1.com", Email: "something@else.com", Name: "Test Name"}}, + {name: "case insensitive subdomain having caps email", user: &structs.User{Username: "TEST@example1.com", Email: "something@else.com", Name: "Test Name"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := verifyUser(*tt.user) + assert.True(t, ok) + assert.Nil(t, err) + }) + } +} + +func TestVerifyUserNegativeCaseInsensitiveEmailDomains(t *testing.T) { + setUp("/config/testing/handler_case_insensitive_email_domains.yml") + tests := []struct { + name string + user *structs.User + }{ + {name: "case sensitive domain having mixed case", user: &structs.User{Username: "TEST@example2.com", Email: "something@else.com", Name: "Test Name"}}, + {name: "case sensitive subdomain having mixed case", user: &structs.User{Username: "TEST@sub.example1.com", Email: "something@else.com", Name: "Test Name"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := verifyUser(*tt.user) + assert.False(t, ok) + assert.NotNil(t, err) + }) + } +} + +func TestVerifyUserPositiveCaseInsensitiveEmails(t *testing.T) { + setUp("/config/testing/handler_case_insensitive_emails.yml") + + tests := []struct { + name string + user *structs.User + }{ + {name: "email is caps", user: &structs.User{Username: "TEST@example.com", Email: "something@else.com", Name: "Test Name"}}, + {name: "email is caps with subdomain", user: &structs.User{Username: "TEST@sub.example.com", Email: "something@else.com", Name: "Test Name"}}, + {name: "email is caps with mixed submain", user: &structs.User{Username: "TEST@sub.EXAMPLE.com", Email: "something@else.com", Name: "Test Name"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := verifyUser(*tt.user) + assert.True(t, ok) + assert.Nil(t, err) + }) + } +} + +func TestVerifyUserNegativeCaseInsensitiveEmails(t *testing.T) { + setUp("/config/testing/handler_case_insensitive_emails.yml") + tests := []struct { + name string + user *structs.User + }{ + {name: "non-email username is caps", user: &structs.User{Username: "TEST_USERNAME", Email: "something@else.com", Name: "Test Name"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, err := verifyUser(*tt.user) + assert.False(t, ok) + assert.NotNil(t, err) + }) + } +} // copied from jwtmanager_test.go // it should live there but circular imports are resolved if it lives here diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index fae2bf90..71580a56 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -35,16 +35,17 @@ import ( // as well as a `envconfig` tag used by https://github.com/kelseyhightower/envconfig // though most of the time envconfig will use the struct key's name: VOUCH_PORT VOUCH_JWT_MAXAGE type Config struct { - LogLevel string `mapstructure:"logLevel"` - Listen string `mapstructure:"listen"` - Port int `mapstructure:"port"` - Domains []string `mapstructure:"domains"` - WhiteList []string `mapstructure:"whitelist"` - TeamWhiteList []string `mapstructure:"teamWhitelist"` - AllowAllUsers bool `mapstructure:"allowAllUsers"` - PublicAccess bool `mapstructure:"publicAccess"` - - TLS struct { + LogLevel string `mapstructure:"logLevel"` + Listen string `mapstructure:"listen"` + Port int `mapstructure:"port"` + Domains []string `mapstructure:"domains"` + CaseInsensitiveEmails bool `mapstructure:"case_insensitive_emails" envconfig:"case_insensitive_emails"` + CaseInsensitiveEmailDomains []string `mapstructure:"case_insensitive_email_domains" envconfig:"case_insensitive_email_domains"` + WhiteList []string `mapstructure:"whitelist"` + TeamWhiteList []string `mapstructure:"teamWhitelist"` + AllowAllUsers bool `mapstructure:"allowAllUsers"` + PublicAccess bool `mapstructure:"publicAccess"` + TLS struct { Cert string `mapstructure:"cert"` Key string `mapstructure:"key"` Profile string `mapstructure:"profile"` @@ -378,6 +379,11 @@ func basicTest() error { return fmt.Errorf("configuration error: either one of %s or %s needs to be set (but not both)", Branding.LCName+".domains", Branding.LCName+".allowAllUsers") } + // Email case insensitive list should be empty if the global case insensitivity flag is set + if Cfg.CaseInsensitiveEmails && len(Cfg.CaseInsensitiveEmailDomains) > 0 { + return fmt.Errorf("configuration error: either one of %s or %s needs to be set (but not both)", Branding.LCName+".case_insensitive_emails", Branding.LCName+".case_insensitive_email_domains") + } + // issue a warning if the secret is too small log.Debugf("vouch.jwt.secret is %d characters long", len(Cfg.JWT.Secret)) if len(Cfg.JWT.Secret) < minBase64Length { diff --git a/pkg/cfg/cfg_test.go b/pkg/cfg/cfg_test.go index 7cfe6cf0..c6202cb9 100644 --- a/pkg/cfg/cfg_test.go +++ b/pkg/cfg/cfg_test.go @@ -130,12 +130,12 @@ func Test_configureFromEnvCfg(t *testing.T) { "VOUCH_HEADERS_CLAIMHEADER", "VOUCH_HEADERS_ACCESSTOKEN", "VOUCH_HEADERS_IDTOKEN", "VOUCH_COOKIE_NAME", "VOUCH_COOKIE_DOMAIN", "VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY"} // array of strings - saenv := []string{"VOUCH_DOMAINS", "VOUCH_WHITELIST", "VOUCH_TEAMWHITELIST", "VOUCH_HEADERS_CLAIMS", "VOUCH_TESTURLS", "VOUCH_POST_LOGOUT_REDIRECT_URIS"} + saenv := []string{"VOUCH_DOMAINS", "VOUCH_WHITELIST", "VOUCH_TEAMWHITELIST", "VOUCH_HEADERS_CLAIMS", "VOUCH_TESTURLS", "VOUCH_POST_LOGOUT_REDIRECT_URIS", "VOUCH_CASE_INSENSITIVE_EMAIL_DOMAINS"} // int ienv := []string{"VOUCH_PORT", "VOUCH_JWT_MAXAGE", "VOUCH_COOKIE_MAXAGE"} // bool benv := []string{"VOUCH_ALLOWALLUSERS", "VOUCH_PUBLICACCESS", "VOUCH_JWT_COMPRESS", "VOUCH_COOKIE_SECURE", - "VOUCH_COOKIE_HTTPONLY", "VOUCH_TESTING"} + "VOUCH_COOKIE_HTTPONLY", "VOUCH_TESTING", "VOUCH_CASE_INSENSITIVE_EMAILS"} // populate environmental variables svalue := "svalue" @@ -168,12 +168,13 @@ func Test_configureFromEnvCfg(t *testing.T) { Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key, } - sacfg := [][]string{Cfg.Domains, Cfg.WhiteList, Cfg.TeamWhiteList, Cfg.Headers.Claims, Cfg.TestURLs, Cfg.LogoutRedirectURLs} + sacfg := [][]string{Cfg.Domains, Cfg.WhiteList, Cfg.TeamWhiteList, Cfg.Headers.Claims, Cfg.TestURLs, Cfg.LogoutRedirectURLs, Cfg.CaseInsensitiveEmailDomains,} icfg := []int{Cfg.Port, Cfg.JWT.MaxAge, Cfg.Cookie.MaxAge} bcfg := []bool{Cfg.AllowAllUsers, Cfg.PublicAccess, Cfg.JWT.Compress, Cfg.Cookie.Secure, Cfg.Cookie.HTTPOnly, Cfg.Testing, + Cfg.CaseInsensitiveEmails, } tests := []struct {