git.iko.soy/typeable/oauth2-proxy
on master help sign in
initial code import
commitfb636396a303620cc4e7426ee1137dfd0e487653 authorJehiah Czebotar <jehiah@gmail.com> dateMon 10 Dec 2012, 20:59 (14 years ago) parentc6f07d5
9 files changed+635−8
.gitignore+24 README.md+15−8 cookies.go+50 htpasswd.go+55 main.go+90 oauthproxy.go+302 string_array.go+16 templates.go+37 validator.go+46
.gitignorenew file+24
@@ -0,0 +1,24 @@
1+google_auth_proxy
2+# Go.gitignore
3+# Compiled Object files, Static and Dynamic libs (Shared Objects)
4+*.o
5+*.a
6+*.so
7+
8+# Folders
9+_obj
10+_test
11+
12+# Architecture specific extensions/prefixes
13+*.[568vq]
14+[568vq].out
15+
16+*.cgo1.go
17+*.cgo2.c
18+_cgo_defun.c
19+_cgo_gotypes.go
20+_cgo_export.*
21+
22+_testmain.go
23+
24+*.exe
README.md+15−8
@@ -28,12 +28,19 @@ individual accounts, or a whole google apps domain.
2828## Usage
2929
3030```
31−./google_auth_proxy
31+Usage of ./google_auth_proxy:
32+ -authenticated-emails-file="": authenticate against emails via file (one per line)
3233 -client-id="": the Google OAuth Client ID: ie: "123456.apps.googleusercontent.com"
33− -client-secret="": the OAuth Client secret
34− -cookie-secret="": the seed for cookie values
35− -redirect-url="": the http base to redirect to. ie: https://internalapp.yourcompany.com/oauth2/callback
36− -htpasswd-file="": additionally lookup basic auth in a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
37− -pass-basic-auth=true: pass basic auth information to upstream
38− -upstream=[]: the http url(s) of the upstream endpoint(s). If multiple, routing is based on URL path
39−```
34+ -client-secret="": the OAuth Client Secret
35+ -cookie-domain="": an optional cookie domain to force cookies to
36+ -cookie-secret="": the seed string for secure cookies
37+ -google-apps-domain="": authenticate against the given google apps domain
38+ -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
39+ -http-address="0.0.0.0:4180": <addr>:<port> to listen on for HTTP clients
40+ -pass-basic-auth=true: pass HTTP Basic Auth information to upstream
41+ -redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback"
42+ -upstream=[]: the http url(s) of the upstream endpoint. If multiple, routing is based on path
43+ -version=false: print version string
44+```
45+
46+Unauthenticated requests will be redirected to `/oauth2/sign_in` to start the sign-in process.
cookies.gonew file+50
@@ -0,0 +1,50 @@
1+package main
2+
3+import (
4+ "crypto/hmac"
5+ "crypto/sha1"
6+ "encoding/base64"
7+ "fmt"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+)
13+
14+func validateCookie(cookie *http.Cookie, seed string) (string, bool) {
15+ // value, timestamp, sig
16+ parts := strings.Split(cookie.Value, "|")
17+ if len(parts) != 3 {
18+ return "", false
19+ }
20+ sig := cookieSignature(seed, cookie.Name, parts[0], parts[1])
21+ if parts[2] == sig {
22+ ts, err := strconv.Atoi(parts[1])
23+ if err == nil && int64(ts) > time.Now().Add(time.Duration(24)*7*time.Hour*-1).Unix() {
24+ // it's a valid cookie. now get the contents
25+ rawValue, err := base64.URLEncoding.DecodeString(parts[0])
26+ if err == nil {
27+ return string(rawValue), true
28+ }
29+ }
30+ }
31+ return "", false
32+}
33+
34+func signedCookieValue(seed string, key string, value string) string {
35+ encodedValue := base64.URLEncoding.EncodeToString([]byte(value))
36+ timeStr := fmt.Sprintf("%d", time.Now().Unix())
37+ sig := cookieSignature(seed, key, encodedValue, timeStr)
38+ cookieVal := fmt.Sprintf("%s|%s|%s", encodedValue, timeStr, sig)
39+ return cookieVal
40+}
41+
42+func cookieSignature(args ...string) string {
43+ h := hmac.New(sha1.New, []byte(args[0]))
44+ for _, arg := range args[1:] {
45+ h.Write([]byte(arg))
46+ }
47+ var b []byte
48+ b = h.Sum(b)
49+ return base64.URLEncoding.EncodeToString(b)
50+}
htpasswd.gonew file+55
@@ -0,0 +1,55 @@
1+package main
2+
3+import (
4+ "crypto/sha1"
5+ "encoding/base64"
6+ "encoding/csv"
7+ "log"
8+ "os"
9+)
10+
11+// lookup passwords in a htpasswd file
12+// The entries must have been created with -s for SHA encryption
13+
14+type HtpasswdFile struct {
15+ Users map[string]string
16+}
17+
18+func NewHtpasswdFile(path string) *HtpasswdFile {
19+ log.Printf("using htpasswd file %s", path)
20+ r, err := os.Open(path)
21+ if err != nil {
22+ log.Fatalf("failed opening %v, %s", path, err.Error())
23+ }
24+ csv_reader := csv.NewReader(r)
25+ csv_reader.Comma = ':'
26+ csv_reader.Comment = '#'
27+ csv_reader.TrimLeadingSpace = true
28+
29+ records, err := csv_reader.ReadAll()
30+ if err != nil {
31+ log.Fatalf("Failed reading file %s", err.Error())
32+ }
33+ h := &HtpasswdFile{Users: make(map[string]string)}
34+ for _, record := range records {
35+ h.Users[record[0]] = record[1]
36+ }
37+ return h
38+}
39+
40+func (h *HtpasswdFile) Validate(user string, password string) bool {
41+ realPassword, exists := h.Users[user]
42+ if !exists {
43+ return false
44+ }
45+ if realPassword[:5] == "{SHA}" {
46+ d := sha1.New()
47+ d.Write([]byte(password))
48+ if realPassword[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
49+ return true
50+ }
51+ } else {
52+ log.Printf("Invalid htpasswd entry for %s. Must be a SHA entry.", user)
53+ }
54+ return false
55+}
main.gonew file+90
@@ -0,0 +1,90 @@
1+package main
2+
3+import (
4+ "flag"
5+ "log"
6+ "net"
7+ "net/http"
8+ "net/url"
9+ "strings"
10+ "fmt"
11+)
12+
13+const VERSION = "0.0.1"
14+
15+var (
16+ showVersion = flag.Bool("version", false, "print version string")
17+ httpAddr = flag.String("http-address", "0.0.0.0:4180", "<addr>:<port> to listen on for HTTP clients")
18+ redirectUrl = flag.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
19+ clientID = flag.String("client-id", "", "the Google OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
20+ clientSecret = flag.String("client-secret", "", "the OAuth Client Secret")
21+ passBasicAuth = flag.Bool("pass-basic-auth", true, "pass HTTP Basic Auth information to upstream")
22+ htpasswdFile = flag.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption")
23+ cookieSecret = flag.String("cookie-secret", "", "the seed string for secure cookies")
24+ cookieDomain = flag.String("cookie-domain", "", "an optional cookie domain to force cookies to")
25+ googleAppsDomain = flag.String("google-apps-domain", "", "authenticate against the given google apps domain")
26+ authenticatedEmailsFile = flag.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
27+ upstreams = StringArray{}
28+)
29+
30+func init() {
31+ flag.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path")
32+}
33+
34+func main() {
35+ flag.Parse()
36+
37+ if *showVersion {
38+ fmt.Printf("google_auth_proxy v%s\n", VERSION)
39+ return
40+ }
41+
42+ if len(upstreams) < 1 {
43+ log.Fatal("missing --upstream")
44+ }
45+ if *cookieSecret == "" {
46+ log.Fatal("missing --cookie-secret")
47+ }
48+ if *clientID == "" {
49+ log.Fatal("missing --client-id")
50+ }
51+ if *clientSecret == "" {
52+ log.Fatal("missing --client-secret")
53+ }
54+
55+ var upstreamUrls []*url.URL
56+ for _, u := range upstreams {
57+ upstreamUrl, err := url.Parse(u)
58+ if err != nil {
59+ log.Fatalf("error parsing --upstream %s", err.Error())
60+ }
61+ upstreamUrls = append(upstreamUrls, upstreamUrl)
62+ }
63+ redirectUrl, err := url.Parse(*redirectUrl)
64+ if err != nil {
65+ log.Fatalf("error parsing --redirect-url %s", err.Error())
66+ }
67+
68+ validator := NewValidator(*googleAppsDomain, *authenticatedEmailsFile)
69+ oauthproxy := NewOauthProxy(upstreamUrls, *clientID, *clientSecret, validator)
70+ oauthproxy.SetRedirectUrl(redirectUrl)
71+ if *googleAppsDomain != "" && *authenticatedEmailsFile == "" {
72+ oauthproxy.SignInMessage = fmt.Sprintf("using a %s email address", *googleAppsDomain)
73+ }
74+ if *htpasswdFile != "" {
75+ oauthproxy.HtpasswdFile = NewHtpasswdFile(*htpasswdFile)
76+ }
77+ listener, err := net.Listen("tcp", *httpAddr)
78+ if err != nil {
79+ log.Fatalf("FATAL: listen (%s) failed - %s", *httpAddr, err.Error())
80+ }
81+ log.Printf("listening on %s", *httpAddr)
82+
83+ server := &http.Server{Handler: oauthproxy}
84+ err = server.Serve(listener)
85+ if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
86+ log.Printf("ERROR: http.Serve() - %s", err.Error())
87+ }
88+
89+ log.Printf("HTTP: closing %s", listener.Addr().String())
90+}
oauthproxy.gonew file+302
@@ -0,0 +1,302 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "github.com/bitly/go-simplejson"
9+ "io/ioutil"
10+ "log"
11+ "net/http"
12+ "net/http/httputil"
13+ "net/url"
14+ "strings"
15+ "time"
16+)
17+
18+const signInPath = "/oauth2/sign_in"
19+const oauthStartPath = "/oauth2/start"
20+const oauthCallbackPath = "/oauth2/callback"
21+
22+type OauthProxy struct {
23+ CookieSeed string
24+ CookieKey string
25+ Validator func(string) bool
26+
27+ redirectUrl *url.URL // the url to receive requests at
28+ oauthRedemptionUrl *url.URL // endpoint to redeem the code
29+ oauthLoginUrl *url.URL // to redirect the user to
30+ oauthUserInfoUrl *url.URL
31+ oauthScope string
32+ clientID string
33+ clientSecret string
34+ SignInMessage string
35+ HtpasswdFile *HtpasswdFile
36+ serveMux *http.ServeMux
37+}
38+
39+func NewOauthProxy(proxyUrls []*url.URL, clientID string, clientSecret string, validator func(string) bool) *OauthProxy {
40+ login, _ := url.Parse("https://accounts.google.com/o/oauth2/auth")
41+ redeem, _ := url.Parse("https://accounts.google.com/o/oauth2/token")
42+ info, _ := url.Parse("https://www.googleapis.com/oauth2/v2/userinfo")
43+ serveMux := http.NewServeMux()
44+ for _, u := range proxyUrls {
45+ path := u.Path
46+ u.Path = ""
47+ log.Printf("mapping %s => %s", path, u)
48+ serveMux.Handle(path, httputil.NewSingleHostReverseProxy(u))
49+ }
50+ return &OauthProxy{
51+ CookieKey: "_oauthproxy",
52+ CookieSeed: *cookieSecret,
53+ Validator: validator,
54+
55+ clientID: clientID,
56+ clientSecret: clientSecret,
57+ oauthScope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
58+ oauthRedemptionUrl: redeem,
59+ oauthLoginUrl: login,
60+ oauthUserInfoUrl: info,
61+ serveMux: serveMux,
62+ }
63+}
64+
65+func (p *OauthProxy) SetRedirectUrl(redirectUrl *url.URL) {
66+ redirectUrl.Path = oauthCallbackPath
67+ p.redirectUrl = redirectUrl
68+}
69+
70+func (p *OauthProxy) GetLoginURL() string {
71+ params := url.Values{}
72+ params.Add("redirect_uri", p.redirectUrl.String())
73+ params.Add("approval_prompt", "force")
74+ params.Add("scope", p.oauthScope)
75+ params.Add("client_id", p.clientID)
76+ params.Add("response_type", "code")
77+ return fmt.Sprintf("%s?%s", p.oauthLoginUrl, params.Encode())
78+}
79+
80+func apiRequest(req *http.Request) (*simplejson.Json, error) {
81+ httpclient := &http.Client{}
82+ resp, err := httpclient.Do(req)
83+ if err != nil {
84+ return nil, err
85+ }
86+ body, err := ioutil.ReadAll(resp.Body)
87+ resp.Body.Close()
88+ if err != nil {
89+ return nil, err
90+ }
91+ if resp.StatusCode != 200 {
92+ log.Printf("got response code %d - %s", resp.StatusCode, body)
93+ return nil, errors.New("api request returned 200 status code")
94+ }
95+ data, err := simplejson.NewJson(body)
96+ if err != nil {
97+ return nil, err
98+ }
99+ return data, nil
100+}
101+
102+func (p *OauthProxy) redeemCode(code string) (string, error) {
103+
104+ params := url.Values{}
105+ params.Add("redirect_uri", p.redirectUrl.String())
106+ params.Add("client_id", p.clientID)
107+ params.Add("client_secret", p.clientSecret)
108+ params.Add("code", code)
109+ params.Add("grant_type", "authorization_code")
110+ log.Printf("body is %s", params.Encode())
111+ req, err := http.NewRequest("POST", p.oauthRedemptionUrl.String(), bytes.NewBufferString(params.Encode()))
112+ if err != nil {
113+ log.Printf("failed building request %s", err.Error())
114+ return "", err
115+ }
116+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
117+ json, err := apiRequest(req)
118+ if err != nil {
119+ log.Printf("failed making request %s", err.Error())
120+ return "", err
121+ }
122+ access_token, err := json.Get("access_token").String()
123+ if err != nil {
124+ return "", err
125+ }
126+ return access_token, nil
127+}
128+func (p *OauthProxy) getUserInfo(token string) (string, error) {
129+ params := url.Values{}
130+ params.Add("access_token", token)
131+ endpoint := fmt.Sprintf("%s?%s", p.oauthUserInfoUrl.String(), params.Encode())
132+ log.Printf("calling %s", endpoint)
133+ req, err := http.NewRequest("GET", endpoint, nil)
134+ if err != nil {
135+ log.Printf("failed building request %s", err.Error())
136+ return "", err
137+ }
138+ json, err := apiRequest(req)
139+ if err != nil {
140+ log.Printf("failed making request %s", err.Error())
141+ return "", err
142+ }
143+ email, err := json.Get("email").String()
144+ if err != nil {
145+ log.Printf("failed getting email from response %s", err.Error())
146+ return "", err
147+ }
148+ return email, nil
149+}
150+
151+func ClearCookie(rw http.ResponseWriter, req *http.Request, key string) {
152+ domain := strings.Split(req.Host, ":")[0]
153+ if *cookieDomain != "" {
154+ domain = *cookieDomain
155+ }
156+ cookie := &http.Cookie{
157+ Name: key,
158+ Value: "",
159+ Path: "/",
160+ Domain: domain,
161+ Expires: time.Now().Add(time.Duration(1) * time.Hour * -1),
162+ HttpOnly: true,
163+ }
164+ http.SetCookie(rw, cookie)
165+}
166+
167+func ErrorPage(rw http.ResponseWriter, code int, title string, message string, signinmessage string) {
168+ log.Printf("ErrorPage %d %s %s %s", code, title, message, signinmessage)
169+ rw.WriteHeader(code)
170+ t := getTemplates()
171+ p := struct {
172+ Title string
173+ Message string
174+ SignInMessage string
175+ }{
176+ Title: fmt.Sprintf("%d %s", code, title),
177+ Message: message,
178+ SignInMessage: signinmessage,
179+ }
180+ t.ExecuteTemplate(rw, "error.html", p)
181+}
182+
183+func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
184+ // check if this is a redirect back at the end of oauth
185+ if req.URL.Path == signInPath {
186+ ClearCookie(rw, req, p.CookieKey)
187+ t := getTemplates()
188+ p := struct{ SignInMessage string }{SignInMessage: p.SignInMessage}
189+ t.ExecuteTemplate(rw, "sign_in.html", p)
190+ return
191+ }
192+ if req.URL.Path == oauthStartPath {
193+ http.Redirect(rw, req, p.GetLoginURL(), 302)
194+ return
195+ }
196+ if req.URL.Path == oauthCallbackPath {
197+ // finish the oauth cycle
198+ reqParams, err := url.ParseQuery(req.URL.RawQuery)
199+ if err != nil {
200+ ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
201+ return
202+ }
203+ errorString, ok := reqParams["error"]
204+ if ok && len(errorString) == 1 {
205+ ErrorPage(rw, 403, "Permission Denied", errorString[0], p.SignInMessage)
206+ return
207+ }
208+ code, ok := reqParams["code"]
209+ if !ok || len(code) != 1 {
210+ ErrorPage(rw, 500, "Internal Error", "Invalid API response", p.SignInMessage)
211+ return
212+ }
213+
214+ token, err := p.redeemCode(code[0])
215+ if err != nil {
216+ log.Printf("error redeeming code %s", err.Error())
217+ ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
218+ return
219+ }
220+ // validate user
221+ email, err := p.getUserInfo(token)
222+ if err != nil {
223+ log.Printf("error redeeming code %s", err.Error())
224+ ErrorPage(rw, 500, "Internal Error", err.Error(), p.SignInMessage)
225+ return
226+ }
227+
228+ // set cookie, or deny
229+ if p.Validator(email) {
230+ log.Printf("authenticating %s completed", email)
231+ domain := strings.Split(req.Host, ":")[0]
232+ if *cookieDomain != "" {
233+ domain = *cookieDomain
234+ }
235+
236+ cookie := &http.Cookie{
237+ Name: p.CookieKey,
238+ Value: signedCookieValue(p.CookieSeed, p.CookieKey, email),
239+ Path: "/",
240+ Domain: domain,
241+ Expires: time.Now().Add(time.Duration(168) * time.Hour), // 7 days
242+ HttpOnly: true,
243+ // Secure: req. ... ? set if X-Scheme: https ?
244+ }
245+ http.SetCookie(rw, cookie)
246+ http.Redirect(rw, req, "/", 302)
247+ return
248+ } else {
249+ ErrorPage(rw, 403, "Permission Denied", "Invalid Account", p.SignInMessage)
250+ return
251+ }
252+ }
253+ cookie, err := req.Cookie(p.CookieKey)
254+ var ok bool
255+ var email string
256+ var user string
257+ if err == nil {
258+ email, ok = validateCookie(cookie, p.CookieSeed)
259+ user = strings.Split(email, "@")[0]
260+ }
261+
262+ if !ok {
263+ user, ok = p.CheckBasicAuth(req)
264+ }
265+
266+ if !ok {
267+ log.Printf("invalid cookie. redirecting to sign in")
268+ // TODO: capture state for which url to redirect to at the end
269+ http.Redirect(rw, req, "/oauth2/sign_in", 302)
270+ return
271+ }
272+
273+ // At this point, the user is authenticated. proxy normally
274+ if *passBasicAuth {
275+ req.SetBasicAuth(user, "")
276+ req.Header["X-Forwarded-User"] = []string{user}
277+ }
278+
279+ p.serveMux.ServeHTTP(rw, req)
280+}
281+
282+func (p *OauthProxy) CheckBasicAuth(req *http.Request) (string, bool) {
283+ if p.HtpasswdFile == nil {
284+ return "", false
285+ }
286+ s := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
287+ if len(s) != 2 || s[0] != "Basic" {
288+ return "", false
289+ }
290+ b, err := base64.StdEncoding.DecodeString(s[1])
291+ if err != nil {
292+ return "", false
293+ }
294+ pair := strings.SplitN(string(b), ":", 2)
295+ if len(pair) != 2 {
296+ return "", false
297+ }
298+ if p.HtpasswdFile.Validate(pair[0], pair[1]) {
299+ return pair[0], true
300+ }
301+ return "", false
302+}
string_array.gonew file+16
@@ -0,0 +1,16 @@
1+package main
2+
3+import (
4+ "fmt"
5+)
6+
7+type StringArray []string
8+
9+func (a *StringArray) Set(s string) error {
10+ *a = append(*a, s)
11+ return nil
12+}
13+
14+func (a *StringArray) String() string {
15+ return fmt.Sprint(*a)
16+}
templates.gonew file+37
@@ -0,0 +1,37 @@
1+package main
2+
3+import (
4+ "html/template"
5+ "log"
6+)
7+
8+func getTemplates() *template.Template {
9+ t, err := template.New("foo").Parse(`{{define "sign_in.html"}}
10+<html><head><title>Sign In</title></head>
11+ <body>
12+ <form method="GET" action="/oauth2/start">
13+ <button type="submit">Sign In w/ Google</button>
14+ {{.SignInMessage}}
15+ </form>
16+</body></html>
17+{{end}}`)
18+ if err != nil {
19+ log.Fatalf("failed parsing template %s", err.Error())
20+ }
21+ t, err = t.Parse(`{{define "error.html"}}
22+<html><head><title>{{.Title}}</title></head>
23+<body>
24+ <h2>{{.Title}}</h2>
25+ <p>{{.Message}}</p>
26+ <hr>
27+ <form method="GET" action="/oauth2/start">
28+ <button type="submit">Sign In w/ Google</button>
29+ {{.SignInMessage}}
30+ </form>
31+</body>
32+</html>{{end}}`)
33+ if err != nil {
34+ log.Fatalf("failed parsing template %s", err.Error())
35+ }
36+ return t
37+}
validator.gonew file+46
@@ -0,0 +1,46 @@
1+package main
2+
3+import (
4+ "os"
5+ "log"
6+ "encoding/csv"
7+ "fmt"
8+ "strings"
9+)
10+
11+func NewValidator(domain string, usersFile string) func(string) bool {
12+
13+ validUsers := make(map[string]bool)
14+ emailSuffix := ""
15+ if domain != "" {
16+ emailSuffix = fmt.Sprintf("@%s", domain)
17+ }
18+
19+ if usersFile != "" {
20+ r, err := os.Open(usersFile)
21+ if err != nil {
22+ log.Fatalf("failed opening -authenticated-emails-file=%v, %s", usersFile, err.Error())
23+ }
24+ csv_reader := csv.NewReader(r)
25+ csv_reader.Comma = ','
26+ csv_reader.Comment = '#'
27+ csv_reader.TrimLeadingSpace = true
28+ records, err := csv_reader.ReadAll()
29+ for _, r := range records {
30+ validUsers[r[0]] = true
31+ }
32+ }
33+
34+ validator := func(email string) bool {
35+ var valid bool
36+ if emailSuffix != "" {
37+ valid = strings.HasSuffix(email, emailSuffix)
38+ }
39+ if !valid {
40+ _, valid = validUsers[email]
41+ }
42+ log.Printf("validating: is %s valid? %v", email, valid)
43+ return valid
44+ }
45+ return validator
46+}