oauth2_proxy/oauthproxy.go
Carlo Lobrano 731fa9f8e0 Github provider: use login as user
- Save both user and email in session state:
    Encoding/decoding methods save both email and user
    field in session state, for use cases when User is not derived from
    email's local-parth, like for GitHub provider.

    For retrocompatibility, if no user is obtained by the provider,
    (e.g. User is an empty string) the encoding/decoding methods fall back
    to the previous behavior and use the email's local-part

    Updated also related tests and added two more tests to show behavior
    when session contains a non-empty user value.

- Added first basic GitHub provider tests

- Added GetUserName method to Provider interface
    The new GetUserName method is intended to return the User
    value when this is not the email's local-part.

    Added also the default implementation to provider_default.go

- Added call to GetUserName in redeemCode

    the new GetUserName method is used in redeemCode
    to get SessionState User value.

    For backward compatibility, if GetUserName error is
    "not implemented", the error is ignored.

- Added GetUserName method and tests to github provider.
2017-11-20 20:02:27 +01:00

727 lines
20 KiB
Go

package main
import (
b64 "encoding/base64"
"errors"
"fmt"
"html/template"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strings"
"time"
"github.com/bitly/oauth2_proxy/cookie"
"github.com/bitly/oauth2_proxy/providers"
"github.com/mbland/hmacauth"
)
const SignatureHeader = "GAP-Signature"
var SignatureHeaders []string = []string{
"Content-Length",
"Content-Md5",
"Content-Type",
"Date",
"Authorization",
"X-Forwarded-User",
"X-Forwarded-Email",
"X-Forwarded-Access-Token",
"Cookie",
"Gap-Auth",
}
type OAuthProxy struct {
CookieSeed string
CookieName string
CSRFCookieName string
CookieDomain string
CookieSecure bool
CookieHttpOnly bool
CookieExpire time.Duration
CookieRefresh time.Duration
Validator func(string) bool
RobotsPath string
PingPath string
SignInPath string
SignOutPath string
OAuthStartPath string
OAuthCallbackPath string
AuthOnlyPath string
redirectURL *url.URL // the url to receive requests at
provider providers.Provider
ProxyPrefix string
SignInMessage string
HtpasswdFile *HtpasswdFile
DisplayHtpasswdForm bool
serveMux http.Handler
SetXAuthRequest bool
PassBasicAuth bool
SkipProviderButton bool
PassUserHeaders bool
BasicAuthPassword string
PassAccessToken bool
CookieCipher *cookie.Cipher
skipAuthRegex []string
skipAuthPreflight bool
compiledRegex []*regexp.Regexp
templates *template.Template
Footer string
}
type UpstreamProxy struct {
upstream string
handler http.Handler
auth hmacauth.HmacAuth
}
func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("GAP-Upstream-Address", u.upstream)
if u.auth != nil {
r.Header.Set("GAP-Auth", w.Header().Get("GAP-Auth"))
u.auth.SignRequest(r)
}
u.handler.ServeHTTP(w, r)
}
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) {
director(req)
// use RequestURI so that we aren't unescaping encoded slashes in the request path
req.Host = target.Host
req.URL.Opaque = req.RequestURI
req.URL.RawQuery = ""
}
}
func setProxyDirector(proxy *httputil.ReverseProxy) {
director := proxy.Director
proxy.Director = func(req *http.Request) {
director(req)
// use RequestURI so that we aren't unescaping encoded slashes in the request path
req.URL.Opaque = req.RequestURI
req.URL.RawQuery = ""
}
}
func NewFileServer(path string, filesystemPath string) (proxy http.Handler) {
return http.StripPrefix(path, http.FileServer(http.Dir(filesystemPath)))
}
func NewOAuthProxy(opts *Options, validator func(string) bool) *OAuthProxy {
serveMux := http.NewServeMux()
var auth hmacauth.HmacAuth
if sigData := opts.signatureData; sigData != nil {
auth = hmacauth.NewHmacAuth(sigData.hash, []byte(sigData.key),
SignatureHeader, SignatureHeaders)
}
for _, u := range opts.proxyURLs {
path := u.Path
switch u.Scheme {
case "http", "https":
u.Path = ""
log.Printf("mapping path %q => upstream %q", path, u)
proxy := NewReverseProxy(u)
if !opts.PassHostHeader {
setProxyUpstreamHostHeader(proxy, u)
} else {
setProxyDirector(proxy)
}
serveMux.Handle(path,
&UpstreamProxy{u.Host, proxy, auth})
case "file":
if u.Fragment != "" {
path = u.Fragment
}
log.Printf("mapping path %q => file system %q", path, u.Path)
proxy := NewFileServer(path, u.Path)
serveMux.Handle(path, &UpstreamProxy{path, proxy, nil})
default:
panic(fmt.Sprintf("unknown upstream protocol %s", u.Scheme))
}
}
for _, u := range opts.CompiledRegex {
log.Printf("compiled skip-auth-regex => %q", u)
}
redirectURL := opts.redirectURL
redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
log.Printf("OAuthProxy configured for %s Client ID: %s", opts.provider.Data().ProviderName, opts.ClientID)
refresh := "disabled"
if opts.CookieRefresh != time.Duration(0) {
refresh = fmt.Sprintf("after %s", opts.CookieRefresh)
}
log.Printf("Cookie settings: name:%s secure(https):%v httponly:%v expiry:%s domain:%s refresh:%s", opts.CookieName, opts.CookieSecure, opts.CookieHttpOnly, opts.CookieExpire, opts.CookieDomain, refresh)
var cipher *cookie.Cipher
if opts.PassAccessToken || (opts.CookieRefresh != time.Duration(0)) {
var err error
cipher, err = cookie.NewCipher(secretBytes(opts.CookieSecret))
if err != nil {
log.Fatal("cookie-secret error: ", err)
}
}
return &OAuthProxy{
CookieName: opts.CookieName,
CSRFCookieName: fmt.Sprintf("%v_%v", opts.CookieName, "csrf"),
CookieSeed: opts.CookieSecret,
CookieDomain: opts.CookieDomain,
CookieSecure: opts.CookieSecure,
CookieHttpOnly: opts.CookieHttpOnly,
CookieExpire: opts.CookieExpire,
CookieRefresh: opts.CookieRefresh,
Validator: validator,
RobotsPath: "/robots.txt",
PingPath: "/ping",
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
ProxyPrefix: opts.ProxyPrefix,
provider: opts.provider,
serveMux: serveMux,
redirectURL: redirectURL,
skipAuthRegex: opts.SkipAuthRegex,
skipAuthPreflight: opts.SkipAuthPreflight,
compiledRegex: opts.CompiledRegex,
SetXAuthRequest: opts.SetXAuthRequest,
PassBasicAuth: opts.PassBasicAuth,
PassUserHeaders: opts.PassUserHeaders,
BasicAuthPassword: opts.BasicAuthPassword,
PassAccessToken: opts.PassAccessToken,
SkipProviderButton: opts.SkipProviderButton,
CookieCipher: cipher,
templates: loadTemplates(opts.CustomTemplatesDir),
Footer: opts.Footer,
}
}
func (p *OAuthProxy) GetRedirectURI(host string) string {
// default to the request Host if not set
if p.redirectURL.Host != "" {
return p.redirectURL.String()
}
var u url.URL
u = *p.redirectURL
if u.Scheme == "" {
if p.CookieSecure {
u.Scheme = "https"
} else {
u.Scheme = "http"
}
}
u.Host = host
return u.String()
}
func (p *OAuthProxy) displayCustomLoginForm() bool {
return p.HtpasswdFile != nil && p.DisplayHtpasswdForm
}
func (p *OAuthProxy) redeemCode(host, code string) (s *providers.SessionState, err error) {
if code == "" {
return nil, errors.New("missing code")
}
redirectURI := p.GetRedirectURI(host)
s, err = p.provider.Redeem(redirectURI, code)
if err != nil {
return
}
if s.Email == "" {
s.Email, err = p.provider.GetEmailAddress(s)
}
if s.User == "" {
s.User, err = p.provider.GetUserName(s)
if err != nil && err.Error() == "not implemented" {
err = nil
}
}
return
}
func (p *OAuthProxy) MakeSessionCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
if value != "" {
value = cookie.SignedValue(p.CookieSeed, p.CookieName, value, now)
if len(value) > 4096 {
// Cookies cannot be larger than 4kb
log.Printf("WARNING - Cookie Size: %d bytes", len(value))
}
}
return p.makeCookie(req, p.CookieName, value, expiration, now)
}
func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
return p.makeCookie(req, p.CSRFCookieName, value, expiration, now)
}
func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie {
if p.CookieDomain != "" {
domain := req.Host
if h, _, err := net.SplitHostPort(domain); err == nil {
domain = h
}
if !strings.HasSuffix(domain, p.CookieDomain) {
log.Printf("Warning: request host is %q but using configured cookie domain of %q", domain, p.CookieDomain)
}
}
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
Domain: p.CookieDomain,
HttpOnly: p.CookieHttpOnly,
Secure: p.CookieSecure,
Expires: now.Add(expiration),
}
}
func (p *OAuthProxy) ClearCSRFCookie(rw http.ResponseWriter, req *http.Request) {
http.SetCookie(rw, p.MakeCSRFCookie(req, "", time.Hour*-1, time.Now()))
}
func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, val string) {
http.SetCookie(rw, p.MakeCSRFCookie(req, val, p.CookieExpire, time.Now()))
}
func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) {
http.SetCookie(rw, p.MakeSessionCookie(req, "", time.Hour*-1, time.Now()))
}
func (p *OAuthProxy) SetSessionCookie(rw http.ResponseWriter, req *http.Request, val string) {
http.SetCookie(rw, p.MakeSessionCookie(req, val, p.CookieExpire, time.Now()))
}
func (p *OAuthProxy) LoadCookiedSession(req *http.Request) (*providers.SessionState, time.Duration, error) {
var age time.Duration
c, err := req.Cookie(p.CookieName)
if err != nil {
// always http.ErrNoCookie
return nil, age, fmt.Errorf("Cookie %q not present", p.CookieName)
}
val, timestamp, ok := cookie.Validate(c, p.CookieSeed, p.CookieExpire)
if !ok {
return nil, age, errors.New("Cookie Signature not valid")
}
session, err := p.provider.SessionFromCookie(val, p.CookieCipher)
if err != nil {
return nil, age, err
}
age = time.Now().Truncate(time.Second).Sub(timestamp)
return session, age, nil
}
func (p *OAuthProxy) SaveSession(rw http.ResponseWriter, req *http.Request, s *providers.SessionState) error {
value, err := p.provider.CookieForSession(s, p.CookieCipher)
if err != nil {
return err
}
p.SetSessionCookie(rw, req, value)
return nil
}
func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter) {
rw.WriteHeader(http.StatusOK)
fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
}
func (p *OAuthProxy) PingPage(rw http.ResponseWriter) {
rw.WriteHeader(http.StatusOK)
fmt.Fprintf(rw, "OK")
}
func (p *OAuthProxy) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
log.Printf("ErrorPage %d %s %s", code, title, message)
rw.WriteHeader(code)
t := struct {
Title string
Message string
ProxyPrefix string
}{
Title: fmt.Sprintf("%d %s", code, title),
Message: message,
ProxyPrefix: p.ProxyPrefix,
}
p.templates.ExecuteTemplate(rw, "error.html", t)
}
func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code int) {
p.ClearSessionCookie(rw, req)
rw.WriteHeader(code)
redirect_url := req.URL.RequestURI()
if req.Header.Get("X-Auth-Request-Redirect") != "" {
redirect_url = req.Header.Get("X-Auth-Request-Redirect")
}
if redirect_url == p.SignInPath {
redirect_url = "/"
}
t := struct {
ProviderName string
SignInMessage string
CustomLogin bool
Redirect string
Version string
ProxyPrefix string
Footer template.HTML
}{
ProviderName: p.provider.Data().ProviderName,
SignInMessage: p.SignInMessage,
CustomLogin: p.displayCustomLoginForm(),
Redirect: redirect_url,
Version: VERSION,
ProxyPrefix: p.ProxyPrefix,
Footer: template.HTML(p.Footer),
}
p.templates.ExecuteTemplate(rw, "sign_in.html", t)
}
func (p *OAuthProxy) ManualSignIn(rw http.ResponseWriter, req *http.Request) (string, bool) {
if req.Method != "POST" || p.HtpasswdFile == nil {
return "", false
}
user := req.FormValue("username")
passwd := req.FormValue("password")
if user == "" {
return "", false
}
// check auth
if p.HtpasswdFile.Validate(user, passwd) {
log.Printf("authenticated %q via HtpasswdFile", user)
return user, true
}
return "", false
}
func (p *OAuthProxy) GetRedirect(req *http.Request) (redirect string, err error) {
err = req.ParseForm()
if err != nil {
return
}
redirect = req.Form.Get("rd")
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
return
}
func (p *OAuthProxy) IsWhitelistedRequest(req *http.Request) (ok bool) {
isPreflightRequestAllowed := p.skipAuthPreflight && req.Method == "OPTIONS"
return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path)
}
func (p *OAuthProxy) IsWhitelistedPath(path string) (ok bool) {
for _, u := range p.compiledRegex {
ok = u.MatchString(path)
if ok {
return
}
}
return
}
func getRemoteAddr(req *http.Request) (s string) {
s = req.RemoteAddr
if req.Header.Get("X-Real-IP") != "" {
s += fmt.Sprintf(" (%q)", req.Header.Get("X-Real-IP"))
}
return
}
func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
switch path := req.URL.Path; {
case path == p.RobotsPath:
p.RobotsTxt(rw)
case path == p.PingPath:
p.PingPage(rw)
case p.IsWhitelistedRequest(req):
p.serveMux.ServeHTTP(rw, req)
case path == p.SignInPath:
p.SignIn(rw, req)
case path == p.SignOutPath:
p.SignOut(rw, req)
case path == p.OAuthStartPath:
p.OAuthStart(rw, req)
case path == p.OAuthCallbackPath:
p.OAuthCallback(rw, req)
case path == p.AuthOnlyPath:
p.AuthenticateOnly(rw, req)
default:
p.Proxy(rw, req)
}
}
func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) {
redirect, err := p.GetRedirect(req)
if err != nil {
p.ErrorPage(rw, 500, "Internal Error", err.Error())
return
}
user, ok := p.ManualSignIn(rw, req)
if ok {
session := &providers.SessionState{User: user}
p.SaveSession(rw, req, session)
http.Redirect(rw, req, redirect, 302)
} else {
if p.SkipProviderButton {
p.OAuthStart(rw, req)
} else {
p.SignInPage(rw, req, http.StatusOK)
}
}
}
func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) {
p.ClearSessionCookie(rw, req)
http.Redirect(rw, req, "/", 302)
}
func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) {
nonce, err := cookie.Nonce()
if err != nil {
p.ErrorPage(rw, 500, "Internal Error", err.Error())
return
}
p.SetCSRFCookie(rw, req, nonce)
redirect, err := p.GetRedirect(req)
if err != nil {
p.ErrorPage(rw, 500, "Internal Error", err.Error())
return
}
redirectURI := p.GetRedirectURI(req.Host)
http.Redirect(rw, req, p.provider.GetLoginURL(redirectURI, fmt.Sprintf("%v:%v", nonce, redirect)), 302)
}
func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
remoteAddr := getRemoteAddr(req)
// finish the oauth cycle
err := req.ParseForm()
if err != nil {
p.ErrorPage(rw, 500, "Internal Error", err.Error())
return
}
errorString := req.Form.Get("error")
if errorString != "" {
p.ErrorPage(rw, 403, "Permission Denied", errorString)
return
}
session, err := p.redeemCode(req.Host, req.Form.Get("code"))
if err != nil {
log.Printf("%s error redeeming code %s", remoteAddr, err)
p.ErrorPage(rw, 500, "Internal Error", "Internal Error")
return
}
s := strings.SplitN(req.Form.Get("state"), ":", 2)
if len(s) != 2 {
p.ErrorPage(rw, 500, "Internal Error", "Invalid State")
return
}
nonce := s[0]
redirect := s[1]
c, err := req.Cookie(p.CSRFCookieName)
if err != nil {
p.ErrorPage(rw, 403, "Permission Denied", err.Error())
return
}
p.ClearCSRFCookie(rw, req)
if c.Value != nonce {
log.Printf("%s csrf token mismatch, potential attack", remoteAddr)
p.ErrorPage(rw, 403, "Permission Denied", "csrf failed")
return
}
if !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
// set cookie, or deny
if p.Validator(session.Email) && p.provider.ValidateGroup(session.Email) {
log.Printf("%s authentication complete %s", remoteAddr, session)
err := p.SaveSession(rw, req, session)
if err != nil {
log.Printf("%s %s", remoteAddr, err)
p.ErrorPage(rw, 500, "Internal Error", "Internal Error")
return
}
http.Redirect(rw, req, redirect, 302)
} else {
log.Printf("%s Permission Denied: %q is unauthorized", remoteAddr, session.Email)
p.ErrorPage(rw, 403, "Permission Denied", "Invalid Account")
}
}
func (p *OAuthProxy) AuthenticateOnly(rw http.ResponseWriter, req *http.Request) {
status := p.Authenticate(rw, req)
if status == http.StatusAccepted {
rw.WriteHeader(http.StatusAccepted)
} else {
http.Error(rw, "unauthorized request", http.StatusUnauthorized)
}
}
func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
status := p.Authenticate(rw, req)
if status == http.StatusInternalServerError {
p.ErrorPage(rw, http.StatusInternalServerError,
"Internal Error", "Internal Error")
} else if status == http.StatusForbidden {
if p.SkipProviderButton {
p.OAuthStart(rw, req)
} else {
p.SignInPage(rw, req, http.StatusForbidden)
}
} else {
p.serveMux.ServeHTTP(rw, req)
}
}
func (p *OAuthProxy) Authenticate(rw http.ResponseWriter, req *http.Request) int {
var saveSession, clearSession, revalidated bool
remoteAddr := getRemoteAddr(req)
session, sessionAge, err := p.LoadCookiedSession(req)
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
}
if ok, err := p.provider.RefreshSessionIfNeeded(session); err != nil {
log.Printf("%s removing session. error refreshing access token %s %s", remoteAddr, err, session)
clearSession = true
session = nil
} else if ok {
saveSession = true
revalidated = true
}
if session != nil && session.IsExpired() {
log.Printf("%s removing session. token expired %s", remoteAddr, session)
session = nil
saveSession = false
clearSession = true
}
if saveSession && !revalidated && session != nil && session.AccessToken != "" {
if !p.provider.ValidateSessionState(session) {
log.Printf("%s removing session. error validating %s", remoteAddr, session)
saveSession = false
session = nil
clearSession = true
}
}
if session != nil && session.Email != "" && !p.Validator(session.Email) {
log.Printf("%s Permission Denied: removing session %s", remoteAddr, session)
session = nil
saveSession = false
clearSession = true
}
if saveSession && session != nil {
err := p.SaveSession(rw, req, session)
if err != nil {
log.Printf("%s %s", remoteAddr, err)
return http.StatusInternalServerError
}
}
if clearSession {
p.ClearSessionCookie(rw, req)
}
if session == nil {
session, err = p.CheckBasicAuth(req)
if err != nil {
log.Printf("%s %s", remoteAddr, err)
}
}
if session == nil {
return http.StatusForbidden
}
// 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.PassUserHeaders {
req.Header["X-Forwarded-User"] = []string{session.User}
if session.Email != "" {
req.Header["X-Forwarded-Email"] = []string{session.Email}
}
}
if p.SetXAuthRequest {
rw.Header().Set("X-Auth-Request-User", session.User)
if session.Email != "" {
rw.Header().Set("X-Auth-Request-Email", session.Email)
}
}
if p.PassAccessToken && session.AccessToken != "" {
req.Header["X-Forwarded-Access-Token"] = []string{session.AccessToken}
}
if session.Email == "" {
rw.Header().Set("GAP-Auth", session.User)
} else {
rw.Header().Set("GAP-Auth", session.Email)
}
return http.StatusAccepted
}
func (p *OAuthProxy) CheckBasicAuth(req *http.Request) (*providers.SessionState, error) {
if p.HtpasswdFile == nil {
return nil, nil
}
auth := req.Header.Get("Authorization")
if auth == "" {
return nil, nil
}
s := strings.SplitN(auth, " ", 2)
if len(s) != 2 || s[0] != "Basic" {
return nil, fmt.Errorf("invalid Authorization header %s", req.Header.Get("Authorization"))
}
b, err := b64.StdEncoding.DecodeString(s[1])
if err != nil {
return nil, err
}
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
return nil, fmt.Errorf("invalid format %s", b)
}
if p.HtpasswdFile.Validate(pair[0], pair[1]) {
log.Printf("authenticated %q via basic auth", pair[0])
return &providers.SessionState{User: pair[0]}, nil
}
return nil, fmt.Errorf("%s not in HtpasswdFile", pair[0])
}