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
13
Godeps
13
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
|
||||
|
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)
|
||||
|
39
options.go
39
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
|
||||
}
|
||||
|
@ -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
Block a user