diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24acb65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +google_auth_proxy +# Go.gitignore +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/README.md b/README.md index 8e8ef74..94fdcb3 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,19 @@ individual accounts, or a whole google apps domain. ## Usage ``` -./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-secret="": the seed for cookie values - -redirect-url="": the http base to redirect to. ie: https://internalapp.yourcompany.com/oauth2/callback - -htpasswd-file="": additionally lookup basic auth in a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption - -pass-basic-auth=true: pass basic auth information to upstream - -upstream=[]: the http url(s) of the upstream endpoint(s). If multiple, routing is based on URL path -``` \ No newline at end of file + -client-secret="": the OAuth Client Secret + -cookie-domain="": an optional cookie domain to force cookies to + -cookie-secret="": the seed string for secure cookies + -google-apps-domain="": authenticate against the given google apps domain + -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption + -http-address="0.0.0.0:4180": : to listen on for HTTP clients + -pass-basic-auth=true: pass HTTP Basic Auth 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 + -version=false: print version string +``` + +Unauthenticated requests will be redirected to `/oauth2/sign_in` to start the sign-in process. diff --git a/cookies.go b/cookies.go new file mode 100644 index 0000000..1faf480 --- /dev/null +++ b/cookies.go @@ -0,0 +1,50 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func validateCookie(cookie *http.Cookie, seed string) (string, bool) { + // value, timestamp, sig + parts := strings.Split(cookie.Value, "|") + if len(parts) != 3 { + return "", false + } + sig := cookieSignature(seed, cookie.Name, parts[0], parts[1]) + if parts[2] == sig { + ts, err := strconv.Atoi(parts[1]) + if err == nil && int64(ts) > time.Now().Add(time.Duration(24)*7*time.Hour*-1).Unix() { + // it's a valid cookie. now get the contents + rawValue, err := base64.URLEncoding.DecodeString(parts[0]) + if err == nil { + return string(rawValue), true + } + } + } + return "", false +} + +func signedCookieValue(seed string, key string, value string) string { + encodedValue := base64.URLEncoding.EncodeToString([]byte(value)) + timeStr := fmt.Sprintf("%d", time.Now().Unix()) + sig := cookieSignature(seed, key, encodedValue, timeStr) + cookieVal := fmt.Sprintf("%s|%s|%s", encodedValue, timeStr, sig) + return cookieVal +} + +func cookieSignature(args ...string) string { + h := hmac.New(sha1.New, []byte(args[0])) + for _, arg := range args[1:] { + h.Write([]byte(arg)) + } + var b []byte + b = h.Sum(b) + return base64.URLEncoding.EncodeToString(b) +} diff --git a/htpasswd.go b/htpasswd.go new file mode 100644 index 0000000..8525c71 --- /dev/null +++ b/htpasswd.go @@ -0,0 +1,55 @@ +package main + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/csv" + "log" + "os" +) + +// lookup passwords in a htpasswd file +// The entries must have been created with -s for SHA encryption + +type HtpasswdFile struct { + Users map[string]string +} + +func NewHtpasswdFile(path string) *HtpasswdFile { + log.Printf("using htpasswd file %s", path) + r, err := os.Open(path) + if err != nil { + log.Fatalf("failed opening %v, %s", path, err.Error()) + } + csv_reader := csv.NewReader(r) + csv_reader.Comma = ':' + csv_reader.Comment = '#' + csv_reader.TrimLeadingSpace = true + + records, err := csv_reader.ReadAll() + if err != nil { + log.Fatalf("Failed reading file %s", err.Error()) + } + h := &HtpasswdFile{Users: make(map[string]string)} + for _, record := range records { + h.Users[record[0]] = record[1] + } + return h +} + +func (h *HtpasswdFile) Validate(user string, password string) bool { + realPassword, exists := h.Users[user] + if !exists { + return false + } + if realPassword[:5] == "{SHA}" { + d := sha1.New() + d.Write([]byte(password)) + if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) { + return true + } + } else { + log.Printf("Invalid htpasswd entry for %s. Must be a SHA entry.", user) + } + return false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..92726ad --- /dev/null +++ b/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "flag" + "log" + "net" + "net/http" + "net/url" + "strings" + "fmt" +) + +const VERSION = "0.0.1" + +var ( + showVersion = flag.Bool("version", false, "print version string") + httpAddr = flag.String("http-address", "0.0.0.0: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") + googleAppsDomain = flag.String("google-apps-domain", "", "authenticate against the given google apps domain") + authenticatedEmailsFile = flag.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)") + upstreams = StringArray{} +) + +func init() { + flag.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path") +} + +func main() { + flag.Parse() + + if *showVersion { + fmt.Printf("google_auth_proxy v%s\n", VERSION) + 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) + if err != nil { + log.Fatalf("error parsing --redirect-url %s", err.Error()) + } + + validator := NewValidator(*googleAppsDomain, *authenticatedEmailsFile) + oauthproxy := NewOauthProxy(upstreamUrls, *clientID, *clientSecret, validator) + oauthproxy.SetRedirectUrl(redirectUrl) + if *googleAppsDomain != "" && *authenticatedEmailsFile == "" { + oauthproxy.SignInMessage = fmt.Sprintf("using a %s email address", *googleAppsDomain) + } + if *htpasswdFile != "" { + oauthproxy.HtpasswdFile = NewHtpasswdFile(*htpasswdFile) + } + listener, err := net.Listen("tcp", *httpAddr) + if err != nil { + log.Fatalf("FATAL: listen (%s) failed - %s", *httpAddr, err.Error()) + } + log.Printf("listening on %s", *httpAddr) + + 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("HTTP: closing %s", listener.Addr().String()) +} diff --git a/oauthproxy.go b/oauthproxy.go new file mode 100644 index 0000000..f96adc3 --- /dev/null +++ b/oauthproxy.go @@ -0,0 +1,302 @@ +package main + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "github.com/bitly/go-simplejson" + "io/ioutil" + "log" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +const signInPath = "/oauth2/sign_in" +const oauthStartPath = "/oauth2/start" +const oauthCallbackPath = "/oauth2/callback" + +type OauthProxy struct { + CookieSeed string + CookieKey string + Validator func(string) bool + + redirectUrl *url.URL // the url to receive requests at + oauthRedemptionUrl *url.URL // endpoint to redeem the code + oauthLoginUrl *url.URL // to redirect the user to + oauthUserInfoUrl *url.URL + oauthScope string + clientID string + clientSecret string + SignInMessage string + HtpasswdFile *HtpasswdFile + serveMux *http.ServeMux +} + +func NewOauthProxy(proxyUrls []*url.URL, clientID string, clientSecret string, 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") + info, _ := url.Parse("https://www.googleapis.com/oauth2/v2/userinfo") + serveMux := http.NewServeMux() + for _, u := range proxyUrls { + path := u.Path + u.Path = "" + log.Printf("mapping %s => %s", path, u) + serveMux.Handle(path, httputil.NewSingleHostReverseProxy(u)) + } + return &OauthProxy{ + CookieKey: "_oauthproxy", + CookieSeed: *cookieSecret, + Validator: validator, + + clientID: clientID, + clientSecret: clientSecret, + oauthScope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", + oauthRedemptionUrl: redeem, + oauthLoginUrl: login, + oauthUserInfoUrl: info, + serveMux: serveMux, + } +} + +func (p *OauthProxy) SetRedirectUrl(redirectUrl *url.URL) { + redirectUrl.Path = oauthCallbackPath + p.redirectUrl = redirectUrl +} + +func (p *OauthProxy) GetLoginURL() string { + params := url.Values{} + params.Add("redirect_uri", p.redirectUrl.String()) + params.Add("approval_prompt", "force") + params.Add("scope", p.oauthScope) + params.Add("client_id", p.clientID) + params.Add("response_type", "code") + return fmt.Sprintf("%s?%s", p.oauthLoginUrl, params.Encode()) +} + +func apiRequest(req *http.Request) (*simplejson.Json, error) { + httpclient := &http.Client{} + resp, err := httpclient.Do(req) + if err != nil { + return nil, err + } + body, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + log.Printf("got response code %d - %s", resp.StatusCode, body) + return nil, errors.New("api request returned 200 status code") + } + data, err := simplejson.NewJson(body) + if err != nil { + return nil, err + } + return data, nil +} + +func (p *OauthProxy) redeemCode(code string) (string, error) { + + params := url.Values{} + params.Add("redirect_uri", p.redirectUrl.String()) + params.Add("client_id", p.clientID) + params.Add("client_secret", p.clientSecret) + params.Add("code", code) + params.Add("grant_type", "authorization_code") + log.Printf("body is %s", params.Encode()) + req, err := http.NewRequest("POST", p.oauthRedemptionUrl.String(), bytes.NewBufferString(params.Encode())) + if err != nil { + log.Printf("failed building request %s", err.Error()) + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + json, err := apiRequest(req) + if err != nil { + log.Printf("failed making request %s", err.Error()) + return "", err + } + access_token, err := json.Get("access_token").String() + if err != nil { + return "", err + } + return access_token, nil +} +func (p *OauthProxy) getUserInfo(token string) (string, error) { + params := url.Values{} + params.Add("access_token", token) + endpoint := fmt.Sprintf("%s?%s", p.oauthUserInfoUrl.String(), params.Encode()) + log.Printf("calling %s", endpoint) + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + log.Printf("failed building request %s", err.Error()) + return "", err + } + json, err := apiRequest(req) + if err != nil { + log.Printf("failed making request %s", err.Error()) + return "", err + } + email, err := json.Get("email").String() + if err != nil { + log.Printf("failed getting email from response %s", err.Error()) + return "", err + } + return email, nil +} + +func ClearCookie(rw http.ResponseWriter, req *http.Request, key string) { + domain := strings.Split(req.Host, ":")[0] + if *cookieDomain != "" { + domain = *cookieDomain + } + cookie := &http.Cookie{ + Name: key, + Value: "", + Path: "/", + Domain: domain, + Expires: time.Now().Add(time.Duration(1) * time.Hour * -1), + HttpOnly: true, + } + http.SetCookie(rw, cookie) +} + +func ErrorPage(rw http.ResponseWriter, code int, title string, message string, signinmessage string) { + log.Printf("ErrorPage %d %s %s %s", code, title, message, signinmessage) + rw.WriteHeader(code) + t := getTemplates() + p := struct { + Title string + Message string + SignInMessage string + }{ + Title: fmt.Sprintf("%d %s", code, title), + Message: message, + SignInMessage: signinmessage, + } + t.ExecuteTemplate(rw, "error.html", p) +} + +func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + // check if this is a redirect back at the end of oauth + if req.URL.Path == signInPath { + ClearCookie(rw, req, p.CookieKey) + t := getTemplates() + p := struct{ SignInMessage string }{SignInMessage: p.SignInMessage} + t.ExecuteTemplate(rw, "sign_in.html", p) + return + } + if req.URL.Path == oauthStartPath { + http.Redirect(rw, req, p.GetLoginURL(), 302) + return + } + if req.URL.Path == oauthCallbackPath { + // finish the oauth cycle + reqParams, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage) + return + } + errorString, ok := reqParams["error"] + if ok && len(errorString) == 1 { + ErrorPage(rw, 403, "Permission Denied", errorString[0], p.SignInMessage) + return + } + code, ok := reqParams["code"] + if !ok || len(code) != 1 { + ErrorPage(rw, 500, "Internal Error", "Invalid API response", p.SignInMessage) + return + } + + token, err := p.redeemCode(code[0]) + if err != nil { + log.Printf("error redeeming code %s", err.Error()) + ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage) + return + } + // validate user + email, err := p.getUserInfo(token) + if err != nil { + log.Printf("error redeeming code %s", err.Error()) + ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage) + return + } + + // set cookie, or deny + if p.Validator(email) { + log.Printf("authenticating %s completed", email) + domain := strings.Split(req.Host, ":")[0] + if *cookieDomain != "" { + domain = *cookieDomain + } + + cookie := &http.Cookie{ + Name: p.CookieKey, + Value: signedCookieValue(p.CookieSeed, p.CookieKey, email), + Path: "/", + Domain: domain, + Expires: time.Now().Add(time.Duration(168) * time.Hour), // 7 days + HttpOnly: true, + // Secure: req. ... ? set if X-Scheme: https ? + } + http.SetCookie(rw, cookie) + http.Redirect(rw, req, "/", 302) + return + } else { + ErrorPage(rw, 403, "Permission Denied", "Invalid Account", p.SignInMessage) + return + } + } + cookie, err := req.Cookie(p.CookieKey) + var ok bool + var email string + var user string + if err == nil { + email, ok = validateCookie(cookie, p.CookieSeed) + user = strings.Split(email, "@")[0] + } + + if !ok { + user, ok = p.CheckBasicAuth(req) + } + + if !ok { + log.Printf("invalid cookie. redirecting to sign in") + // TODO: capture state for which url to redirect to at the end + http.Redirect(rw, req, "/oauth2/sign_in", 302) + return + } + + // At this point, the user is authenticated. proxy normally + if *passBasicAuth { + req.SetBasicAuth(user, "") + req.Header["X-Forwarded-User"] = []string{user} + } + + p.serveMux.ServeHTTP(rw, req) +} + +func (p *OauthProxy) CheckBasicAuth(req *http.Request) (string, bool) { + if p.HtpasswdFile == nil { + return "", false + } + s := strings.SplitN(req.Header.Get("Authorization"), " ", 2) + if len(s) != 2 || s[0] != "Basic" { + return "", false + } + b, err := base64.StdEncoding.DecodeString(s[1]) + if err != nil { + return "", false + } + pair := strings.SplitN(string(b), ":", 2) + if len(pair) != 2 { + return "", false + } + if p.HtpasswdFile.Validate(pair[0], pair[1]) { + return pair[0], true + } + return "", false +} diff --git a/string_array.go b/string_array.go new file mode 100644 index 0000000..2369196 --- /dev/null +++ b/string_array.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" +) + +type StringArray []string + +func (a *StringArray) Set(s string) error { + *a = append(*a, s) + return nil +} + +func (a *StringArray) String() string { + return fmt.Sprint(*a) +} diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..b76e30b --- /dev/null +++ b/templates.go @@ -0,0 +1,37 @@ +package main + +import ( + "html/template" + "log" +) + +func getTemplates() *template.Template { + t, err := template.New("foo").Parse(`{{define "sign_in.html"}} +Sign In + +
+ + {{.SignInMessage}} +
+ +{{end}}`) + if err != nil { + log.Fatalf("failed parsing template %s", err.Error()) + } + t, err = t.Parse(`{{define "error.html"}} +{{.Title}} + +

{{.Title}}

+

{{.Message}}

+
+
+ + {{.SignInMessage}} +
+ +{{end}}`) + if err != nil { + log.Fatalf("failed parsing template %s", err.Error()) + } + return t +} diff --git a/validator.go b/validator.go new file mode 100644 index 0000000..178ac50 --- /dev/null +++ b/validator.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "log" + "encoding/csv" + "fmt" + "strings" +) + +func NewValidator(domain string, usersFile string) func(string) bool { + + validUsers := make(map[string]bool) + emailSuffix := "" + if domain != "" { + emailSuffix = fmt.Sprintf("@%s", domain) + } + + if usersFile != "" { + r, err := os.Open(usersFile) + if err != nil { + log.Fatalf("failed opening -authenticated-emails-file=%v, %s", usersFile, err.Error()) + } + csv_reader := csv.NewReader(r) + csv_reader.Comma = ',' + csv_reader.Comment = '#' + csv_reader.TrimLeadingSpace = true + records, err := csv_reader.ReadAll() + for _, r := range records { + validUsers[r[0]] = true + } + } + + validator := func(email string) bool { + var valid bool + if emailSuffix != "" { + valid = strings.HasSuffix(email, emailSuffix) + } + if !valid { + _, valid = validUsers[email] + } + log.Printf("validating: is %s valid? %v", email, valid) + return valid + } + return validator +}