Add support for passing groups from Azure AD
This commit is contained in:
parent
02c70307d6
commit
5e5717ded7
|
|
@ -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
|
||||
1
main.go
1
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")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue