From d4fe9a4f57c365aafeae4311dfa28549bf60005c Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Sun, 9 Nov 2014 14:51:10 -0500 Subject: [PATCH] Add config file support --- .travis.yml | 2 + README.md | 33 ++++--- contrib/google_auth_proxy.cfg.example | 44 +++++++++ main.go | 136 +++++++++++++------------- oauthproxy.go | 72 +++++++------- options.go | 67 +++++++++++++ string_array.go | 4 +- version.go | 3 + 8 files changed, 243 insertions(+), 118 deletions(-) create mode 100644 contrib/google_auth_proxy.cfg.example create mode 100644 options.go create mode 100644 version.go diff --git a/.travis.yml b/.travis.yml index faf97b8..468e23c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,8 @@ language: go install: - go get github.com/bmizerany/assert - go get github.com/bitly/go-simplejson + - go get github.com/mreiferson/go-options + - go get github.com/BurntSushi/toml notifications: email: false diff --git a/README.md b/README.md index 0f558e9..63ec13c 100644 --- a/README.md +++ b/README.md @@ -42,28 +42,40 @@ intend to run `google_auth_proxy` on. 5. Take note of the **Client ID** and **Client Secret** -## Command Line Options +## Configuration + +`google_auth_proxy` can be configured via [config file](#config-file), [command line options](#command-line-options) or [environment variables](#environment-variables). + +### Config File + +An example [google_auth_proxy.cfg](contrib/google_auth_proxy.cfg.example) config file is in the contrib directory. It can be used by specifying `-config=/etc/google_auth_proxy.cfg` + +### Command Line Options ``` -Usage of ./google_auth_proxy: +Usage of google_auth_proxy: -authenticated-emails-file="": authenticate against emails via file (one per line) -client-id="": the Google OAuth Client ID: ie: "123456.apps.googleusercontent.com" -client-secret="": the OAuth Client Secret - -cookie-domain="": an optional cookie domain to force cookies to - -cookie-expire=168h: expire timeframe for cookie + -config="": path to config file + -cookie-domain="": an optional cookie domain to force cookies to (ie: .yourcompany.com) + -cookie-expire=168h0m0s: expire timeframe for cookie -cookie-https-only=false: set HTTPS only cookie -cookie-secret="": the seed string for secure cookies - -google-apps-domain=[]: authenticate against the given google apps domain (may be given multiple times) + -google-apps-domain=: authenticate against the given Google apps domain (may be given multiple times) -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": : to listen on for HTTP clients - -pass-basic-auth=true: pass HTTP Basic Auth information to upstream + -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream -redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback" - -upstream=[]: the http url(s) of the upstream endpoint. If multiple, routing is based on path + -upstream=: the http url(s) of the upstream endpoint. If multiple, routing is based on path -version=false: print version string ``` +### Environment variables -## Example Configuration +The environment variables `google_auth_client_id`, `google_auth_secret` and `google_auth_cookie_secret` can be used in place of the corresponding command-line arguments. + +### Example Nginx Configuration This example has a [Nginx](http://nginx.org/) SSL endpoint proxying to `google_auth_proxy` on port `4180`. `google_auth_proxy` then authenticates requests for an upstream application running on port `8080`. The external @@ -105,13 +117,10 @@ The command line to run `google_auth_proxy` would look like this: --client-secret=... ``` -## Environment variables - -The environment variables `google_auth_client_id`, `google_auth_secret` and `google_auth_cookie_secret` can be used in place of the corresponding command-line arguments. ## Endpoint Documentation -Google Auth Proxy responds directly to the following endpoints. All other endpoints will be authenticated. +Google Auth Proxy responds directly to the following endpoints. All other endpoints will be proxied upstream when authenticated. * /ping - returns an 200 OK response * /oauth2/sign_in - the login page, which also doubles as a sign out page (it clears cookies) diff --git a/contrib/google_auth_proxy.cfg.example b/contrib/google_auth_proxy.cfg.example new file mode 100644 index 0000000..fc7f883 --- /dev/null +++ b/contrib/google_auth_proxy.cfg.example @@ -0,0 +1,44 @@ +## Google Auth Proxy Config File +## https://github.com/bitly/google_auth_proxy + +## : to listen on for HTTP clients +# http_address = "127.0.0.1:4180" + +## the OAuth Redirect URL. +# redirect_url = "https://internalapp.yourcompany.com/oauth2/callback" + +## the http url(s) of the upstream endpoint. If multiple, routing is based on path +# upstreams = [ +# "http://127.0.0.1:8080/" +# ] + +## pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream +# pass_basic_auth = true + +## Google Apps Domains to allow authentication for +# google_apps_domains = [ +# "yourcompany.com" +# ] + + +## The Google OAuth Client ID, Secret +# client_id = "123456.apps.googleusercontent.com" +# client_secret = "" + +## Authenticated Email Addresses File (one email per line) +# authenticated_emails_file = "" + +## Htpasswd File (optional) +## Additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption +## enabling exposes a username/login signin form +# htpasswd_file = "" + + +## Cookie Settings +## Secret - the seed string for secure cookies +## Domain - optional cookie domain to force cookies to (ie: .yourcompany.com) +## Expire - expire timeframe for cookie +# cookie_secret = "" +# cookie_domain = "" +# cookie_expire = "168h" +# cookie_https_only = false diff --git a/main.go b/main.go index 5cf72df..6d3f052 100644 --- a/main.go +++ b/main.go @@ -6,49 +6,63 @@ import ( "log" "net" "net/http" - "net/url" "os" "strings" "time" + + "github.com/BurntSushi/toml" + "github.com/mreiferson/go-options" ) -const VERSION = "0.1.0" - -var ( - showVersion = flag.Bool("version", false, "print version string") - httpAddr = flag.String("http-address", "127.0.0.1:4180", ": to listen on for HTTP clients") - redirectUrl = flag.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"") - clientID = flag.String("client-id", "", "the Google OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"") - clientSecret = flag.String("client-secret", "", "the OAuth Client Secret") - passBasicAuth = flag.Bool("pass-basic-auth", true, "pass HTTP Basic Auth information to upstream") - htpasswdFile = flag.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption") - cookieSecret = flag.String("cookie-secret", "", "the seed string for secure cookies") - cookieDomain = flag.String("cookie-domain", "", "an optional cookie domain to force cookies to") - cookieExpire = flag.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie") - cookieHttpsOnly = flag.Bool("cookie-https-only", false, "set HTTPS only cookie") - authenticatedEmailsFile = flag.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)") - googleAppsDomains = StringArray{} - upstreams = StringArray{} -) - -func init() { - flag.Var(&googleAppsDomains, "google-apps-domain", "authenticate against the given google apps domain (may be given multiple times)") - flag.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path") -} - func main() { + flagSet := flag.NewFlagSet("google_auth_proxy", flag.ExitOnError) - flag.Parse() + googleAppsDomains := StringArray{} + upstreams := StringArray{} + + config := flagSet.String("config", "", "path to config file") + showVersion := flagSet.Bool("version", false, "print version string") + + flagSet.String("http-address", "127.0.0.1:4180", ": to listen on for HTTP clients") + flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"") + flagSet.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path") + flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream") + + flagSet.Var(&googleAppsDomains, "google-apps-domain", "authenticate against the given Google apps domain (may be given multiple times)") + flagSet.String("client-id", "", "the Google 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)") + flagSet.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption") + + flagSet.String("cookie-secret", "", "the seed string for secure cookies") + flagSet.String("cookie-domain", "", "an optional cookie domain to force cookies to (ie: .yourcompany.com)") + flagSet.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie") + flagSet.Bool("cookie-https-only", false, "set HTTPS only cookie") + + flagSet.Parse(os.Args[1:]) + + opts := NewOptions() + + var cfg map[string]interface{} + if *config != "" { + _, err := toml.DecodeFile(*config, &cfg) + if err != nil { + log.Fatalf("ERROR: failed to load config file %s - %s", *config, err) + } + } + + options.Resolve(opts, flagSet, cfg) // Try to use env for secrets if no flag is set - if *clientID == "" { - *clientID = os.Getenv("google_auth_client_id") + // TODO: better parsing of these values + if opts.ClientID == "" { + opts.ClientID = os.Getenv("google_auth_client_id") } - if *clientSecret == "" { - *clientSecret = os.Getenv("google_auth_secret") + if opts.ClientSecret == "" { + opts.ClientSecret = os.Getenv("google_auth_secret") } - if *cookieSecret == "" { - *cookieSecret = os.Getenv("google_auth_cookie_secret") + if opts.CookieSecret == "" { + opts.CookieSecret = os.Getenv("google_auth_cookie_secret") } if *showVersion { @@ -56,59 +70,41 @@ func main() { return } - if len(upstreams) < 1 { - log.Fatal("missing --upstream") - } - if *cookieSecret == "" { - log.Fatal("missing --cookie-secret") - } - if *clientID == "" { - log.Fatal("missing --client-id") - } - if *clientSecret == "" { - log.Fatal("missing --client-secret") - } - - var upstreamUrls []*url.URL - for _, u := range upstreams { - upstreamUrl, err := url.Parse(u) - if err != nil { - log.Fatalf("error parsing --upstream %s", err.Error()) - } - upstreamUrls = append(upstreamUrls, upstreamUrl) - } - redirectUrl, err := url.Parse(*redirectUrl) + err := opts.Validate() if err != nil { - log.Fatalf("error parsing --redirect-url %s", err.Error()) + log.Printf("%s", err) + os.Exit(1) } - validator := NewValidator(googleAppsDomains, *authenticatedEmailsFile) - oauthproxy := NewOauthProxy(upstreamUrls, *clientID, *clientSecret, validator) - oauthproxy.SetRedirectUrl(redirectUrl) - if len(googleAppsDomains) != 0 && *authenticatedEmailsFile == "" { - if len(googleAppsDomains) > 1 { - oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using one of the following domains: %v", strings.Join(googleAppsDomains, ", ")) + validator := NewValidator(opts.GoogleAppsDomains, opts.AuthenticatedEmailsFile) + oauthproxy := NewOauthProxy(opts, validator) + + if len(opts.GoogleAppsDomains) != 0 && opts.AuthenticatedEmailsFile == "" { + if len(opts.GoogleAppsDomains) > 1 { + oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using one of the following domains: %v", strings.Join(opts.GoogleAppsDomains, ", ")) } else { - oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using %v", googleAppsDomains[0]) + oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using %v", opts.GoogleAppsDomains[0]) } } - if *htpasswdFile != "" { - oauthproxy.HtpasswdFile, err = NewHtpasswdFromFile(*htpasswdFile) + + if opts.HtpasswdFile != "" { + oauthproxy.HtpasswdFile, err = NewHtpasswdFromFile(opts.HtpasswdFile) if err != nil { - log.Fatalf("FATAL: unable to open %s %s", *htpasswdFile, err.Error()) + log.Fatalf("FATAL: unable to open %s %s", opts.HtpasswdFile, err) } } - listener, err := net.Listen("tcp", *httpAddr) + + listener, err := net.Listen("tcp", opts.HttpAddress) if err != nil { - log.Fatalf("FATAL: listen (%s) failed - %s", *httpAddr, err.Error()) + log.Fatalf("FATAL: listen (%s) failed - %s", opts.HttpAddress, err) } - log.Printf("listening on %s", *httpAddr) + log.Printf("listening on %s", opts.HttpAddress) server := &http.Server{Handler: oauthproxy} err = server.Serve(listener) if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { - log.Printf("ERROR: http.Serve() - %s", err.Error()) + log.Printf("ERROR: http.Serve() - %s", err) } - log.Printf("HTTP: closing %s", listener.Addr().String()) + log.Printf("HTTP: closing %s", listener.Addr()) } diff --git a/oauthproxy.go b/oauthproxy.go index 859fdb2..e726780 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -22,9 +22,12 @@ const oauthStartPath = "/oauth2/start" const oauthCallbackPath = "/oauth2/callback" type OauthProxy struct { - CookieSeed string - CookieKey string - Validator func(string) bool + CookieSeed string + CookieKey string + CookieDomain string + CookieHttpsOnly bool + CookieExpire time.Duration + Validator func(string) bool redirectUrl *url.URL // the url to receive requests at oauthRedemptionUrl *url.URL // endpoint to redeem the code @@ -35,40 +38,41 @@ type OauthProxy struct { SignInMessage string HtpasswdFile *HtpasswdFile serveMux *http.ServeMux + PassBasicAuth bool } -func NewOauthProxy(proxyUrls []*url.URL, clientID string, clientSecret string, validator func(string) bool) *OauthProxy { +func NewOauthProxy(opts *Options, validator func(string) bool) *OauthProxy { login, _ := url.Parse("https://accounts.google.com/o/oauth2/auth") redeem, _ := url.Parse("https://accounts.google.com/o/oauth2/token") serveMux := http.NewServeMux() - for _, u := range proxyUrls { + for _, u := range opts.proxyUrls { path := u.Path - if len(path) == 0 { - path = "/" - } u.Path = "" - log.Printf("mapping %s => %s", path, u) + log.Printf("mapping path %q => upstream %q", path, u) serveMux.Handle(path, httputil.NewSingleHostReverseProxy(u)) } - return &OauthProxy{ - CookieKey: "_oauthproxy", - CookieSeed: *cookieSecret, - Validator: validator, + redirectUrl := opts.redirectUrl + redirectUrl.Path = oauthCallbackPath - clientID: clientID, - clientSecret: clientSecret, + return &OauthProxy{ + CookieKey: "_oauthproxy", + CookieSeed: opts.CookieSecret, + CookieDomain: opts.CookieDomain, + CookieHttpsOnly: opts.CookieHttpsOnly, + CookieExpire: opts.CookieExpire, + Validator: validator, + + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, oauthScope: "profile email", oauthRedemptionUrl: redeem, oauthLoginUrl: login, serveMux: serveMux, + redirectUrl: redirectUrl, + PassBasicAuth: opts.PassBasicAuth, } } -func (p *OauthProxy) SetRedirectUrl(redirectUrl *url.URL) { - redirectUrl.Path = oauthCallbackPath - p.redirectUrl = redirectUrl -} - func (p *OauthProxy) GetLoginURL(redirectUrl string) string { params := url.Values{} params.Add("redirect_uri", p.redirectUrl.String()) @@ -164,8 +168,8 @@ func jwtDecodeSegment(seg string) ([]byte, error) { func (p *OauthProxy) ClearCookie(rw http.ResponseWriter, req *http.Request) { domain := strings.Split(req.Host, ":")[0] - if *cookieDomain != "" && strings.HasSuffix(domain, *cookieDomain) { - domain = *cookieDomain + if p.CookieDomain != "" && strings.HasSuffix(domain, p.CookieDomain) { + domain = p.CookieDomain } cookie := &http.Cookie{ Name: p.CookieKey, @@ -181,8 +185,8 @@ func (p *OauthProxy) ClearCookie(rw http.ResponseWriter, req *http.Request) { func (p *OauthProxy) SetCookie(rw http.ResponseWriter, req *http.Request, val string) { domain := strings.Split(req.Host, ":")[0] // strip the port (if any) - if *cookieDomain != "" && strings.HasSuffix(domain, *cookieDomain) { - domain = *cookieDomain + if p.CookieDomain != "" && strings.HasSuffix(domain, p.CookieDomain) { + domain = p.CookieDomain } cookie := &http.Cookie{ Name: p.CookieKey, @@ -190,8 +194,8 @@ func (p *OauthProxy) SetCookie(rw http.ResponseWriter, req *http.Request, val st Path: "/", Domain: domain, HttpOnly: true, - Secure: *cookieHttpsOnly, - Expires: time.Now().Add(*cookieExpire), + Secure: p.CookieHttpsOnly, + Expires: time.Now().Add(p.CookieExpire), } http.SetCookie(rw, cookie) } @@ -267,11 +271,11 @@ func (p *OauthProxy) GetRedirect(req *http.Request) (string, error) { func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // check if this is a redirect back at the end of oauth - remoteIP := req.Header.Get("X-Real-IP") - if remoteIP == "" { - remoteIP = req.RemoteAddr + remoteAddr := req.RemoteAddr + if req.Header.Get("X-Real-IP") != "" { + remoteAddr += fmt.Sprintf(" (%q)", req.Header.Get("X-Real-IP")) } - log.Printf("%s %s %s", remoteIP, req.Method, req.URL.Path) + log.Printf("%s %s %s", remoteAddr, req.Method, req.URL.RequestURI()) var ok bool var user string @@ -322,7 +326,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { _, email, err := p.redeemCode(req.Form.Get("code")) if err != nil { - log.Printf("error redeeming code %s", err) + log.Printf("%s error redeeming code %s", remoteAddr, err) p.ErrorPage(rw, 500, "Internal Error", err.Error()) return } @@ -334,7 +338,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // set cookie, or deny if p.Validator(email) { - log.Printf("authenticating %s completed", email) + log.Printf("%s authenticating %s completed", remoteAddr, email) p.SetCookie(rw, req, email) http.Redirect(rw, req, redirect, 302) return @@ -362,13 +366,13 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } if !ok { - log.Printf("invalid cookie") + log.Printf("%s - invalid cookie session", remoteAddr) p.SignInPage(rw, req, 403) return } // At this point, the user is authenticated. proxy normally - if *passBasicAuth { + if p.PassBasicAuth { req.SetBasicAuth(user, "") req.Header["X-Forwarded-User"] = []string{user} req.Header["X-Forwarded-Email"] = []string{email} diff --git a/options.go b/options.go new file mode 100644 index 0000000..c2a7b21 --- /dev/null +++ b/options.go @@ -0,0 +1,67 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +// Configuration Options that can be set by Command Line Flag, or Config File +type Options struct { + HttpAddress string `flag:"http-address" cfg:"http_address"` + RedirectUrl string `flag:"redirect-url" cfg:"redirect_url"` + ClientID string `flag:"client-id" cfg:"client_id"` + ClientSecret string `flag:"client-secret" cfg:"client_secret"` + PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"` + HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"` + CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret"` + CookieDomain string `flag:"cookie-domain" cfg:"cookie_domain"` + CookieExpire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"` + CookieHttpsOnly bool `flag:"cookie-https-only" cfg:"cookie_https_only"` + AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` + GoogleAppsDomains []string `flag:"google-apps-domain" cfg:"google_apps_domains"` + Upstreams []string `flag:"upstream" cfg:"upstreams"` + + // internal values that are set after config validation + redirectUrl *url.URL + proxyUrls []*url.URL +} + +func NewOptions() *Options { + return &Options{} +} + +func (o *Options) Validate() error { + if len(o.Upstreams) < 1 { + return errors.New("missing -upstream") + } + if o.CookieSecret == "" { + errors.New("missing -cookie-secret") + } + if o.ClientID == "" { + return errors.New("missing -client-id") + } + if o.ClientSecret == "" { + return errors.New("missing -client-secret") + } + + redirectUrl, err := url.Parse(o.RedirectUrl) + if err != nil { + return fmt.Errorf("error parsing -redirect-url=%q %s", o.RedirectUrl, err) + } + o.redirectUrl = redirectUrl + + for _, u := range o.Upstreams { + upstreamUrl, err := url.Parse(u) + if err != nil { + return fmt.Errorf("error parsing -upstream=%q %s", upstreamUrl, err) + } + if upstreamUrl.Path == "" { + upstreamUrl.Path = "/" + } + o.proxyUrls = append(o.proxyUrls, upstreamUrl) + } + + return nil +} diff --git a/string_array.go b/string_array.go index 2369196..10f0ce3 100644 --- a/string_array.go +++ b/string_array.go @@ -1,7 +1,7 @@ package main import ( - "fmt" + "strings" ) type StringArray []string @@ -12,5 +12,5 @@ func (a *StringArray) Set(s string) error { } func (a *StringArray) String() string { - return fmt.Sprint(*a) + return strings.Join(*a, ",") } diff --git a/version.go b/version.go new file mode 100644 index 0000000..a44ae0e --- /dev/null +++ b/version.go @@ -0,0 +1,3 @@ +package main + +const VERSION = "0.1.0"