git.iko.soy/typeable/oauth2-proxy
on v1.1.1 help sign in
typeable/oauth2-proxy/oauthproxy.go
514 lines 13 kB history raw
1package main
3import (
4 "bytes"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "html/template"
9 "io/ioutil"
10 "log"
11 "net"
12 "net/http"
13 "net/http/httputil"
14 "net/url"
15 "regexp"
16 "strings"
17 "time"
19 "github.com/bitly/go-simplejson"
22const pingPath = "/ping"
23const signInPath = "/oauth2/sign_in"
24const oauthStartPath = "/oauth2/start"
25const oauthCallbackPath = "/oauth2/callback"
27type OauthProxy struct {
28 CookieSeed string
29 CookieKey string
30 CookieDomain string
31 CookieSecure bool
32 CookieHttpOnly bool
33 CookieExpire time.Duration
34 Validator func(string) bool
36 redirectUrl *url.URL // the url to receive requests at
37 oauthRedemptionUrl *url.URL // endpoint to redeem the code
38 oauthLoginUrl *url.URL // to redirect the user to
39 oauthScope string
40 clientID string
41 clientSecret string
42 SignInMessage string
43 HtpasswdFile *HtpasswdFile
44 DisplayHtpasswdForm bool
45 serveMux http.Handler
46 PassBasicAuth bool
47 skipAuthRegex []string
48 compiledRegex []*regexp.Regexp
49 templates *template.Template
52type UpstreamProxy struct {
53 upstream string
54 handler http.Handler
57func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
58 w.Header().Set("GAP-Upstream-Address", u.upstream)
59 u.handler.ServeHTTP(w, r)
62func NewReverseProxy(target *url.URL) (proxy *httputil.ReverseProxy) {
63 return httputil.NewSingleHostReverseProxy(target)
65func setProxyUpstreamHostHeader(proxy *httputil.ReverseProxy, target *url.URL) {
66 director := proxy.Director
67 proxy.Director = func(req *http.Request) {
68 director(req)
69 // use RequestURI so that we aren't unescaping encoded slashes in the request path
70 req.Host = target.Host
71 req.URL.Opaque = req.RequestURI
72 req.URL.RawQuery = ""
73 }
75func setProxyDirector(proxy *httputil.ReverseProxy) {
76 director := proxy.Director
77 proxy.Director = func(req *http.Request) {
78 director(req)
79 // use RequestURI so that we aren't unescaping encoded slashes in the request path
80 req.URL.Opaque = req.RequestURI
81 req.URL.RawQuery = ""
82 }
85func NewOauthProxy(opts *Options, validator func(string) bool) *OauthProxy {
86 login, _ := url.Parse("https://accounts.google.com/o/oauth2/auth")
87 redeem, _ := url.Parse("https://accounts.google.com/o/oauth2/token")
88 serveMux := http.NewServeMux()
89 for _, u := range opts.proxyUrls {
90 path := u.Path
91 u.Path = ""
92 log.Printf("mapping path %q => upstream %q", path, u)
93 proxy := NewReverseProxy(u)
94 if !opts.PassHostHeader {
95 setProxyUpstreamHostHeader(proxy, u)
96 } else {
97 setProxyDirector(proxy)
98 }
99 serveMux.Handle(path, &UpstreamProxy{u.Host, proxy})
100 }
101 for _, u := range opts.CompiledRegex {
102 log.Printf("compiled skip-auth-regex => %q", u)
103 }
105 redirectUrl := opts.redirectUrl
106 redirectUrl.Path = oauthCallbackPath
108 log.Printf("OauthProxy configured for %s", opts.ClientID)
109 domain := opts.CookieDomain
110 if domain == "" {
111 domain = "<default>"
112 }
113 if !opts.CookieHttpsOnly {
114 log.Printf("Warning: cookie-https-only setting is deprecated and will be removed in a future version. use cookie-secure")
115 opts.CookieSecure = opts.CookieHttpsOnly
116 }
118 log.Printf("Cookie settings: secure (https):%v httponly:%v expiry:%s domain:%s", opts.CookieSecure, opts.CookieHttpOnly, opts.CookieExpire, domain)
120 return &OauthProxy{
121 CookieKey: "_oauthproxy",
122 CookieSeed: opts.CookieSecret,
123 CookieDomain: opts.CookieDomain,
124 CookieSecure: opts.CookieSecure,
125 CookieHttpOnly: opts.CookieHttpOnly,
126 CookieExpire: opts.CookieExpire,
127 Validator: validator,
129 clientID: opts.ClientID,
130 clientSecret: opts.ClientSecret,
131 oauthScope: "profile email",
132 oauthRedemptionUrl: redeem,
133 oauthLoginUrl: login,
134 serveMux: serveMux,
135 redirectUrl: redirectUrl,
136 skipAuthRegex: opts.SkipAuthRegex,
137 compiledRegex: opts.CompiledRegex,
138 PassBasicAuth: opts.PassBasicAuth,
139 templates: loadTemplates(opts.CustomTemplatesDir),
140 }
143func (p *OauthProxy) GetRedirectUrl(host string) string {
144 // default to the request Host if not set
145 if p.redirectUrl.Host != "" {
146 return p.redirectUrl.String()
147 }
148 var u url.URL
149 u = *p.redirectUrl
150 if u.Scheme == "" {
151 if p.CookieSecure {
152 u.Scheme = "https"
153 } else {
154 u.Scheme = "http"
155 }
156 }
157 u.Host = host
158 return u.String()
161func (p *OauthProxy) GetLoginURL(host, redirect string) string {
162 params := url.Values{}
163 params.Add("redirect_uri", p.GetRedirectUrl(host))
164 params.Add("approval_prompt", "force")
165 params.Add("scope", p.oauthScope)
166 params.Add("client_id", p.clientID)
167 params.Add("response_type", "code")
168 if strings.HasPrefix(redirect, "/") {
169 params.Add("state", redirect)
170 }
171 return fmt.Sprintf("%s?%s", p.oauthLoginUrl, params.Encode())
174func apiRequest(req *http.Request) (*simplejson.Json, error) {
175 httpclient := &http.Client{}
176 resp, err := httpclient.Do(req)
177 if err != nil {
178 return nil, err
179 }
180 body, err := ioutil.ReadAll(resp.Body)
181 resp.Body.Close()
182 if err != nil {
183 return nil, err
184 }
185 if resp.StatusCode != 200 {
186 log.Printf("got response code %d - %s", resp.StatusCode, body)
187 return nil, errors.New("api request returned non 200 status code")
188 }
189 data, err := simplejson.NewJson(body)
190 if err != nil {
191 return nil, err
192 }
193 return data, nil
196func (p *OauthProxy) displayCustomLoginForm() bool {
197 return p.HtpasswdFile != nil && p.DisplayHtpasswdForm
200func (p *OauthProxy) redeemCode(host, code string) (string, string, error) {
201 if code == "" {
202 return "", "", errors.New("missing code")
203 }
204 params := url.Values{}
205 params.Add("redirect_uri", p.GetRedirectUrl(host))
206 params.Add("client_id", p.clientID)
207 params.Add("client_secret", p.clientSecret)
208 params.Add("code", code)
209 params.Add("grant_type", "authorization_code")
210 req, err := http.NewRequest("POST", p.oauthRedemptionUrl.String(), bytes.NewBufferString(params.Encode()))
211 if err != nil {
212 log.Printf("failed building request %s", err.Error())
213 return "", "", err
214 }
215 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
216 json, err := apiRequest(req)
217 if err != nil {
218 log.Printf("failed making request %s", err)
219 return "", "", err
220 }
221 access_token, err := json.Get("access_token").String()
222 if err != nil {
223 return "", "", err
224 }
226 idToken, err := json.Get("id_token").String()
227 if err != nil {
228 return "", "", err
229 }
231 // id_token is a base64 encode ID token payload
232 // https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo
233 jwt := strings.Split(idToken, ".")
234 b, err := jwtDecodeSegment(jwt[1])
235 if err != nil {
236 return "", "", err
237 }
238 data, err := simplejson.NewJson(b)
239 if err != nil {
240 return "", "", err
241 }
242 email, err := data.Get("email").String()
243 if err != nil {
244 return "", "", err
245 }
247 return access_token, email, nil
250func jwtDecodeSegment(seg string) ([]byte, error) {
251 if l := len(seg) % 4; l > 0 {
252 seg += strings.Repeat("=", 4-l)
253 }
255 return base64.URLEncoding.DecodeString(seg)
258func (p *OauthProxy) ClearCookie(rw http.ResponseWriter, req *http.Request) {
259 domain := req.Host
260 if h, _, err := net.SplitHostPort(domain); err == nil {
261 domain = h
262 }
263 if p.CookieDomain != "" {
264 if !strings.HasSuffix(domain, p.CookieDomain) {
265 log.Printf("Warning: request host is %q but using configured cookie domain of %q", domain, p.CookieDomain)
266 }
267 domain = p.CookieDomain
268 }
269 cookie := &http.Cookie{
270 Name: p.CookieKey,
271 Value: "",
272 Path: "/",
273 Domain: domain,
274 HttpOnly: p.CookieHttpOnly,
275 Secure: p.CookieSecure,
276 Expires: time.Now().Add(time.Duration(1) * time.Hour * -1),
277 }
278 http.SetCookie(rw, cookie)
281func (p *OauthProxy) SetCookie(rw http.ResponseWriter, req *http.Request, val string) {
283 domain := req.Host
284 if h, _, err := net.SplitHostPort(domain); err == nil {
285 domain = h
286 }
287 if p.CookieDomain != "" {
288 if !strings.HasSuffix(domain, p.CookieDomain) {
289 log.Printf("Warning: request host is %q but using configured cookie domain of %q", domain, p.CookieDomain)
290 }
291 domain = p.CookieDomain
292 }
293 cookie := &http.Cookie{
294 Name: p.CookieKey,
295 Value: signedCookieValue(p.CookieSeed, p.CookieKey, val),
296 Path: "/",
297 Domain: domain,
298 HttpOnly: p.CookieHttpOnly,
299 Secure: p.CookieSecure,
300 Expires: time.Now().Add(p.CookieExpire),
301 }
302 http.SetCookie(rw, cookie)
305func (p *OauthProxy) PingPage(rw http.ResponseWriter) {
306 rw.WriteHeader(http.StatusOK)
307 fmt.Fprintf(rw, "OK")
310func (p *OauthProxy) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
311 log.Printf("ErrorPage %d %s %s", code, title, message)
312 rw.WriteHeader(code)
313 t := struct {
314 Title string
315 Message string
316 }{
317 Title: fmt.Sprintf("%d %s", code, title),
318 Message: message,
319 }
320 p.templates.ExecuteTemplate(rw, "error.html", t)
323func (p *OauthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code int) {
324 p.ClearCookie(rw, req)
325 rw.WriteHeader(code)
327 t := struct {
328 SignInMessage string
329 CustomLogin bool
330 Redirect string
331 Version string
332 }{
333 SignInMessage: p.SignInMessage,
334 CustomLogin: p.displayCustomLoginForm(),
335 Redirect: req.URL.RequestURI(),
336 Version: VERSION,
337 }
338 p.templates.ExecuteTemplate(rw, "sign_in.html", t)
341func (p *OauthProxy) ManualSignIn(rw http.ResponseWriter, req *http.Request) (string, bool) {
342 if req.Method != "POST" || p.HtpasswdFile == nil {
343 return "", false
344 }
345 user := req.FormValue("username")
346 passwd := req.FormValue("password")
347 if user == "" {
348 return "", false
349 }
350 // check auth
351 if p.HtpasswdFile.Validate(user, passwd) {
352 log.Printf("authenticated %q via HtpasswdFile", user)
353 return user, true
354 }
355 return "", false
358func (p *OauthProxy) GetRedirect(req *http.Request) (string, error) {
359 err := req.ParseForm()
361 if err != nil {
362 return "", err
363 }
365 redirect := req.FormValue("rd")
367 if redirect == "" {
368 redirect = "/"
369 }
371 return redirect, err
374func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
375 // check if this is a redirect back at the end of oauth
376 remoteAddr := req.RemoteAddr
377 if req.Header.Get("X-Real-IP") != "" {
378 remoteAddr += fmt.Sprintf(" (%q)", req.Header.Get("X-Real-IP"))
379 }
381 var ok bool
382 var user string
383 var email string
385 if req.URL.Path == pingPath {
386 p.PingPage(rw)
387 return
388 }
390 for _, u := range p.compiledRegex {
391 match := u.MatchString(req.URL.Path)
392 if match {
393 p.serveMux.ServeHTTP(rw, req)
394 return
395 }
397 }
399 if req.URL.Path == signInPath {
400 redirect, err := p.GetRedirect(req)
401 if err != nil {
402 p.ErrorPage(rw, 500, "Internal Error", err.Error())
403 return
404 }
406 user, ok = p.ManualSignIn(rw, req)
407 if ok {
408 p.SetCookie(rw, req, user)
409 http.Redirect(rw, req, redirect, 302)
410 } else {
411 p.SignInPage(rw, req, 200)
412 }
413 return
414 }
415 if req.URL.Path == oauthStartPath {
416 redirect, err := p.GetRedirect(req)
417 if err != nil {
418 p.ErrorPage(rw, 500, "Internal Error", err.Error())
419 return
420 }
421 http.Redirect(rw, req, p.GetLoginURL(req.Host, redirect), 302)
422 return
423 }
424 if req.URL.Path == oauthCallbackPath {
425 // finish the oauth cycle
426 err := req.ParseForm()
427 if err != nil {
428 p.ErrorPage(rw, 500, "Internal Error", err.Error())
429 return
430 }
431 errorString := req.Form.Get("error")
432 if errorString != "" {
433 p.ErrorPage(rw, 403, "Permission Denied", errorString)
434 return
435 }
437 _, email, err := p.redeemCode(req.Host, req.Form.Get("code"))
438 if err != nil {
439 log.Printf("%s error redeeming code %s", remoteAddr, err)
440 p.ErrorPage(rw, 500, "Internal Error", err.Error())
441 return
442 }
444 redirect := req.Form.Get("state")
445 if redirect == "" {
446 redirect = "/"
447 }
449 // set cookie, or deny
450 if p.Validator(email) {
451 log.Printf("%s authenticating %s completed", remoteAddr, email)
452 p.SetCookie(rw, req, email)
453 http.Redirect(rw, req, redirect, 302)
454 return
455 } else {
456 p.ErrorPage(rw, 403, "Permission Denied", "Invalid Account")
457 return
458 }
459 }
461 if !ok {
462 cookie, err := req.Cookie(p.CookieKey)
463 if err == nil {
464 email, ok = validateCookie(cookie, p.CookieSeed)
465 user = strings.Split(email, "@")[0]
466 }
467 }
469 if !ok {
470 user, ok = p.CheckBasicAuth(req)
471 }
473 if !ok {
474 p.SignInPage(rw, req, 403)
475 return
476 }
478 // At this point, the user is authenticated. proxy normally
479 if p.PassBasicAuth {
480 req.SetBasicAuth(user, "")
481 req.Header["X-Forwarded-User"] = []string{user}
482 req.Header["X-Forwarded-Email"] = []string{email}
483 }
484 if email == "" {
485 rw.Header().Set("GAP-Auth", user)
486 } else {
487 rw.Header().Set("GAP-Auth", email)
488 }
490 p.serveMux.ServeHTTP(rw, req)
493func (p *OauthProxy) CheckBasicAuth(req *http.Request) (string, bool) {
494 if p.HtpasswdFile == nil {
495 return "", false
496 }
497 s := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
498 if len(s) != 2 || s[0] != "Basic" {
499 return "", false
500 }
501 b, err := base64.StdEncoding.DecodeString(s[1])
502 if err != nil {
503 return "", false
504 }
505 pair := strings.SplitN(string(b), ":", 2)
506 if len(pair) != 2 {
507 return "", false
508 }
509 if p.HtpasswdFile.Validate(pair[0], pair[1]) {
510 log.Printf("authenticated %q via basic auth", pair[0])
511 return pair[0], true
512 }
513 return "", false