Merge pull request #139 from jburnham/google_group_auth
[RDY] google: Support restricting access to a specific group(s)
This commit is contained in:
		
						commit
						2a784ae0d0
					
				
							
								
								
									
										3
									
								
								Godeps
								
								
								
								
							
							
						
						
									
										3
									
								
								Godeps
								
								
								
								
							|  | @ -3,3 +3,6 @@ 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 | ||||
|  |  | |||
							
								
								
									
										25
									
								
								README.md
								
								
								
								
							
							
						
						
									
										25
									
								
								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://]<addr>:<port> or unix://<path> to listen on for HTTP clients | ||||
|   -https-address=":443": <addr>:<port> to listen on for HTTPS clients | ||||
|  | @ -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`. | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										4
									
								
								main.go
								
								
								
								
							
							
						
						
									
										4
									
								
								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)") | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										25
									
								
								options.go
								
								
								
								
							
							
						
						
									
										25
									
								
								options.go
								
								
								
								
							|  | @ -3,6 +3,7 @@ package main | |||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | @ -25,6 +26,9 @@ type Options struct { | |||
| 	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"` | ||||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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()) | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue