Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add external authorization via http request #830

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ import (
"github.com/fabiolb/fabio/config"
)

type AuthDecision struct {
Authorized bool
Done bool
}

func authorized() AuthDecision {
return AuthDecision{Authorized: true, Done: false}
}

func unauthorized() AuthDecision {
return AuthDecision{Authorized: false, Done: false}
}

type AuthScheme interface {
Authorized(request *http.Request, response http.ResponseWriter) bool
Authorized(request *http.Request, response http.ResponseWriter) AuthDecision
}

func LoadAuthSchemes(cfg map[string]config.AuthScheme) (map[string]AuthScheme, error) {
Expand All @@ -21,6 +34,12 @@ func LoadAuthSchemes(cfg map[string]config.AuthScheme) (map[string]AuthScheme, e
return nil, err
}
auths[a.Name] = b
case "external":
d, err := newExternalAuth(a.External)
if err != nil {
return nil, err
}
auths[a.Name] = d
default:
return nil, fmt.Errorf("unknown auth type '%s'", a.Type)
}
Expand Down
10 changes: 7 additions & 3 deletions auth/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,17 @@ func newBasicAuth(cfg config.BasicAuth) (AuthScheme, error) {
}, nil
}

func (b *basic) Authorized(request *http.Request, response http.ResponseWriter) bool {
func (b *basic) Authorized(request *http.Request, response http.ResponseWriter) AuthDecision {
user, password, ok := request.BasicAuth()

if !ok {
response.Header().Set("WWW-Authenticate", "Basic realm=\""+b.realm+"\"")
return false
return unauthorized()
}

return b.secrets.Match(user, password)
if b.secrets.Match(user, password) {
return authorized()
} else {
return unauthorized()
}
}
6 changes: 3 additions & 3 deletions auth/basic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func TestBasic_Authorised(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got, want := basicAuth.Authorized(tt.req, tt.res), tt.out; !reflect.DeepEqual(got, want) {
if got, want := basicAuth.Authorized(tt.req, tt.res), tt.out; !reflect.DeepEqual(got.Authorized, want) {
t.Errorf("got %v want %v", got, want)
}
})
Expand Down Expand Up @@ -194,7 +194,7 @@ func TestBasic_Authorised_should_fail_without_htpasswd_file(t *testing.T) {
w := &responseWriter{}

t.Run("should authorize against supplied htpasswd file", func(t *testing.T) {
if got, want := a.Authorized(r, w), true; !reflect.DeepEqual(got, want) {
if got, want := a.Authorized(r, w), true; !reflect.DeepEqual(got.Authorized, want) {
t.Errorf("got %v want %v", got, want)
}
})
Expand All @@ -206,7 +206,7 @@ func TestBasic_Authorised_should_fail_without_htpasswd_file(t *testing.T) {
time.Sleep(2 * time.Second) // ensure htpasswd file refresh happend

t.Run("should not authorize after removing htpasswd file", func(t *testing.T) {
if got, want := a.Authorized(r, w), false; !reflect.DeepEqual(got, want) {
if got, want := a.Authorized(r, w), false; !reflect.DeepEqual(got.Authorized, want) {
t.Errorf("got %v want %v", got, want)
}
})
Expand Down
63 changes: 63 additions & 0 deletions auth/external.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package auth

import (
"bytes"
"log"
"net/http"

"github.com/fabiolb/fabio/config"
)

type external struct {
endpoint string
appendAuthHeaders []string
setAuthHeaders []string
}

func newExternalAuth(cfg config.ExternalAuth) (AuthScheme, error) {
return &external{
endpoint: cfg.Endpoint,
appendAuthHeaders: cfg.AppendAuthHeaders,
setAuthHeaders: cfg.SetAuthHeaders,
}, nil
}

func (b *external) Authorized(request *http.Request, response http.ResponseWriter) AuthDecision {
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
Copy link
Member

@nathanejohnson nathanejohnson Nov 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This client should specify a timeout at least, and possibly a transport too depending on whether tls configurations would need to be overridden. Also, it would be much better to store this client inside the external struct instead of recreating it each time.


authRequest, err := http.NewRequest(request.Method, b.endpoint+request.URL.RequestURI(), bytes.NewReader(nil))
if err != nil {
log.Println("[ERROR] Can't make auth external request value:", err.Error())
return unauthorized()
}
authRequest.Header = request.Header
authRequest.Host = request.Host

resp, err := client.Do(authRequest)
if err != nil {
log.Println("[ERROR] External request error:", err.Error())
return unauthorized()
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resp.Body is never drained or closed. if err is nil, body needs to be dealt with.

if resp.StatusCode == 200 {
for _, header := range b.setAuthHeaders {
response.Header().Set(header, resp.Header.Get(header))
}
for _, header := range b.appendAuthHeaders {
response.Header().Add(header, resp.Header.Get(header))
}
return authorized()
}

if resp.StatusCode == 302 {
http.Redirect(response, request, resp.Header.Get("location"), 302)
return AuthDecision{Authorized: false, Done: true}
}

if resp.StatusCode != 401 {
log.Println("[WARN] Unexpected status code", resp.StatusCode, "treated as unauthorized")
return unauthorized()
}

return unauthorized()
}
13 changes: 10 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,10 @@ type Tracing struct {
}

type AuthScheme struct {
Name string
Type string
Basic BasicAuth
Name string
Type string
Basic BasicAuth
External ExternalAuth
}

type BasicAuth struct {
Expand All @@ -195,6 +196,12 @@ type BasicAuth struct {
ModTime time.Time // the htpasswd file last modification time
}

type ExternalAuth struct {
Endpoint string
AppendAuthHeaders []string
SetAuthHeaders []string
}

type ConsulTlS struct {
KeyFile string
CertFile string
Expand Down
17 changes: 17 additions & 0 deletions config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"regexp"
"runtime"
"strconv"
Expand Down Expand Up @@ -689,7 +690,23 @@ func parseAuthScheme(cfg map[string]string) (a AuthScheme, err error) {
}
a.Basic.Refresh = d
}
case "external":
a.External = ExternalAuth{
Endpoint: cfg["endpoint"],
AppendAuthHeaders: strings.Split(cfg["append-auth-headers"], ","),
SetAuthHeaders: strings.Split(cfg["set-auth-headers"], ","),
}

if strings.HasSuffix(a.External.Endpoint, "/") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious to the reasoning for this restriction?

return AuthScheme{}, errors.New("decision auth endpoint should not end with /")
}
if _, err := url.Parse(a.External.Endpoint); err != nil {
return AuthScheme{}, err
}

if a.External.Endpoint == "" {
return AuthScheme{}, fmt.Errorf("missing 'endpoint' in auth '%s'", a.Name)
}
default:
return AuthScheme{}, fmt.Errorf("unknown auth type '%s'", a.Type)
}
Expand Down
11 changes: 11 additions & 0 deletions docs/content/ref/proxy.auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ Note: removing the htpasswd file will cause all requests to fail with HTTP statu

Supported htpasswd formats are detailed [here](https://github.com/tg123/go-htpasswd)

#### External

This authorization scheme sends the incoming http request without body to the specified endpoint. The url path is appended to the endpoint value, thus the endpoint value should not end in a `/`. When the endpoint returns with an http 200 status code, the request is regarded as authorized and forwarded. If the endpoint returns with an http 302 status code, the redirection is send back to the client. For any other status code, the request will be regarded as unauthorized.

This scheme supports the `append-auth-headers` and `set-auth-headers` options. These configure headers from the authorization endpoint to copy to the upstream request. The `set-auth-headers` option replaces any header from the original client request, and the `append-auth-headers` option appends the header instead. Multiple headers can be specified separated by commas.

name=<name>;type=external;endpoint=http://oathkeeper-api:4456/decisions;append-auth-headers=x-user,authorization

#### Examples

# single basic auth scheme
Expand All @@ -35,6 +43,9 @@ Supported htpasswd formats are detailed [here](https://github.com/tg123/go-htpas
proxy.auth = name=mybasicauth;type=basic;file=p/creds.htpasswd;refresh=30s
name=myotherauth;type=basic;file=p/other-creds.htpasswd;realm=myrealm

# single ory oathkeeper decision api
name=<name>;type=external;endpoint=http://oathkeeper-api:4456/decisions

The default is

proxy.auth =
6 changes: 5 additions & 1 deletion proxy/http_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if !t.Authorized(r, w, p.AuthSchemes) {
authDecision := t.Authorized(r, w, p.AuthSchemes)
if authDecision.Done {
return
}
if !authDecision.Authorized {
http.Error(w, "authorization failed", http.StatusUnauthorized)
return
}
Expand Down
6 changes: 3 additions & 3 deletions route/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import (
"github.com/fabiolb/fabio/auth"
)

func (t *Target) Authorized(r *http.Request, w http.ResponseWriter, authSchemes map[string]auth.AuthScheme) bool {
func (t *Target) Authorized(r *http.Request, w http.ResponseWriter, authSchemes map[string]auth.AuthScheme) auth.AuthDecision {
if t.AuthScheme == "" {
return true
return auth.AuthDecision{Authorized: true, Done: false}
}

scheme := authSchemes[t.AuthScheme]

if scheme == nil {
log.Printf("[ERROR] unknown auth scheme '%s'\n", t.AuthScheme)
return false
return auth.AuthDecision{Authorized: false, Done: false}
}

return scheme.Authorized(r, w)
Expand Down
6 changes: 3 additions & 3 deletions route/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ type testAuth struct {
ok bool
}

func (t *testAuth) Authorized(r *http.Request, w http.ResponseWriter) bool {
return t.ok
func (t *testAuth) Authorized(r *http.Request, w http.ResponseWriter) auth.AuthDecision {
return auth.AuthDecision{Authorized: t.ok, Done: false}
}

type responseWriter struct {
Expand Down Expand Up @@ -74,7 +74,7 @@ func TestTarget_Authorized(t *testing.T) {
AuthScheme: tt.authScheme,
}

if got, want := target.Authorized(&http.Request{}, &responseWriter{}, tt.authSchemes), tt.out; !reflect.DeepEqual(got, want) {
if got, want := target.Authorized(&http.Request{}, &responseWriter{}, tt.authSchemes), tt.out; !reflect.DeepEqual(got.Authorized, want) {
t.Errorf("got %v want %v", got, want)
}
})
Expand Down