git.iko.soy/typeable/oauth2-proxy
on master help sign in
google: Support restricting access to a specific group(s)
commit3fd8f911c2108ee1ea0556f1200b191255c3c461 authorJustin Burnham <justin@jburnham.net> dateThu 20 Aug 2015, 03:07 (11 years ago) parentd1c0208
10 files changed+242−19
Godeps+8−5 README.md+26−3 main.go+4 oauthproxy.go+3−3 options.go+32−7 options_test.go+26 providers/google.go+119−1 providers/google_test.go+17 providers/provider_default.go+6 providers/providers.go+1
Godeps+8−5
@@ -1,5 +1,8 @@
1−github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223
2−github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24
3−github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d
4−github.com/bmizerany/assert e17e99893cb6509f428e1728281c2ad60a6b31e3
5−gopkg.in/fsnotify.v1 v1.2.0
1+github.com/BurntSushi/toml 3883ac1ce943878302255f538fce319d23226223
2+github.com/bitly/go-simplejson 3378bdcb5cebedcbf8b5750edee28010f128fe24
3+github.com/mreiferson/go-options ee94b57f2fbf116075426f853e5abbcdfeca8b3d
4+github.com/bmizerany/assert e17e99893cb6509f428e1728281c2ad60a6b31e3
5+gopkg.in/fsnotify.v1 v1.2.0
6+golang.org/x/oauth2 397fe7649477ff2e8ced8fc0b2696f781e53745a
7+golang.org/x/oauth2/google 397fe7649477ff2e8ced8fc0b2696f781e53745a
8+google.golang.org/api/admin/directory/v1 a5c3e2a4792aff40e59840d9ecdff0542a202a80
README.md+26−3
@@ -51,6 +51,26 @@ For Google, the registration steps are:
5151
5252It's recommended to refresh sessions on a short interval (1h) with `cookie-refresh` setting which validates that the account is still authorized.
5353
54+#### Restrict auth to specific Google groups on your domain. (optional)
55+
56+1. Create a service account: https://developers.google.com/identity/protocols/OAuth2ServiceAccount and make sure to download the json file.
57+2. Make note of the Client ID for a future step.
58+3. Under "APIs & Auth", choose APIs.
59+4. Click on Admin SDK and then Enable API.
60+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:
61+```
62+https://www.googleapis.com/auth/admin.directory.group.readonly
63+https://www.googleapis.com/auth/admin.directory.user.readonly
64+```
65+6. Follow the steps on https://support.google.com/a/answer/60757 to enable Admin API access.
66+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.
67+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
68+and the user will be checked against all the provided groups.
69+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.
70+10. Restart oauth2_proxy.
71+
72+Note: The user is checked against the group members list on initial authentication and every time the token is refreshed ( about once an hour ).
73+
5474### GitHub Auth Provider
5575
56761. Create a new project: https://github.com/settings/developers
@@ -110,6 +130,10 @@ Usage of oauth2_proxy:
110130 -email-domain=: authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email
111131 -github-org="": restrict logins to members of this organisation
112132 -github-team="": restrict logins to members of this team
133+ -google-group="": restrict logins to members of this google group
134+ -google-admin-email="": the google admin to impersonate for api calls
135+ -google-service-account-json="": the path to the service account json credentials
136+
113137 -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
114138 -http-address="127.0.0.1:4180": [http://]<addr>:<port> or unix://<path> to listen on for HTTP clients
115139 -https-address=":443": <addr>:<port> to listen on for HTTPS clients
@@ -141,7 +165,7 @@ The environment variables `OAUTH2_PROXY_CLIENT_ID`, `OAUTH2_PROXY_CLIENT_SECRET`
141165
142166## SSL Configuration
143167
144−There are two recommended configurations.
168+There are two recommended configurations.
145169
1461701) Configure SSL Terminiation with OAuth2 Proxy by providing a `--tls-cert=/path/to/cert.pem` and `--tls-key=/path/to/cert.key`.
147171
@@ -171,7 +195,7 @@ Nginx will listen on port `443` and handle SSL connections while proxying to `oa
171195`oauth2_proxy` will then authenticate requests for an upstream application. The external endpoint for this example
172196would be `https://internal.yourcompany.com/`.
173197
174−An example Nginx config follows. Note the use of `Strict-Transport-Security` header to pin requests to SSL
198+An example Nginx config follows. Note the use of `Strict-Transport-Security` header to pin requests to SSL
175199via [HSTS](http://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security):
176200
177201```
@@ -233,4 +257,3 @@ Follow the examples in the [`providers` package](providers/) to define a new
233257`Provider` instance. Add a new `case` to
234258[`providers.New()`](providers/providers.go) to allow `oauth2_proxy` to use the
235259new `Provider`.
236−
main.go+4
@@ -20,6 +20,7 @@ func main() {
2020 emailDomains := StringArray{}
2121 upstreams := StringArray{}
2222 skipAuthRegex := StringArray{}
23+ googleGroups := StringArray{}
2324
2425 config := flagSet.String("config", "", "path to config file")
2526 showVersion := flagSet.Bool("version", false, "print version string")
@@ -39,6 +40,9 @@ func main() {
3940 flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
4041 flagSet.String("github-org", "", "restrict logins to members of this organisation")
4142 flagSet.String("github-team", "", "restrict logins to members of this team")
43+ flagSet.Var(&googleGroups, "google-group", "restrict logins to members of this google group (may be given multiple times).")
44+ flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls")
45+ flagSet.String("google-service-account-json", "", "the path to the service account json credentials")
4246 flagSet.String("client-id", "", "the OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
4347 flagSet.String("client-secret", "", "the OAuth Client Secret")
4448 flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
oauthproxy.go+3−3
@@ -433,7 +433,7 @@ func (p *OauthProxy) OauthCallback(rw http.ResponseWriter, req *http.Request) {
433433 }
434434
435435 // set cookie, or deny
436− if p.Validator(session.Email) {
436+ if p.Validator(session.Email) && p.provider.ValidateGroup(session.Email) {
437437 log.Printf("%s authentication complete %s", remoteAddr, session)
438438 err := p.SaveSession(rw, req, session)
439439 if err != nil {
@@ -477,7 +477,7 @@ func (p *OauthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
477477 clearSession = true
478478 }
479479
480− if saveSession && !revalidated && session.AccessToken != "" {
480+ if saveSession && !revalidated && session != nil && session.AccessToken != "" {
481481 if !p.provider.ValidateSessionState(session) {
482482 log.Printf("%s removing session. error validating %s", remoteAddr, session)
483483 saveSession = false
@@ -493,7 +493,7 @@ func (p *OauthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
493493 clearSession = true
494494 }
495495
496− if saveSession {
496+ if saveSession && session != nil {
497497 err := p.SaveSession(rw, req, session)
498498 if err != nil {
499499 log.Printf("%s %s", remoteAddr, err)
options.go+32−7
@@ -3,6 +3,7 @@ package main
33import (
44 "fmt"
55 "net/url"
6+ "os"
67 "regexp"
78 "strings"
89 "time"
@@ -21,13 +22,16 @@ type Options struct {
2122 TLSCertFile string `flag:"tls-cert" cfg:"tls_cert_file"`
2223 TLSKeyFile string `flag:"tls-key" cfg:"tls_key_file"`
2324
24− AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
25− EmailDomains []string `flag:"email-domain" cfg:"email_domains"`
26− GitHubOrg string `flag:"github-org" cfg:"github_org"`
27− GitHubTeam string `flag:"github-team" cfg:"github_team"`
28− HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"`
29− DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"`
30− CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"`
25+ AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
26+ EmailDomains []string `flag:"email-domain" cfg:"email_domains"`
27+ GitHubOrg string `flag:"github-org" cfg:"github_org"`
28+ GitHubTeam string `flag:"github-team" cfg:"github_team"`
29+ GoogleGroups []string `flag:"google-group" cfg:"google_group"`
30+ GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"`
31+ GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"`
32+ HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"`
33+ DisplayHtpasswdForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"`
34+ CustomTemplatesDir string `flag:"custom-templates-dir" cfg:"custom_templates_dir"`
3135
3236 CookieName string `flag:"cookie-name" cfg:"cookie_name" env:"OAUTH2_PROXY_COOKIE_NAME"`
3337 CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret" env:"OAUTH2_PROXY_COOKIE_SECRET"`
@@ -159,6 +163,18 @@ func (o *Options) Validate() error {
159163 o.CookieExpire.String()))
160164 }
161165
166+ if len(o.GoogleGroups) > 0 || o.GoogleAdminEmail != "" || o.GoogleServiceAccountJSON != "" {
167+ if len(o.GoogleGroups) < 1 {
168+ msgs = append(msgs, "missing setting: google-group")
169+ }
170+ if o.GoogleAdminEmail == "" {
171+ msgs = append(msgs, "missing setting: google-admin-email")
172+ }
173+ if o.GoogleServiceAccountJSON == "" {
174+ msgs = append(msgs, "missing setting: google-service-account-json")
175+ }
176+ }
177+
162178 if len(msgs) != 0 {
163179 return fmt.Errorf("Invalid configuration:\n %s",
164180 strings.Join(msgs, "\n "))
@@ -182,6 +198,15 @@ func parseProviderInfo(o *Options, msgs []string) []string {
182198 switch p := o.provider.(type) {
183199 case *providers.GitHubProvider:
184200 p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam)
201+ case *providers.GoogleProvider:
202+ if o.GoogleServiceAccountJSON != "" {
203+ file, err := os.Open(o.GoogleServiceAccountJSON)
204+ if err != nil {
205+ msgs = append(msgs, "invalid Google credentials file: "+o.GoogleServiceAccountJSON)
206+ } else {
207+ p.SetGroupRestriction(o.GoogleGroups, o.GoogleAdminEmail, file)
208+ }
209+ }
185210 }
186211 return msgs
187212}
options_test.go+26
@@ -40,6 +40,32 @@ func TestNewOptions(t *testing.T) {
4040 assert.Equal(t, expected, err.Error())
4141}
4242
43+func TestGoogleGroupOptions(t *testing.T) {
44+ o := testOptions()
45+ o.GoogleGroups = []string{"googlegroup"}
46+ err := o.Validate()
47+ assert.NotEqual(t, nil, err)
48+
49+ expected := errorMsg([]string{
50+ "missing setting: google-admin-email",
51+ "missing setting: google-service-account-json"})
52+ assert.Equal(t, expected, err.Error())
53+}
54+
55+func TestGoogleGroupInvalidFile(t *testing.T) {
56+ o := testOptions()
57+ o.GoogleGroups = []string{"test_group"}
58+ o.GoogleAdminEmail = "admin@example.com"
59+ o.GoogleServiceAccountJSON = "file_doesnt_exist.json"
60+ err := o.Validate()
61+ assert.NotEqual(t, nil, err)
62+
63+ expected := errorMsg([]string{
64+ "invalid Google credentials file: file_doesnt_exist.json",
65+ })
66+ assert.Equal(t, expected, err.Error())
67+}
68+
4369func TestInitializedOptions(t *testing.T) {
4470 o := testOptions()
4571 assert.Equal(t, nil, o.Validate())
providers/google.go+119−1
@@ -6,17 +6,25 @@ import (
66 "encoding/json"
77 "errors"
88 "fmt"
9+ "io"
910 "io/ioutil"
1011 "log"
1112 "net/http"
1213 "net/url"
1314 "strings"
1415 "time"
16+
17+ "golang.org/x/oauth2"
18+ "golang.org/x/oauth2/google"
19+ "google.golang.org/api/admin/directory/v1"
1520)
1621
1722type GoogleProvider struct {
1823 *ProviderData
1924 RedeemRefreshUrl *url.URL
25+ // GroupValidator is a function that determines if the passed email is in
26+ // the configured Google group.
27+ GroupValidator func(string) bool
2028}
2129
2230func NewGoogleProvider(p *ProviderData) *GoogleProvider {
@@ -42,7 +50,15 @@ func NewGoogleProvider(p *ProviderData) *GoogleProvider {
4250 if p.Scope == "" {
4351 p.Scope = "profile email"
4452 }
45− return &GoogleProvider{ProviderData: p}
53+
54+ return &GoogleProvider{
55+ ProviderData: p,
56+ // Set a default GroupValidator to just always return valid (true), it will
57+ // be overwritten if we configured a Google group restriction.
58+ GroupValidator: func(email string) bool {
59+ return true
60+ },
61+ }
4662}
4763
4864func emailFromIdToken(idToken string) (string, error) {
@@ -139,6 +155,102 @@ func (p *GoogleProvider) Redeem(redirectUrl, code string) (s *SessionState, err
139155 return
140156}
141157
158+// SetGroupRestriction configures the GoogleProvider to restrict access to the
159+// specified group(s). AdminEmail has to be an administrative email on the domain that is
160+// checked. CredentialsFile is the path to a json file containing a Google service
161+// account credentials.
162+func (p *GoogleProvider) SetGroupRestriction(groups []string, adminEmail string, credentialsReader io.Reader) {
163+ adminService := getAdminService(adminEmail, credentialsReader)
164+ p.GroupValidator = func(email string) bool {
165+ return userInGroup(adminService, groups, email)
166+ }
167+}
168+
169+func getAdminService(adminEmail string, credentialsReader io.Reader) *admin.Service {
170+ data, err := ioutil.ReadAll(credentialsReader)
171+ if err != nil {
172+ log.Fatal("can't read Google credentials file:", err)
173+ }
174+ conf, err := google.JWTConfigFromJSON(data, admin.AdminDirectoryUserReadonlyScope, admin.AdminDirectoryGroupReadonlyScope)
175+ if err != nil {
176+ log.Fatal("can't load Google credentials file:", err)
177+ }
178+ conf.Subject = adminEmail
179+
180+ client := conf.Client(oauth2.NoContext)
181+ adminService, err := admin.New(client)
182+ if err != nil {
183+ log.Fatal(err)
184+ }
185+ return adminService
186+}
187+
188+func userInGroup(service *admin.Service, groups []string, email string) bool {
189+ user, err := fetchUser(service, email)
190+ if err != nil {
191+ log.Printf("error fetching user: %v", err)
192+ return false
193+ }
194+ id := user.Id
195+ custID := user.CustomerId
196+
197+ for _, group := range groups {
198+ members, err := fetchGroupMembers(service, group)
199+ if err != nil {
200+ log.Printf("error fetching group members: %v", err)
201+ return false
202+ }
203+
204+ for _, member := range members {
205+ switch member.Type {
206+ case "CUSTOMER":
207+ if member.Id == custID {
208+ return true
209+ }
210+ case "USER":
211+ if member.Id == id {
212+ return true
213+ }
214+ }
215+ }
216+ }
217+ return false
218+}
219+
220+func fetchUser(service *admin.Service, email string) (*admin.User, error) {
221+ user, err := service.Users.Get(email).Do()
222+ return user, err
223+}
224+
225+func fetchGroupMembers(service *admin.Service, group string) ([]*admin.Member, error) {
226+ members := []*admin.Member{}
227+ pageToken := ""
228+ for {
229+ req := service.Members.List(group)
230+ if pageToken != "" {
231+ req.PageToken(pageToken)
232+ }
233+ r, err := req.Do()
234+ if err != nil {
235+ return nil, err
236+ }
237+ for _, member := range r.Members {
238+ members = append(members, member)
239+ }
240+ if r.NextPageToken == "" {
241+ break
242+ }
243+ pageToken = r.NextPageToken
244+ }
245+ return members, nil
246+}
247+
248+// ValidateGroup validates that the provided email exists in the configured Google
249+// group(s).
250+func (p *GoogleProvider) ValidateGroup(email string) bool {
251+ return p.GroupValidator(email)
252+}
253+
142254func (p *GoogleProvider) RefreshSessionIfNeeded(s *SessionState) (bool, error) {
143255 if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" {
144256 return false, nil
@@ -148,6 +260,12 @@ func (p *GoogleProvider) RefreshSessionIfNeeded(s *SessionState) (bool, error) {
148260 if err != nil {
149261 return false, err
150262 }
263+
264+ // re-check that the user is in the proper google group(s)
265+ if !p.ValidateGroup(s.Email) {
266+ return false, fmt.Errorf("%s is no longer in the group(s)", s.Email)
267+ }
268+
151269 origExpiration := s.ExpiresOn
152270 s.AccessToken = newToken
153271 s.ExpiresOn = time.Now().Add(duration).Truncate(time.Second)
providers/google_test.go+17
@@ -105,6 +105,23 @@ func TestGoogleProviderGetEmailAddress(t *testing.T) {
105105 assert.Equal(t, "refresh12345", session.RefreshToken)
106106}
107107
108+func TestGoogleProviderValidateGroup(t *testing.T) {
109+ p := newGoogleProvider()
110+ p.GroupValidator = func(email string) bool {
111+ return email == "michael.bland@gsa.gov"
112+ }
113+ assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
114+ p.GroupValidator = func(email string) bool {
115+ return email != "michael.bland@gsa.gov"
116+ }
117+ assert.Equal(t, false, p.ValidateGroup("michael.bland@gsa.gov"))
118+}
119+
120+func TestGoogleProviderWithoutValidateGroup(t *testing.T) {
121+ p := newGoogleProvider()
122+ assert.Equal(t, true, p.ValidateGroup("michael.bland@gsa.gov"))
123+}
124+
108125//
109126func TestGoogleProviderGetEmailAddressInvalidEncoding(t *testing.T) {
110127 p := newGoogleProvider()
providers/provider_default.go+6
@@ -105,6 +105,12 @@ func (p *ProviderData) GetEmailAddress(s *SessionState) (string, error) {
105105 return "", errors.New("not implemented")
106106}
107107
108+// ValidateGroup validates that the provided email exists in the configured provider
109+// email group(s).
110+func (p *ProviderData) ValidateGroup(email string) bool {
111+ return true
112+}
113+
108114func (p *ProviderData) ValidateSessionState(s *SessionState) bool {
109115 return validateToken(p, s.AccessToken, nil)
110116}
providers/providers.go+1
@@ -8,6 +8,7 @@ type Provider interface {
88 Data() *ProviderData
99 GetEmailAddress(*SessionState) (string, error)
1010 Redeem(string, string) (*SessionState, error)
11+ ValidateGroup(string) bool
1112 ValidateSessionState(*SessionState) bool
1213 GetLoginURL(redirectURI, finalRedirect string) string
1314 RefreshSessionIfNeeded(*SessionState) (bool, error)