diff --git a/Godeps b/Godeps index 6a77c0f..ad3cf0d 100644 --- a/Godeps +++ b/Godeps @@ -1,5 +1,8 @@ -github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223 -github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24 -github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d -github.com/bmizerany/assert e17e99893cb6509f428e1728281c2ad60a6b31e3 -gopkg.in/fsnotify.v1 v1.2.0 +github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223 +github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24 +github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d +github.com/bmizerany/assert e17e99893cb6509f428e1728281c2ad60a6b31e3 +gopkg.in/fsnotify.v1 v1.2.0 +golang.org/x/oauth2 397fe7649477ff2e8ced8fc0b2696f781e53745a +golang.org/x/oauth2/google 397fe7649477ff2e8ced8fc0b2696f781e53745a +google.golang.org/api/admin/directory/v1 a5c3e2a4792aff40e59840d9ecdff0542a202a80 diff --git a/README.md b/README.md index dc26ec9..86cdc37 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,26 @@ For Google, the registration steps are: It's recommended to refresh sessions on a short interval (1h) with `cookie-refresh` setting which validates that the account is still authorized. +#### Restrict auth to specific Google groups on your domain. (optional) + +1. Create a service account: https://developers.google.com/identity/protocols/OAuth2ServiceAccount and make sure to download the json file. +2. Make note of the Client ID for a future step. +3. Under "APIs & Auth", choose APIs. +4. Click on Admin SDK and then Enable API. +5. Follow the steps on https://developers.google.com/admin-sdk/directory/v1/guides/delegation#delegate_domain-wide_authority_to_your_service_account and give the client id from step 2 the following oauth scopes: +``` +https://www.googleapis.com/auth/admin.directory.group.readonly +https://www.googleapis.com/auth/admin.directory.user.readonly +``` +6. Follow the steps on https://support.google.com/a/answer/60757 to enable Admin API access. +7. Create or choose an existing administrative email address on the Gmail domain to assign to the ```google-admin-email``` flag. This email will be impersonated by this client to make calls to the Admin SDK. See the note on the link from step 5 for the reason why. +8. Create or choose an existing email group and set that email to the ```google-group``` flag. You can pass multiple instances of this flag with different groups +and the user will be checked against all the provided groups. +9. Lock down the permissions on the json file downloaded from step 1 so only oauth2_proxy is able to read the file and set the path to the file in the ```google-service-account-json``` flag. +10. Restart oauth2_proxy. + +Note: The user is checked against the group members list on initial authentication and every time the token is refreshed ( about once an hour ). + ### GitHub Auth Provider 1. Create a new project: https://github.com/settings/developers @@ -110,6 +130,10 @@ Usage of oauth2_proxy: -email-domain=: authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email -github-org="": restrict logins to members of this organisation -github-team="": restrict logins to members of this team + -google-group="": restrict logins to members of this google group + -google-admin-email="": the google admin to impersonate for api calls + -google-service-account-json="": the path to the service account json credentials + -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption -http-address="127.0.0.1:4180": [http://]: or unix:// to listen on for HTTP clients -https-address=":443": : to listen on for HTTPS clients @@ -141,7 +165,7 @@ The environment variables `OAUTH2_PROXY_CLIENT_ID`, `OAUTH2_PROXY_CLIENT_SECRET` ## SSL Configuration -There are two recommended configurations. +There are two recommended configurations. 1) Configure SSL Terminiation with OAuth2 Proxy by providing a `--tls-cert=/path/to/cert.pem` and `--tls-key=/path/to/cert.key`. @@ -171,7 +195,7 @@ Nginx will listen on port `443` and handle SSL connections while proxying to `oa `oauth2_proxy` will then authenticate requests for an upstream application. The external endpoint for this example would be `https://internal.yourcompany.com/`. -An example Nginx config follows. Note the use of `Strict-Transport-Security` header to pin requests to SSL +An example Nginx config follows. Note the use of `Strict-Transport-Security` header to pin requests to SSL via [HSTS](http://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security): ``` @@ -233,4 +257,3 @@ Follow the examples in the [`providers` package](providers/) to define a new `Provider` instance. Add a new `case` to [`providers.New()`](providers/providers.go) to allow `oauth2_proxy` to use the new `Provider`. - diff --git a/main.go b/main.go index 79f349c..9c797b0 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ func main() { emailDomains := StringArray{} upstreams := StringArray{} skipAuthRegex := StringArray{} + googleGroups := StringArray{} config := flagSet.String("config", "", "path to config file") showVersion := flagSet.Bool("version", false, "print version string") @@ -39,6 +40,9 @@ func main() { flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email") flagSet.String("github-org", "", "restrict logins to members of this organisation") flagSet.String("github-team", "", "restrict logins to members of this team") + flagSet.Var(&googleGroups, "google-group", "restrict logins to members of this google group (may be given multiple times).") + flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls") + flagSet.String("google-service-account-json", "", "the path to the service account json credentials") flagSet.String("client-id", "", "the OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"") flagSet.String("client-secret", "", "the OAuth Client Secret") flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)") diff --git a/oauthproxy.go b/oauthproxy.go index 07c3ec9..9d24542 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -433,7 +433,7 @@ func (p *OauthProxy) OauthCallback(rw http.ResponseWriter, req *http.Request) { } // set cookie, or deny - if p.Validator(session.Email) { + if p.Validator(session.Email) && p.provider.ValidateGroup(session.Email) { log.Printf("%s authentication complete %s", remoteAddr, session) err := p.SaveSession(rw, req, session) if err != nil { @@ -477,7 +477,7 @@ func (p *OauthProxy) Proxy(rw http.ResponseWriter, req *http.Request) { clearSession = true } - if saveSession && !revalidated && session.AccessToken != "" { + if saveSession && !revalidated && session != nil && session.AccessToken != "" { if !p.provider.ValidateSessionState(session) { log.Printf("%s removing session. error validating %s", remoteAddr, session) saveSession = false @@ -493,7 +493,7 @@ func (p *OauthProxy) Proxy(rw http.ResponseWriter, req *http.Request) { clearSession = true } - if saveSession { + if saveSession && session != nil { err := p.SaveSession(rw, req, session) if err != nil { log.Printf("%s %s", remoteAddr, err) diff --git a/options.go b/options.go index 56ab944..b4b8afa 100644 --- a/options.go +++ b/options.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/url" + "os" "regexp" "strings" "time" @@ -21,13 +22,16 @@ type Options struct { TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file"` TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file"` - AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` - EmailDomains []string `flag:"email-domain" cfg:"email_domains"` - GitHubOrg string `flag:"github-org" cfg:"github_org"` - GitHubTeam string `flag:"github-team" cfg:"github_team"` - HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` - DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"` - CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"` + AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` + EmailDomains []string `flag:"email-domain" cfg:"email_domains"` + GitHubOrg string `flag:"github-org" cfg:"github_org"` + GitHubTeam string `flag:"github-team" cfg:"github_team"` + GoogleGroups []string `flag:"google-group" cfg:"google_group"` + GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"` + GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` + HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` + DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"` + CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"` CookieName string `flag:"cookie-name" cfg:"cookie_name" env:"OAUTH2_PROXY_COOKIE_NAME"` CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret" env:"OAUTH2_PROXY_COOKIE_SECRET"` @@ -159,6 +163,18 @@ func (o *Options) Validate() error { o.CookieExpire.String())) } + if len(o.GoogleGroups) > 0 || o.GoogleAdminEmail != "" || o.GoogleServiceAccountJSON != "" { + if len(o.GoogleGroups) < 1 { + msgs = append(msgs, "missing setting: google-group") + } + if o.GoogleAdminEmail == "" { + msgs = append(msgs, "missing setting: google-admin-email") + } + if o.GoogleServiceAccountJSON == "" { + msgs = append(msgs, "missing setting: google-service-account-json") + } + } + if len(msgs) != 0 { return fmt.Errorf("Invalid configuration:\n %s", strings.Join(msgs, "\n ")) @@ -182,6 +198,15 @@ func parseProviderInfo(o *Options, msgs []string) []string { switch p := o.provider.(type) { case *providers.GitHubProvider: p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam) + case *providers.GoogleProvider: + if o.GoogleServiceAccountJSON != "" { + file, err := os.Open(o.GoogleServiceAccountJSON) + if err != nil { + msgs = append(msgs, "invalid Google credentials file: "+o.GoogleServiceAccountJSON) + } else { + p.SetGroupRestriction(o.GoogleGroups, o.GoogleAdminEmail, file) + } + } } return msgs } diff --git a/options_test.go b/options_test.go index 3b2f19f..fcb4b58 100644 --- a/options_test.go +++ b/options_test.go @@ -40,6 +40,32 @@ func TestNewOptions(t *testing.T) { assert.Equal(t, expected, err.Error()) } +func TestGoogleGroupOptions(t *testing.T) { + o := testOptions() + o.GoogleGroups = []string{"googlegroup"} + err := o.Validate() + assert.NotEqual(t, nil, err) + + expected := errorMsg([]string{ + "missing setting: google-admin-email", + "missing setting: google-service-account-json"}) + assert.Equal(t, expected, err.Error()) +} + +func TestGoogleGroupInvalidFile(t *testing.T) { + o := testOptions() + o.GoogleGroups = []string{"test_group"} + o.GoogleAdminEmail = "admin@example.com" + o.GoogleServiceAccountJSON = "file_doesnt_exist.json" + err := o.Validate() + assert.NotEqual(t, nil, err) + + expected := errorMsg([]string{ + "invalid Google credentials file: file_doesnt_exist.json", + }) + assert.Equal(t, expected, err.Error()) +} + func TestInitializedOptions(t *testing.T) { o := testOptions() assert.Equal(t, nil, o.Validate()) diff --git a/providers/google.go b/providers/google.go index 8c0a0cc..d71f313 100644 --- a/providers/google.go +++ b/providers/google.go @@ -6,17 +6,25 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "log" "net/http" "net/url" "strings" "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/admin/directory/v1" ) type GoogleProvider struct { *ProviderData RedeemRefreshUrl *url.URL + // GroupValidator is a function that determines if the passed email is in + // the configured Google group. + GroupValidator func(string) bool } func NewGoogleProvider(p *ProviderData) *GoogleProvider { @@ -42,7 +50,15 @@ func NewGoogleProvider(p *ProviderData) *GoogleProvider { if p.Scope == "" { p.Scope = "profile email" } - return &GoogleProvider{ProviderData: p} + + return &GoogleProvider{ + ProviderData: p, + // Set a default GroupValidator to just always return valid (true), it will + // be overwritten if we configured a Google group restriction. + GroupValidator: func(email string) bool { + return true + }, + } } func emailFromIdToken(idToken string) (string, error) { @@ -139,6 +155,102 @@ func (p *GoogleProvider) Redeem(redirectUrl, code string) (s *SessionState, err return } +// SetGroupRestriction configures the GoogleProvider to restrict access to the +// specified group(s). AdminEmail has to be an administrative email on the domain that is +// checked. CredentialsFile is the path to a json file containing a Google service +// account credentials. +func (p *GoogleProvider) SetGroupRestriction(groups []string, adminEmail string, credentialsReader io.Reader) { + adminService := getAdminService(adminEmail, credentialsReader) + p.GroupValidator = func(email string) bool { + return userInGroup(adminService, groups, email) + } +} + +func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service { + data, err := ioutil.ReadAll(credentialsReader) + if err != nil { + log.Fatal("can't read Google credentials file:", err) + } + conf, err := google.JWTConfigFromJSON(data, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope) + if err != nil { + log.Fatal("can't load Google credentials file:", err) + } + conf.Subject = adminEmail + + client := conf.Client(oauth2.NoContext) + adminService, err := admin.New(client) + if err != nil { + log.Fatal(err) + } + return adminService +} + +func userInGroup(service *admin.Service, groups []string, email string) bool { + user, err := fetchUser(service, email) + if err != nil { + log.Printf("error fetching user: %v", err) + return false + } + id := user.Id + custID := user.CustomerId + + for _, group := range groups { + members, err := fetchGroupMembers(service, group) + if err != nil { + log.Printf("error fetching group members: %v", err) + return false + } + + for _, member := range members { + switch member.Type { + case "CUSTOMER": + if member.Id == custID { + return true + } + case "USER": + if member.Id == id { + return true + } + } + } + } + return false +} + +func fetchUser(service *admin.Service, email string) (*admin.User, error) { + user, err := service.Users.Get(email).Do() + return user, err +} + +func fetchGroupMembers(service *admin.Service, group string) ([]*admin.Member, error) { + members := []*admin.Member{} + pageToken := "" + for { + req := service.Members.List(group) + if pageToken != "" { + req.PageToken(pageToken) + } + r, err := req.Do() + if err != nil { + return nil, err + } + for _, member := range r.Members { + members = append(members, member) + } + if r.NextPageToken == "" { + break + } + pageToken = r.NextPageToken + } + return members, nil +} + +// ValidateGroup validates that the provided email exists in the configured Google +// group(s). +func (p *GoogleProvider) ValidateGroup(email string) bool { + return p.GroupValidator(email) +} + func (p *GoogleProvider) RefreshSessionIfNeeded(s *SessionState) (bool, error) { if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" { return false, nil @@ -148,6 +260,12 @@ func (p *GoogleProvider) RefreshSessionIfNeeded(s *SessionState) (bool, error) { if err != nil { return false, err } + + // re-check that the user is in the proper google group(s) + if !p.ValidateGroup(s.Email) { + return false, fmt.Errorf("%s is no longer in the group(s)", s.Email) + } + origExpiration := s.ExpiresOn s.AccessToken = newToken s.ExpiresOn = time.Now().Add(duration).Truncate(time.Second) diff --git a/providers/google_test.go b/providers/google_test.go index 0da80f4..8f0d29b 100644 --- a/providers/google_test.go +++ b/providers/google_test.go @@ -105,6 +105,23 @@ func TestGoogleProviderGetEmailAddress(t *testing.T) { assert.Equal(t, "refresh12345", session.RefreshToken) } +func TestGoogleProviderValidateGroup(t *testing.T) { + p := newGoogleProvider() + p.GroupValidator = func(email string) bool { + return email == "michael.bland@gsa.gov" + } + assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov")) + p.GroupValidator = func(email string) bool { + return email != "michael.bland@gsa.gov" + } + assert.Equal(t, false, p.ValidateGroup("michael.bland@gsa.gov")) +} + +func TestGoogleProviderWithoutValidateGroup(t *testing.T) { + p := newGoogleProvider() + assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov")) +} + // func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) { p := newGoogleProvider() diff --git a/providers/provider_default.go b/providers/provider_default.go index d0b46b9..1a2e7f7 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -105,6 +105,12 @@ func (p *ProviderData) GetEmailAddress(s *SessionState) (string, error) { return "", errors.New("not implemented") } +// ValidateGroup validates that the provided email exists in the configured provider +// email group(s). +func (p *ProviderData) ValidateGroup(email string) bool { + return true +} + func (p *ProviderData) ValidateSessionState(s *SessionState) bool { return validateToken(p, s.AccessToken, nil) } diff --git a/providers/providers.go b/providers/providers.go index 3192011..59e5f9a 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -8,6 +8,7 @@ type Provider interface { Data() *ProviderData GetEmailAddress(*SessionState) (string, error) Redeem(string, string) (*SessionState, error) + ValidateGroup(string) bool ValidateSessionState(*SessionState) bool GetLoginURL(redirectURI, finalRedirect string) string RefreshSessionIfNeeded(*SessionState) (bool, error)