Add support for passing groups from Azure AD

This commit is contained in:
Brandon Matthews 2017-01-11 22:43:47 -08:00
parent 02c70307d6
commit 5e5717ded7
9 changed files with 201 additions and 16 deletions

29
Vagrantfile vendored Normal file
View File

@ -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

View File

@ -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")

View File

@ -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}
}

View File

@ -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 == "" {

View File

@ -8,6 +8,7 @@ import (
"log"
"net/http"
"net/url"
"strings"
)
type AzureProvider struct {
@ -21,21 +22,24 @@ 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",
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()
}

View File

@ -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)
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 {

View File

@ -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

View File

@ -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

44
reflect.py Normal file
View File

@ -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()