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 cace4f18a6
commit febc3e4fe3
1 changed files with 117 additions and 77 deletions

194
main.go
View File

@ -6,6 +6,9 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/golang/groupcache/lru"
"github.com/namsral/flag"
"github.com/pborman/uuid"
"log"
"net/http"
"net/http/httputil"
@ -13,65 +16,76 @@ import (
"os"
"strings"
"time"
"github.com/golang/groupcache/lru"
"github.com/namsral/flag"
"github.com/pborman/uuid"
)
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
AllowAllPtr *bool
}
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")
allowAllPtr := flag.Bool("allow-all", false, "allow all discourse users (default: admin users only)")
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.AllowAllPtr = flag.Bool("allow-all", false, "allow all discourse users (default: admin users only)")
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, *allowAllPtr)
handler := authProxyHandler(proxy, config)
server := &http.Server{
Addr: *listenUriPtr,
Addr: *config.ListenUriPtr,
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@ -81,69 +95,95 @@ func main() {
log.Fatal(server.ListenAndServe())
}
func redirectIfCookieMissing(handler http.Handler, ssoSecret, cookieSecret, ssoUri, proxyHost string, allowAll bool) http.Handler {
func authProxyHandler(handler http.Handler, config *Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("__discourse_proxy")
var username string
if err == nil && cookie != nil {
username, err = parseCookie(cookie.Value, cookieSecret)
}
if err == nil {
r.Header.Set("Discourse-User-Name", username)
handler.ServeHTTP(w, r)
if checkAuthorizationHeader(handler, r, w, config) {
return
}
query := r.URL.Query()
sso := query.Get("sso")
sig := query.Get("sig")
if len(sso) == 0 {
url := ssoUri + "/session/sso_provider?" + sso_payload(ssoSecret, proxyHost, r.URL.String())
http.Redirect(w, r, url, 302)
} else {
decoded, _ := base64.StdEncoding.DecodeString(sso)
decodedString := string(decoded)
parsedQuery, _ := url.ParseQuery(decodedString)
username := parsedQuery["username"]
admin := parsedQuery["admin"]
nonce := parsedQuery["nonce"]
if len(nonce) > 0 && len(username) > 0 {
if allowAll == false {
if len(admin) < 1 || admin[0] != "true" {
log.Println("Rejecting access to non-admin user ", username)
w.Write([]byte(fmt.Sprintf("auth-proxy access is restricted to admin users, and %s is not an admin", username)))
return
}
log.Println("Granting access to admin user ", username)
}
returnUrl, err := getReturnUrl(ssoSecret, sso, sig, nonce[0])
if err != nil {
fmt.Fprintf(w, "Invalid request")
return
}
// 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}
http.SetCookie(w, &cookie)
// works around weird safari stuff
fmt.Fprintf(w, "<html><head></head><body><script>window.location = '%v'</script></body>", returnUrl)
}
}
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, config.CookieSecret)
}
if err == nil {
r.Header.Set(*config.UsernameHeaderPtr, username)
handler.ServeHTTP(w, r)
return
}
query := r.URL.Query()
sso := query.Get("sso")
sig := query.Get("sig")
if len(sso) == 0 {
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)
decodedString := string(decoded)
parsedQuery, _ := url.ParseQuery(decodedString)
username := parsedQuery["username"]
admin := parsedQuery["admin"]
nonce := parsedQuery["nonce"]
if len(nonce) > 0 && len(admin) > 0 && len(username) > 0 && admin[0] == "true" {
returnUrl, err := getReturnUrl(*config.SsoSecretPtr, sso, sig, nonce[0])
if err != nil {
fmt.Fprintf(w, "Invalid request")
return
}
// we have a valid auth
expiration := time.Now().Add(365 * 24 * time.Hour)
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) {
value, gotNonce := nonceCache.Get(nonce)
returnUrl = value.(string)