diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 00000000..76caec7d --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,29 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant.configure("2") do |config| + + config.vm.box = "minimal/xenial64" + + config.vm.network "forwarded_port", guest: 8081, host: 8081 + config.vm.network "forwarded_port", guest: 8080, host: 8080 + + config.vm.synced_folder "../../../", "/home/vagrant/go/src/" + + config.vm.provider "virtualbox" do |vb| + vb.memory = "1024" + vb.customize ["modifyvm", :id, "--cableconnected1", "on"] + end + + config.vm.provision "shell", inline: <<-SHELL + apt-get update + apt-get install software-properties-common + add-apt-repository ppa:ubuntu-lxc/lxd-stable + apt-get update + apt-get install -y python golang + SHELL + + config.vm.provision "shell", inline: <<-SHELL + echo 'export GOPATH=$HOME/go' > /etc/profile.d/gopath.sh + SHELL +end diff --git a/main.go b/main.go index b5ed971e..dad45fc7 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ func main() { flagSet.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint or file:// paths for static files. Routing is based on the path") flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream") flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream") + flagSet.Bool("pass-groups", false, "pass user group information along with basic auth to upstream") flagSet.String("basic-auth-password", "", "the password to set when passing the HTTP Basic Auth header") flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header") flagSet.Bool("pass-host-header", true, "pass the request Host Header to upstream") diff --git a/oauthproxy.go b/oauthproxy.go index f4cd5779..259aa38d 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -30,6 +30,7 @@ var SignatureHeaders []string = []string{ "X-Forwarded-User", "X-Forwarded-Email", "X-Forwarded-Access-Token", + "X-Forwarded-Groups", "Cookie", "Gap-Auth", } @@ -62,6 +63,7 @@ type OAuthProxy struct { serveMux http.Handler SetXAuthRequest bool PassBasicAuth bool + PassGroups bool SkipProviderButton bool PassUserHeaders bool BasicAuthPassword string @@ -91,6 +93,7 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { func NewReverseProxy(target *url.URL) (proxy *httputil.ReverseProxy) { return httputil.NewSingleHostReverseProxy(target) } + func setProxyUpstreamHostHeader(proxy *httputil.ReverseProxy, target *url.URL) { director := proxy.Director proxy.Director = func(req *http.Request) { @@ -101,6 +104,7 @@ func setProxyUpstreamHostHeader(proxy *httputil.ReverseProxy, target *url.URL) { req.URL.RawQuery = "" } } + func setProxyDirector(proxy *httputil.ReverseProxy) { director := proxy.Director proxy.Director = func(req *http.Request) { @@ -110,6 +114,7 @@ func setProxyDirector(proxy *httputil.ReverseProxy) { req.URL.RawQuery = "" } } + func NewFileServer(path string, filesystemPath string) (proxy http.Handler) { return http.StripPrefix(path, http.FileServer(http.Dir(filesystemPath))) } @@ -202,6 +207,7 @@ func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy { SetXAuthRequest: opts.SetXAuthRequest, PassBasicAuth: opts.PassBasicAuth, PassUserHeaders: opts.PassUserHeaders, + PassGroups: opts.PassGroups, BasicAuthPassword: opts.BasicAuthPassword, PassAccessToken: opts.PassAccessToken, SkipProviderButton: opts.SkipProviderButton, @@ -237,6 +243,7 @@ func (p *OAuthProxy) redeemCode(host, code string) (s *providers.SessionState, e if code == "" { return nil, errors.New("missing code") } + redirectURI := p.GetRedirectURI(host) s, err = p.provider.Redeem(redirectURI, code) if err != nil { @@ -509,6 +516,7 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { p.ErrorPage(rw, 500, "Internal Error", err.Error()) return } + errorString := req.Form.Get("error") if errorString != "" { p.ErrorPage(rw, 403, "Permission Denied", errorString) @@ -541,6 +549,16 @@ func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) { return } + session.IDToken = req.Form.Get("id_token") + if p.PassGroups && session.IDToken != "" { + session.Groups, err = p.provider.GetGroups(session) + if err != nil { + p.ErrorPage(rw, 500, "Internal Error", "Internal Error") + return + } + } + + redirect = req.Form.Get("state") if !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") { redirect = "/" } @@ -594,6 +612,7 @@ func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int if err != nil { log.Printf("%s %s", remoteAddr, err) } + if session != nil && sessionAge > p.CookieRefresh && p.CookieRefresh != time.Duration(0) { log.Printf("%s refreshing %s old session cookie for %s (refresh after %s)", remoteAddr, sessionAge, session, p.CookieRefresh) saveSession = true @@ -655,12 +674,16 @@ func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int } // At this point, the user is authenticated. proxy normally + if p.PassBasicAuth { req.SetBasicAuth(session.User, p.BasicAuthPassword) req.Header["X-Forwarded-User"] = []string{session.User} if session.Email != "" { req.Header["X-Forwarded-Email"] = []string{session.Email} } + if p.PassGroups && session.Groups != "" { + req.Header["X-Forwarded-Groups"] = []string{session.Groups} + } } if p.PassUserHeaders { req.Header["X-Forwarded-User"] = []string{session.User} @@ -674,6 +697,7 @@ func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int rw.Header().Set("X-Auth-Request-Email", session.Email) } } + if p.PassAccessToken && session.AccessToken != "" { req.Header["X-Forwarded-Access-Token"] = []string{session.AccessToken} } diff --git a/options.go b/options.go index 63f23c6c..0970f631 100644 --- a/options.go +++ b/options.go @@ -51,6 +51,7 @@ type Options struct { Upstreams []string `flag:"upstream" cfg:"upstreams"` SkipAuthRegex []string `flag:"skip-auth-regex" cfg:"skip_auth_regex"` PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"` + PassGroups bool `flag:"pass-groups" cfg:"pass_groups"` BasicAuthPassword string `flag:"basic-auth-password" cfg:"basic_auth_password"` PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"` PassHostHeader bool `flag:"pass-host-header" cfg:"pass_host_header"` @@ -101,6 +102,7 @@ func NewOptions() *Options { SetXAuthRequest: false, PassBasicAuth: true, PassUserHeaders: true, + PassGroups: false, PassAccessToken: false, PassHostHeader: true, ApprovalPrompt: "force", @@ -128,7 +130,7 @@ func (o *Options) Validate() error { if o.ClientID == "" { msgs = append(msgs, "missing setting: client-id") } - if o.ClientSecret == "" { + if o.ClientSecret == "" && o.Provider != "azure" { msgs = append(msgs, "missing setting: client-secret") } if o.AuthenticatedEmailsFile == "" && len(o.EmailDomains) == 0 && o.HtpasswdFile == "" { diff --git a/providers/azure.go b/providers/azure.go index 1ba8a797..17cf5ea9 100644 --- a/providers/azure.go +++ b/providers/azure.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/url" + "strings" ) type AzureProvider struct { @@ -20,22 +21,25 @@ func NewAzureProvider(p *ProviderData) *AzureProvider { if p.ProfileURL == nil || p.ProfileURL.String() == "" { p.ProfileURL = &url.URL{ - Scheme: "https", - Host: "graph.windows.net", - Path: "/me", - RawQuery: "api-version=1.6", + Scheme: "https", + Host: "graph.microsoft.com", + Path: "/v1.0/me", } } if p.ProtectedResource == nil || p.ProtectedResource.String() == "" { p.ProtectedResource = &url.URL{ Scheme: "https", - Host: "graph.windows.net", + Host: "graph.microsoft.com", } } if p.Scope == "" { p.Scope = "openid" } + if p.ApprovalPrompt == "" || p.ApprovalPrompt == "force" { + p.ApprovalPrompt = "consent" + } + return &AzureProvider{ProviderData: p} } @@ -122,3 +126,66 @@ func (p *AzureProvider) GetEmailAddress(s *SessionState) (string, error) { return email, err } + +func (p *AzureProvider) GetGroups(s *SessionState) (string, error) { + if s.AccessToken == "" { + return "", errors.New("missing access token") + } + + if s.IDToken == "" { + return "", errors.New("missing id token") + } + + // parse token, check if it has groups + // if groups, return "|".join(groups_list) + // else + // look for claim source + // GET source + // parse response + // return "|".join(response.groups_list) + + req, err := http.NewRequest("GET", "https://graph.microsoft.com/v1.0/me/memberOf/", nil) + //req, err := http.NewRequest("POST", "https://graph.microsoft.com/v1.0/me/getMemberGroups", strings.NewReader("{\"securityEnabledOnly\":true}")) + if err != nil { + return "", err + } + req.Header = getAzureHeader(s.AccessToken) + req.Header.Add("Content-Type", "application/json") + + groupData, err := api.Request(req) + + groups := make([]string, 0) + for _, groupInfo := range groupData.Get("value").MustArray() { + //v, ok := groupInfo.(string) + v, ok := groupInfo.(map[string]interface{}) + if !ok { + continue + } + dname := v["displayName"].(string) + secen := v["securityEnabled"].(bool) + mailen := v["mailEnabled"].(bool) + if secen == true && mailen == false { + groups = append(groups, dname) + } + } + + return strings.Join(groups, "|"), nil +} + +func (p *AzureProvider) GetLoginURL(redirectURI, finalRedirect string) string { + var a url.URL + a = *p.LoginURL + params, _ := url.ParseQuery(a.RawQuery) + params.Set("client_id", p.ClientID) + params.Set("response_type", "id_token code") + params.Set("redirect_uri", redirectURI) + params.Set("response_mode", "form_post") + params.Add("scope", p.Scope) + params.Set("prompt", p.ApprovalPrompt) + params.Set("nonce", "FIXME") + if strings.HasPrefix(finalRedirect, "/") { + params.Add("state", finalRedirect) + } + a.RawQuery = params.Encode() + return a.String() +} diff --git a/providers/provider_default.go b/providers/provider_default.go index 1d1daea4..25e27827 100644 --- a/providers/provider_default.go +++ b/providers/provider_default.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io/ioutil" + "log" "net/http" "net/url" @@ -21,7 +22,9 @@ func (p *ProviderData) Redeem(redirectURL, code string) (s *SessionState, err er params := url.Values{} params.Add("redirect_uri", redirectURL) params.Add("client_id", p.ClientID) - params.Add("client_secret", p.ClientSecret) + if p.ClientSecret != "" { + params.Add("client_secret", p.ClientSecret) + } params.Add("code", code) params.Add("grant_type", "authorization_code") if p.ProtectedResource != nil && p.ProtectedResource.String() != "" { @@ -36,16 +39,18 @@ func (p *ProviderData) Redeem(redirectURL, code string) (s *SessionState, err er req.Header.Set("Content-Type", "application/x-www-form-urlencoded") var resp *http.Response - resp, err = http.DefaultClient.Do(req) - if err != nil { - return nil, err - } + resp, c_err := http.DefaultClient.Do(req) var body []byte - body, err = ioutil.ReadAll(resp.Body) + body, b_err := ioutil.ReadAll(resp.Body) resp.Body.Close() - if err != nil { + if b_err != nil { return } + if c_err != nil { + log.Printf("headers from failed redemption are %s", resp.Header) + log.Printf("body from failed redemption is %s", body) + return nil, c_err + } if resp.StatusCode != 200 { err = fmt.Errorf("got %d from %q %s", resp.StatusCode, p.RedeemURL.String(), body) @@ -106,6 +111,10 @@ func (p *ProviderData) GetEmailAddress(s *SessionState) (string, error) { return "", errors.New("not implemented") } +func (p *ProviderData) GetGroups(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 { diff --git a/providers/providers.go b/providers/providers.go index fb2e5fc5..2634d197 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -7,6 +7,7 @@ import ( type Provider interface { Data() *ProviderData GetEmailAddress(*SessionState) (string, error) + GetGroups(*SessionState) (string, error) Redeem(string, string) (*SessionState, error) ValidateGroup(string) bool ValidateSessionState(*SessionState) bool diff --git a/providers/session_state.go b/providers/session_state.go index 80f96a56..922527e9 100644 --- a/providers/session_state.go +++ b/providers/session_state.go @@ -11,10 +11,12 @@ import ( type SessionState struct { AccessToken string + IDToken string ExpiresOn time.Time RefreshToken string Email string User string + Groups string } func (s *SessionState) IsExpired() bool { @@ -35,6 +37,9 @@ func (s *SessionState) String() string { if s.RefreshToken != "" { o += " refresh_token:true" } + if s.Groups != "" { + o += fmt.Sprintf(" groups:%s", s.Groups) + } return o + "}" } @@ -72,7 +77,7 @@ func (s *SessionState) EncryptedString(c *cookie.Cipher) (string, error) { return "", err } } - return fmt.Sprintf("%s:%s:%d:%s", s.userOrEmail(), a, s.ExpiresOn.Unix(), r), nil + return fmt.Sprintf("%s:%s:%d:%s:%s", s.userOrEmail(), a, s.ExpiresOn.Unix(), r, s.Groups), nil } func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) { @@ -85,8 +90,8 @@ func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) return &SessionState{User: v}, nil } - if len(chunks) != 4 { - err = fmt.Errorf("invalid number of fields (got %d expected 4)", len(chunks)) + if len(chunks) != 5 { + err = fmt.Errorf("invalid number of fields (got %d expected 5)", len(chunks)) return } @@ -109,6 +114,9 @@ func DecodeSessionState(v string, c *cookie.Cipher) (s *SessionState, err error) } else { s.User = u } + if chunks[4] != "" { + s.Groups = chunks[4] + } ts, _ := strconv.Atoi(chunks[2]) s.ExpiresOn = time.Unix(int64(ts), 0) return diff --git a/reflect.py b/reflect.py new file mode 100644 index 00000000..434f1c68 --- /dev/null +++ b/reflect.py @@ -0,0 +1,44 @@ +from BaseHTTPServer import BaseHTTPRequestHandler +import urlparse, json + +class GetHandler(BaseHTTPRequestHandler): + + def do_GET(self): + parsed_path = urlparse.urlparse(self.path) + message = '\n'.join([ + 'client_address=%s (%s)' % (self.client_address, + self.address_string()), + 'command=%s' % self.command, + 'path=%s' % self.path, + 'real path=%s' % parsed_path.path, + 'query=%s' % parsed_path.query, + 'request_version=%s' % self.request_version, + 'headers=%s' % self.headers, + '', + 'server_version=%s' % self.server_version, + 'sys_version=%s' % self.sys_version, + 'protocol_version=%s' % self.protocol_version, + '', + ]) + self.send_response(200) + self.end_headers() + self.wfile.write(message) + return + + def do_POST(self): + content_len = int(self.headers.getheader('content-length')) + post_body = self.rfile.read(content_len) + self.send_response(200) + self.end_headers() + + data = json.loads(post_body) + + self.wfile.write(data['foo']) + return + +if __name__ == '__main__': + from BaseHTTPServer import HTTPServer + port = 8081 + server = HTTPServer(('localhost', port), GetHandler) + print 'Starting server at http://localhost:{}'.format(port) + server.serve_forever()