Skip to content
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Application Options:
--whitelist= Only allow given email addresses, can be set multiple times [$WHITELIST]
--port= Port to listen on (default: 4181) [$PORT]
--rule.<name>.<param>= Rule definitions, param can be: "action", "rule" or "provider"
--authorization-url= URL to use for forward authorization to another service [$AUTHORIZATION_URL]

Google Provider:
--providers.google.client-id= Client ID [$PROVIDERS_GOOGLE_CLIENT_ID]
Expand Down Expand Up @@ -362,6 +363,22 @@ All options can be supplied in any of the following ways, in the following prece

Note: It is possible to break your redirect flow with rules, please be careful not to create an `allow` rule that matches your redirect_uri unless you know what you're doing. This limitation is being tracked in in #101 and the behaviour will change in future releases.

- `authorization-url`

When defined, an authorization layer is added after the 'rules'.

- A request with the following information is sent to the URL:
- `email`
- `request`
- `method`
- `host`
- `path`
- The user's authorization must be indicated by the following response:
- Status: `OK 200`
- Body: `Authorized`
- If the authorization service responds with anything else, the user will not be authorized to access the requested resource.


## Concepts

### User Restriction
Expand Down
52 changes: 52 additions & 0 deletions internal/auth.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package tfa

import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -97,6 +100,55 @@ func ValidateEmail(email, ruleName string) bool {
return false
}

// ValidateRequest validates the request against the authorization service
// and returns true if the request is authorized
func ValidateRequest(r *http.Request, email string) bool {
if config.AuthorizationURL == "" {
fmt.Println("Authorization URL not set, skipping request validation")
return true
}

// Get info from request
method := r.Header.Get("X-Forwarded-Method")
host := r.Header.Get("X-Forwarded-Host")
path := r.Header.Get("X-Forwarded-Uri")

// Build JSON body
reqMap := map[string]interface{}{
"email": email,
"request": map[string]string{
"method": method,
"host": host,
"path": path,
},
}

// Marshal JSON body
jsonBody, err := json.Marshal(reqMap)
if err != nil {
return false
}

// Throw request to authorization service
resp, err := http.Post(config.AuthorizationURL, "application/json", bytes.NewReader(jsonBody))
if err != nil {
return false
}

// Get response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}

// Check the response
if resp.StatusCode == http.StatusOK && string(body) == "Authorized" {
return true
}

return false
}

// ValidateWhitelist checks if the email is in whitelist
func ValidateWhitelist(email string, whitelist CommaSeparatedList) bool {
for _, whitelist := range whitelist {
Expand Down
37 changes: 37 additions & 0 deletions internal/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,43 @@ func TestAuthValidateEmail(t *testing.T) {
assert.True(v, "should allow user in whitelist")
}

func TestAuthValidateRequest(t *testing.T) {
assert := assert.New(t)
config, _ = NewConfig([]string{})

r := httptest.NewRequest("GET", "http://example.com/foo", nil)
r.Header.Add("X-Forwarded-Method", "POST")
r.Header.Add("X-Forwarded-Host", "example.com")
r.Header.Add("X-Forwarded-Uri", "/foo")

// No Authorization URL configured, skip request validation
config.AuthorizationURL = ""
v := ValidateRequest(r, "[email protected]")
assert.True(v, "should skip request validation if no authorization URL is configured")

// Authorization URL configured, validate request response 200 Authorized
srvOk := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Authorized"))
}))
defer srvOk.Close()

config.AuthorizationURL = srvOk.URL
v = ValidateRequest(r, "[email protected]")
assert.True(v, "should allow request if authorization URL returns 200 OK")

// Authorization URL configured, validate request response 200 Unauthorized
srvKo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Unauthorized"))
}))
defer srvKo.Close()

config.AuthorizationURL = srvKo.URL
v = ValidateRequest(r, "[email protected]")
assert.False(v, "should not allow request if authorization URL returns 200 Unauthorized")
}

func TestRedirectUri(t *testing.T) {
assert := assert.New(t)

Expand Down
2 changes: 2 additions & 0 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type Config struct {
Providers provider.Providers `group:"providers" namespace:"providers" env-namespace:"PROVIDERS"`
Rules map[string]*Rule `long:"rule.<name>.<param>" description:"Rule definitions, param can be: \"action\", \"rule\" or \"provider\""`

AuthorizationURL string `long:"authorization-url" env:"AUTHORIZATION_URL" description:"The URL used for forward authorization requests, the body of the request will be a JSON object with the following fields: \"email\", \"request\". The request field is an array of objects with the following fields: \"method\", \"host\", \"path\""`

// Filled during transformations
Secret []byte `json:"-"`
Lifetime time.Duration
Expand Down
3 changes: 3 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestConfigDefaults(t *testing.T) {
assert.Equal("/_oauth", c.Path)
assert.Len(c.Whitelist, 0)
assert.Equal(c.Port, 4181)
assert.Equal("", c.AuthorizationURL)

assert.Equal("select_account", c.Providers.Google.Prompt)
}
Expand All @@ -52,6 +53,7 @@ func TestConfigParseArgs(t *testing.T) {
"--rule.1.rule=PathPrefix(`/one`)",
"--rule.two.action=auth",
"--rule.two.rule=\"Host(`two.com`) && Path(`/two`)\"",
"--authorization-url=http://authorize.example.org/authorize",
"--port=8000",
})
require.Nil(t, err)
Expand All @@ -60,6 +62,7 @@ func TestConfigParseArgs(t *testing.T) {
assert.Equal("cookiename", c.CookieName)
assert.Equal("csrfcookiename", c.CSRFCookieName)
assert.Equal("oidc", c.DefaultProvider)
assert.Equal("http://authorize.example.org/authorize", c.AuthorizationURL)
assert.Equal(8000, c.Port)

// Check rules
Expand Down
8 changes: 8 additions & 0 deletions internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ func (s *Server) AuthHandler(providerName, rule string) http.HandlerFunc {
return
}

// Validate if request is authorized
authorized := ValidateRequest(r, email)
if !authorized {
logger.WithField("email", email).Debug("Request not authorized for user")
http.Error(w, "Not authorized", 403)
return
}

// Valid request
logger.Debug("Allowing valid request")
w.Header().Set("X-Forwarded-User", email)
Expand Down
35 changes: 35 additions & 0 deletions internal/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,41 @@ func TestServerAuthHandlerValid(t *testing.T) {
assert.Equal([]string{"[email protected]"}, users, "X-Forwarded-User header should match user")
}

func TestServerAuthHandlerValidateRequest(t *testing.T) {
assert := assert.New(t)
config = newDefaultConfig()
config.Domains = []string{}

req := newDefaultHttpRequest("/foo")
c := MakeCookie(req, "[email protected]")

// Authorization service denies (should return 403)
srvKo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Unauthorized"))
}))
defer srvKo.Close()
config.AuthorizationURL = srvKo.URL

res, _ := doHttpRequest(req, c)
assert.Equal(403, res.StatusCode, "Should return 403 if ValidateRequest returns false")

// Authorization service accepts (200 + "Authorized")
srvOk := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Authorized"))
}))
defer srvOk.Close()
config.AuthorizationURL = srvOk.URL

res, _ = doHttpRequest(req, c)
assert.Equal(200, res.StatusCode, "Should return 200 if ValidateRequest returns true")

users := res.Header["X-Forwarded-User"]
assert.Len(users, 1, "valid request should have X-Forwarded-User header")
assert.Equal([]string{"[email protected]"}, users, "X-Forwarded-User header should match user")
}

func TestServerAuthCallback(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
Expand Down