Support HTTP basic auth, allow username header name to be overridden

The big change here is to support an extremely limited form of HTTP basic
auth, for those situations when you've got some subset of requests coming in
which still need to be authenticated, but which aren't able to authenticate
via Discourse SSO.  The intended use case is for webhooks and other
progammatic access methods.  It is not intended to be a fully-featured HTTP
auth method (it only supports a single hard-coded user/password pair), but
instead an extremely simplistic "escape hatch".

If you need more complicated HTTP authentication, you probably want to
install nginx and do some crazy proxy chain games.  Best of luck to you with
that.

To avoid getting in the way of the SSO flow, the HTTP authentication is done
"blind"; that is, a `WWW-Authenticate` is never sent in a response.  This
may get up the nose of some user agents, however I can't see an easy way
around this.

Allowing the username header to be changed to something other than
Discourse-User-Name is a smaller change, needed to support third-party
software which looks for the authenticated username in a different header,
and which can't be overridden without a hammer and chisel.
This commit is contained in:
Matt Palmer 2017-05-28 16:51:27 +10:00
parent cfa7d348a2
commit c52cfa2a6b
1 changed files with 114 additions and 63 deletions

99
main.go
View File

@ -6,9 +6,9 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/pborman/uuid"
"github.com/golang/groupcache/lru"
"github.com/namsral/flag"
"github.com/pborman/uuid"
"log"
"net/http"
"net/http/httputil"
@ -20,56 +20,70 @@ import (
var nonceCache = lru.New(20)
func main() {
type Config struct {
ListenUriPtr *string
ProxyUriPtr *string
OriginUriPtr *string
SsoSecretPtr *string
SsoUriPtr *string
BasicAuthPtr *string
UsernameHeaderPtr *string
CookieSecret string
}
listenUriPtr := flag.String("listen-url", "", "uri to listen on eg: localhost:2001. leave blank to set equal to proxy-url")
proxyUriPtr := flag.String("proxy-url", "", "outer url of this host eg: http://secrets.example.com")
originUriPtr := flag.String("origin-url", "", "origin to proxy eg: http://localhost:2002")
ssoSecretPtr := flag.String("sso-secret", "", "SSO secret for origin")
ssoUriPtr := flag.String("sso-url", "", "SSO endpoint eg: http://discourse.forum.com")
func main() {
config := new(Config)
config.ListenUriPtr = flag.String("listen-url", "", "uri to listen on eg: localhost:2001. leave blank to set equal to proxy-url")
config.ProxyUriPtr = flag.String("proxy-url", "", "outer url of this host eg: http://secrets.example.com")
config.OriginUriPtr = flag.String("origin-url", "", "origin to proxy eg: http://localhost:2002")
config.SsoSecretPtr = flag.String("sso-secret", "", "SSO secret for origin")
config.SsoUriPtr = flag.String("sso-url", "", "SSO endpoint eg: http://discourse.forum.com")
config.BasicAuthPtr = flag.String("basic-auth", "", "HTTP Basic authentication credentials to let through directly")
config.UsernameHeaderPtr = flag.String("username-header", "Discourse-User-Name", "Request header to pass authenticated username into")
flag.Parse()
originUrl, err := url.Parse(*originUriPtr)
originUrl, err := url.Parse(*config.OriginUriPtr)
if err != nil {
flag.Usage()
log.Fatal("invalid origin url")
}
_, err = url.Parse(*ssoUriPtr)
_, err = url.Parse(*config.SsoUriPtr)
if err != nil {
flag.Usage()
log.Fatal("invalid sso url, should point at Discourse site with enable sso")
}
proxyUrl, err2 := url.Parse(*proxyUriPtr)
proxyUrl, err2 := url.Parse(*config.ProxyUriPtr)
if err2 != nil {
flag.Usage()
log.Fatal("invalid proxy uri")
}
if *listenUriPtr == "" {
if *config.ListenUriPtr == "" {
log.Println("Defaulting to listening on the proxy url")
*listenUriPtr = proxyUrl.Host
*config.ListenUriPtr = proxyUrl.Host
}
if *proxyUriPtr == "" || *originUriPtr == "" || *ssoSecretPtr == "" || *ssoUriPtr == "" || *listenUriPtr == "" {
if *config.ProxyUriPtr == "" || *config.OriginUriPtr == "" || *config.SsoSecretPtr == "" || *config.SsoUriPtr == "" || *config.ListenUriPtr == "" {
flag.Usage()
os.Exit(1)
return
}
cookieSecret := uuid.New()
config.CookieSecret = uuid.New()
proxy := httputil.NewSingleHostReverseProxy(originUrl)
handler := redirectIfCookieMissing(proxy, *ssoSecretPtr, cookieSecret, *ssoUriPtr, *proxyUriPtr)
handler := authProxyHandler(proxy, config)
server := &http.Server{
Addr: *listenUriPtr,
Addr: *config.ListenUriPtr,
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@ -79,18 +93,56 @@ func main() {
log.Fatal(server.ListenAndServe())
}
func redirectIfCookieMissing(handler http.Handler, ssoSecret, cookieSecret, ssoUri, proxyHost string) http.Handler {
func authProxyHandler(handler http.Handler, config *Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if checkAuthorizationHeader(handler, r, w, config) {
return
}
redirectIfNoCookie(handler, r, w, config)
})
}
func checkAuthorizationHeader(handler http.Handler, r *http.Request, w http.ResponseWriter, config *Config) bool {
if *config.BasicAuthPtr == "" {
// Can't auth if we don't have anything to auth against
return false
}
auth_header := r.Header.Get("Authorization")
if len(auth_header) < 6 {
return false
}
if auth_header[0:6] == "Basic " {
b_creds, _ := base64.StdEncoding.DecodeString(auth_header[6:])
creds := string(b_creds)
if creds == *config.BasicAuthPtr {
colon_idx := strings.Index(creds, ":")
if colon_idx == -1 {
return false
}
username := creds[0:colon_idx]
r.Header.Set(*config.UsernameHeaderPtr, username)
r.Header.Del("Authorization")
handler.ServeHTTP(w, r)
return true
}
}
return false
}
func redirectIfNoCookie(handler http.Handler, r *http.Request, w http.ResponseWriter, config *Config) {
cookie, err := r.Cookie("__discourse_proxy")
var username string
if err == nil && cookie != nil {
username, err = parseCookie(cookie.Value, cookieSecret)
username, err = parseCookie(cookie.Value, config.CookieSecret)
}
if err == nil {
r.Header.Set("Discourse-User-Name", username)
r.Header.Set(*config.UsernameHeaderPtr, username)
handler.ServeHTTP(w, r)
return
}
@ -100,7 +152,7 @@ func redirectIfCookieMissing(handler http.Handler, ssoSecret, cookieSecret, ssoU
sig := query.Get("sig")
if len(sso) == 0 {
url := ssoUri + "/session/sso_provider?" + sso_payload(ssoSecret, proxyHost, r.URL.String())
url := *config.SsoUriPtr + "/session/sso_provider?" + sso_payload(*config.SsoSecretPtr, *config.ProxyUriPtr, r.URL.String())
http.Redirect(w, r, url, 302)
} else {
decoded, _ := base64.StdEncoding.DecodeString(sso)
@ -112,7 +164,7 @@ func redirectIfCookieMissing(handler http.Handler, ssoSecret, cookieSecret, ssoU
nonce := parsedQuery["nonce"]
if len(nonce) > 0 && len(admin) > 0 && len(username) > 0 && admin[0] == "true" {
returnUrl, err := getReturnUrl(ssoSecret, sso, sig, nonce[0])
returnUrl, err := getReturnUrl(*config.SsoSecretPtr, sso, sig, nonce[0])
if err != nil {
fmt.Fprintf(w, "Invalid request")
@ -122,14 +174,13 @@ func redirectIfCookieMissing(handler http.Handler, ssoSecret, cookieSecret, ssoU
// we have a valid auth
expiration := time.Now().Add(365 * 24 * time.Hour)
cookie := http.Cookie{Name: "__discourse_proxy", Value: signCookie(username[0], cookieSecret), Expires: expiration, HttpOnly: true}
cookie := http.Cookie{Name: "__discourse_proxy", Value: signCookie(username[0], config.CookieSecret), Expires: expiration, HttpOnly: true}
http.SetCookie(w, &cookie)
// works around weird safari stuff
fmt.Fprintf(w, "<html><head></head><body><script>window.location = '%v'</script></body>", returnUrl)
}
}
})
}
func getReturnUrl(secret string, payload string, sig string, nonce string) (returnUrl string, err error) {