Add auth host feature

Allow central host for use as base for redirect_uri

Closes #3
This commit is contained in:
Thom Seddon 2018-10-29 17:39:36 +00:00
parent b3716d401e
commit d230572879
5 changed files with 297 additions and 34 deletions

View File

@ -24,10 +24,11 @@ The following configuration is supported:
|-----------------------|------|-----------| |-----------------------|------|-----------|
|-client-id|string|*Google Client ID (required)| |-client-id|string|*Google Client ID (required)|
|-client-secret|string|*Google Client Secret (required)| |-client-secret|string|*Google Client Secret (required)|
|-secret|string|*Secret used for signing (required)|
|-config|string|Path to config file| |-config|string|Path to config file|
|-cookie-domains|string|Comma separated list of cookie domains| |-auth-host|string|Central auth login (see below)|
|-cookie-domains|string|Comma separated list of cookie domains (see below)|
|-cookie-name|string|Cookie Name (default "_forward_auth")| |-cookie-name|string|Cookie Name (default "_forward_auth")|
|-cookie-secret|string|*Cookie secret (required)|
|-cookie-secure|bool|Use secure cookies (default true)| |-cookie-secure|bool|Use secure cookies (default true)|
|-csrf-cookie-name|string|CSRF Cookie Name (default "_forward_auth_csrf")| |-csrf-cookie-name|string|CSRF Cookie Name (default "_forward_auth_csrf")|
|-direct|bool|Run in direct mode (use own hostname as oppose to <br>X-Forwarded-Host, used for testing/development) |-direct|bool|Run in direct mode (use own hostname as oppose to <br>X-Forwarded-Host, used for testing/development)
@ -55,6 +56,39 @@ For example, if cookie domain is `test.com` and a request comes in on `app1.test
Beware however, if using cookie domains whilst running multiple instances of traefik/traefik-forward-auth for the same domain, the cookies will clash. You can fix this by using the same `cookie-secret` in both instances, or using a different `cookie-name` on each. Beware however, if using cookie domains whilst running multiple instances of traefik/traefik-forward-auth for the same domain, the cookies will clash. You can fix this by using the same `cookie-secret` in both instances, or using a different `cookie-name` on each.
## Operation Modes
#### Overlay
Overlay is the default operation mode, in this mode the authorisation endpoint is overlayed onto any domain. By default the `/_oauth` path is used, this can be customised using the `-url-path` option.
If a request comes in for `www.myapp.com/home` then the user will be redirected to the google login, following this they will be sent back to `www.myapp.com/_oauth`, where their token will be validated (this request will not be forwarded to your application). Following successful authoristion, the user will return to their originally requested url of `www.myapp.com/home`.
As the hostname in the `redirect_uri` is dynamically generated based on the orignal request, every hostname must be permitted in the Google OAuth console (e.g. `www.myappp.com` would need to be added in the above example)
#### Auth Host
This is an optional mode of operation that is useful when dealing with a large number of subdomains, it is activated by using the `-auth-host` config option.
For example, if you have a few applications: `app1.test.com`, `app2.test.com`, `appN.test.com`, adding every domain to Google's console can become laborious.
To utilise an auth host, permit domain level cookies by setting the cookie domain to `test.com` then set the `auth-host` to: `auth.test.com`.
The user flow will then be:
1. Request to `app10.test.com/home/page`
2. User redirected to Google login
3. After Google login, user is redirected to `auth.test.com/_oauth`
4. Token, user and CSRF cookie is validated, auth cookie is set to `test.com`
5. User is redirected to `app10.test.com/home/page`
6. Request is allowed
With this setup, only `auth.test.com` must be permitted in the Google console.
Two criteria must be met for an `auth-host` to be used:
1. Request matches given `cookie-domain`
2. `auth-host` is also subdomain of same `cookie-domain`
## Copyright ## Copyright
2018 Thom Seddon 2018 Thom Seddon

View File

@ -20,6 +20,7 @@ import (
type ForwardAuth struct { type ForwardAuth struct {
Path string Path string
Lifetime time.Duration Lifetime time.Duration
Secret []byte
ClientId string ClientId string
ClientSecret string ClientSecret string
@ -29,10 +30,11 @@ type ForwardAuth struct {
TokenURL *url.URL TokenURL *url.URL
UserURL *url.URL UserURL *url.URL
AuthHost string
CookieName string CookieName string
CookieDomains []CookieDomain CookieDomains []CookieDomain
CSRFCookieName string CSRFCookieName string
CookieSecret []byte
CookieSecure bool CookieSecure bool
Domain []string Domain []string
@ -210,9 +212,30 @@ func (f *ForwardAuth) returnUrl(r *http.Request) string {
// Get oauth redirect uri // Get oauth redirect uri
func (f *ForwardAuth) redirectUri(r *http.Request) string { func (f *ForwardAuth) redirectUri(r *http.Request) string {
if use, _ := f.useAuthDomain(r); use {
proto := r.Header.Get("X-Forwarded-Proto")
return fmt.Sprintf("%s://%s%s", proto, f.AuthHost, f.Path)
}
return fmt.Sprintf("%s%s", f.redirectBase(r), f.Path) return fmt.Sprintf("%s%s", f.redirectBase(r), f.Path)
} }
// Should we use auth host + what it is
func (f *ForwardAuth) useAuthDomain(r *http.Request) (bool, string) {
if f.AuthHost == "" {
return false, ""
}
// Does the request match a given cookie domain?
reqMatch, reqHost := f.matchCookieDomains(r.Header.Get("X-Forwarded-Host"))
// Do any of the auth hosts match a cookie domain?
authMatch, authHost := f.matchCookieDomains(f.AuthHost)
// We need both to match the same domain
return reqMatch && authMatch && reqHost == authHost, reqHost
}
// Cookie methods // Cookie methods
// Create an auth cookie // Create an auth cookie
@ -238,7 +261,7 @@ func (f *ForwardAuth) MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie
Name: f.CSRFCookieName, Name: f.CSRFCookieName,
Value: nonce, Value: nonce,
Path: "/", Path: "/",
Domain: f.cookieDomain(r), Domain: f.csrfCookieDomain(r),
HttpOnly: true, HttpOnly: true,
Secure: f.CookieSecure, Secure: f.CookieSecure,
Expires: f.cookieExpiry(), Expires: f.cookieExpiry(),
@ -251,7 +274,7 @@ func (f *ForwardAuth) ClearCSRFCookie(r *http.Request) *http.Cookie {
Name: f.CSRFCookieName, Name: f.CSRFCookieName,
Value: "", Value: "",
Path: "/", Path: "/",
Domain: f.cookieDomain(r), Domain: f.csrfCookieDomain(r),
HttpOnly: true, HttpOnly: true,
Secure: f.CookieSecure, Secure: f.CookieSecure,
Expires: time.Now().Local().Add(time.Hour * -1), Expires: time.Now().Local().Add(time.Hour * -1),
@ -297,22 +320,44 @@ func (f *ForwardAuth) cookieDomain(r *http.Request) string {
host = r.Host host = r.Host
} }
// Remove port for matching
p := strings.Split(host, ":")
// Check if any of the given cookie domains matches // Check if any of the given cookie domains matches
for _, domain := range f.CookieDomains { _, domain := f.matchCookieDomains(host)
if domain.Match(p[0]) { return domain
return domain.Domain }
// Cookie domain
func (f *ForwardAuth) csrfCookieDomain(r *http.Request) string {
var host string
if use, domain := f.useAuthDomain(r); use {
host = domain
} else if f.Direct {
host = r.Host
} else {
host = r.Header.Get("X-Forwarded-Host")
}
// Remove port
p := strings.Split(host, ":")
return p[0]
}
// Return matching cookie domain if exists
func (f *ForwardAuth) matchCookieDomains(domain string) (bool, string) {
// Remove port
p := strings.Split(domain, ":")
for _, d := range f.CookieDomains {
if d.Match(p[0]) {
return true, d.Domain
} }
} }
return p[0] return false, p[0]
} }
// Create cookie hmac // Create cookie hmac
func (f *ForwardAuth) cookieSignature(r *http.Request, email, expires string) string { func (f *ForwardAuth) cookieSignature(r *http.Request, email, expires string) string {
hash := hmac.New(sha256.New, f.CookieSecret) hash := hmac.New(sha256.New, f.Secret)
hash.Write([]byte(f.cookieDomain(r))) hash.Write([]byte(f.cookieDomain(r)))
hash.Write([]byte(email)) hash.Write([]byte(email))
hash.Write([]byte(expires)) hash.Write([]byte(expires))

View File

@ -84,6 +84,11 @@ func TestValidateEmail(t *testing.T) {
} }
func TestGetLoginURL(t *testing.T) { func TestGetLoginURL(t *testing.T) {
r, _ := http.NewRequest("GET", "http://example.com", nil)
r.Header.Add("X-Forwarded-Proto", "http")
r.Header.Add("X-Forwarded-Host", "example.com")
r.Header.Add("X-Forwarded-Uri", "/hello")
fw = &ForwardAuth{ fw = &ForwardAuth{
Path: "/_oauth", Path: "/_oauth",
ClientId: "idtest", ClientId: "idtest",
@ -95,10 +100,6 @@ func TestGetLoginURL(t *testing.T) {
Path: "/auth", Path: "/auth",
}, },
} }
r, _ := http.NewRequest("GET", "http://example.com", nil)
r.Header.Add("X-Forwarded-Proto", "http")
r.Header.Add("X-Forwarded-Host", "example.com")
r.Header.Add("X-Forwarded-Uri", "/hello")
// Check url // Check url
uri, err := url.Parse(fw.GetLoginURL(r, "nonce")) uri, err := url.Parse(fw.GetLoginURL(r, "nonce"))
@ -125,14 +126,145 @@ func TestGetLoginURL(t *testing.T) {
"state": []string{"nonce:http://example.com/hello"}, "state": []string{"nonce:http://example.com/hello"},
} }
if !reflect.DeepEqual(qs, expectedQs) { if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string, expected:") t.Error("Incorrect login query string:")
t.Error(expectedQs) qsDiff(expectedQs, qs)
t.Error("Got:") }
t.Error(qs)
//
// With Auth URL but no matching cookie domain
// - will not use auth host
//
fw = &ForwardAuth{
Path: "/_oauth",
AuthHost: "auth.example.com",
ClientId: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
LoginURL: &url.URL{
Scheme: "https",
Host: "test.com",
Path: "/auth",
},
}
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://example.com/hello"},
}
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
//
// With correct Auth URL + cookie domain
//
cookieDomain := NewCookieDomain("example.com")
fw = &ForwardAuth{
Path: "/_oauth",
AuthHost: "auth.example.com",
ClientId: "idtest",
ClientSecret: "sectest",
Scope: "scopetest",
LoginURL: &url.URL{
Scheme: "https",
Host: "test.com",
Path: "/auth",
},
CookieDomains: []CookieDomain{*cookieDomain},
}
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://auth.example.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://example.com/hello"},
}
qsDiff(expectedQs, qs)
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
}
//
// With Auth URL + cookie domain, but from different domain
// - will not use auth host
//
r, _ = http.NewRequest("GET", "http://another.com", nil)
r.Header.Add("X-Forwarded-Proto", "http")
r.Header.Add("X-Forwarded-Host", "another.com")
r.Header.Add("X-Forwarded-Uri", "/hello")
// Check url
uri, err = url.Parse(fw.GetLoginURL(r, "nonce"))
if err != nil {
t.Error("Error parsing login url:", err)
}
if uri.Scheme != "https" {
t.Error("Expected login Scheme to be \"https\", got:", uri.Scheme)
}
if uri.Host != "test.com" {
t.Error("Expected login Host to be \"test.com\", got:", uri.Host)
}
if uri.Path != "/auth" {
t.Error("Expected login Path to be \"/auth\", got:", uri.Path)
}
// Check query string
qs = uri.Query()
expectedQs = url.Values{
"client_id": []string{"idtest"},
"redirect_uri": []string{"http://another.com/_oauth"},
"response_type": []string{"code"},
"scope": []string{"scopetest"},
"state": []string{"nonce:http://another.com/hello"},
}
qsDiff(expectedQs, qs)
if !reflect.DeepEqual(qs, expectedQs) {
t.Error("Incorrect login query string:")
qsDiff(expectedQs, qs)
} }
} }
// TODO // TODO
// func TestExchangeCode(t *testing.T) { // func TestExchangeCode(t *testing.T) {
// } // }
@ -145,9 +277,35 @@ func TestGetLoginURL(t *testing.T) {
// func TestMakeCookie(t *testing.T) { // func TestMakeCookie(t *testing.T) {
// } // }
// func TestMakeCSRFCookie(t *testing.T) { func TestMakeCSRFCookie(t *testing.T) {
// t.Log("TODO") r, _ := http.NewRequest("GET", "http://app.example.com", nil)
// } r.Header.Add("X-Forwarded-Host", "app.example.com")
// No cookie domain or auth url
fw = &ForwardAuth{}
c := fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "app.example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
// With cookie domain but no auth url
cookieDomain := NewCookieDomain("example.com")
fw = &ForwardAuth{CookieDomains: []CookieDomain{*cookieDomain},}
c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "app.example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
// With cookie domain and auth url
fw = &ForwardAuth{
AuthHost: "auth.example.com",
CookieDomains: []CookieDomain{*cookieDomain},
}
c = fw.MakeCSRFCookie(r, "12345678901234567890123456789012")
if c.Domain != "example.com" {
t.Error("Cookie Domain should match request domain, got:", c.Domain)
}
}
func TestClearCSRFCookie(t *testing.T) { func TestClearCSRFCookie(t *testing.T) {
fw = &ForwardAuth{} fw = &ForwardAuth{}

28
main.go
View File

@ -37,6 +37,7 @@ func handler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get auth cookie
c, err := r.Cookie(fw.CookieName) c, err := r.Cookie(fw.CookieName)
if err != nil { if err != nil {
// Error indicates no cookie, generate nonce // Error indicates no cookie, generate nonce
@ -130,33 +131,40 @@ func main() {
flag.String(flag.DefaultConfigFlagname, "", "Path to config file") flag.String(flag.DefaultConfigFlagname, "", "Path to config file")
path := flag.String("url-path", "_oauth", "Callback URL") path := flag.String("url-path", "_oauth", "Callback URL")
lifetime := flag.Int("lifetime", 43200, "Session length in seconds") lifetime := flag.Int("lifetime", 43200, "Session length in seconds")
secret := flag.String("secret", "", "*Secret used for signing (required)")
authHost := flag.String("auth-host", "", "Central auth login")
clientId := flag.String("client-id", "", "*Google Client ID (required)") clientId := flag.String("client-id", "", "*Google Client ID (required)")
clientSecret := flag.String("client-secret", "", "*Google Client Secret (required)") clientSecret := flag.String("client-secret", "", "*Google Client Secret (required)")
cookieName := flag.String("cookie-name", "_forward_auth", "Cookie Name") cookieName := flag.String("cookie-name", "_forward_auth", "Cookie Name")
cSRFCookieName := flag.String("csrf-cookie-name", "_forward_auth_csrf", "CSRF Cookie Name") cSRFCookieName := flag.String("csrf-cookie-name", "_forward_auth_csrf", "CSRF Cookie Name")
cookieDomainList := flag.String("cookie-domains", "", "Comma separated list of cookie domains") //todo cookieDomainList := flag.String("cookie-domains", "", "Comma separated list of cookie domains") //todo
cookieSecret := flag.String("cookie-secret", "", "*Cookie secret (required)") cookieSecret := flag.String("cookie-secret", "", "depreciated")
cookieSecure := flag.Bool("cookie-secure", true, "Use secure cookies") cookieSecure := flag.Bool("cookie-secure", true, "Use secure cookies")
domainList := flag.String("domain", "", "Comma separated list of email domains to allow") domainList := flag.String("domain", "", "Comma separated list of email domains to allow")
direct := flag.Bool("direct", false, "Run in direct mode (use own hostname as oppose to X-Forwarded-Host, used for testing/development)") direct := flag.Bool("direct", false, "Run in direct mode (use own hostname as oppose to X-Forwarded-Host, used for testing/development)")
flag.Parse() flag.Parse()
// Backwards compatability
if *secret == "" && *cookieSecret != "" {
*secret = *cookieSecret
}
// Check for show stopper errors // Check for show stopper errors
err := false stop := false
if *clientId == "" { if *clientId == "" {
err = true stop = true
log.Critical("client-id must be set") log.Critical("client-id must be set")
} }
if *clientSecret == "" { if *clientSecret == "" {
err = true stop = true
log.Critical("client-secret must be set") log.Critical("client-secret must be set")
} }
if *cookieSecret == "" { if *secret == "" {
err = true stop = true
log.Critical("cookie-secret must be set") log.Critical("secret must be set")
} }
if err { if stop {
return return
} }
@ -178,6 +186,8 @@ func main() {
fw = &ForwardAuth{ fw = &ForwardAuth{
Path: fmt.Sprintf("/%s", *path), Path: fmt.Sprintf("/%s", *path),
Lifetime: time.Second * time.Duration(*lifetime), Lifetime: time.Second * time.Duration(*lifetime),
Secret: []byte(*secret),
AuthHost: *authHost,
ClientId: *clientId, ClientId: *clientId,
ClientSecret: *clientSecret, ClientSecret: *clientSecret,
@ -201,7 +211,6 @@ func main() {
CookieName: *cookieName, CookieName: *cookieName,
CSRFCookieName: *cSRFCookieName, CSRFCookieName: *cSRFCookieName,
CookieDomains: cookieDomains, CookieDomains: cookieDomains,
CookieSecret: []byte(*cookieSecret),
CookieSecure: *cookieSecure, CookieSecure: *cookieSecure,
Domain: domain, Domain: domain,
@ -212,6 +221,7 @@ func main() {
// Attach handler // Attach handler
http.HandleFunc("/", handler) http.HandleFunc("/", handler)
log.Debugf("Staring with options: %#v", fw)
log.Notice("Litening on :4181") log.Notice("Litening on :4181")
log.Notice(http.ListenAndServe(":4181", nil)) log.Notice(http.ListenAndServe(":4181", nil))
} }

View File

@ -63,6 +63,22 @@ func newHttpRequest(uri string) *http.Request {
return r return r
} }
func qsDiff(one, two url.Values) {
for k, _ := range one {
if two.Get(k) == "" {
fmt.Printf("Key missing: %s\n", k)
}
if one.Get(k) != two.Get(k) {
fmt.Printf("Value different for %s: expected: '%s' got: '%s'\n", k, one.Get(k), two.Get(k))
}
}
for k, _ := range two {
if one.Get(k) == "" {
fmt.Printf("Extra key: %s\n", k)
}
}
}
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
fw = &ForwardAuth{ fw = &ForwardAuth{
Path: "_oauth", Path: "_oauth",