Skip to content

Commit

Permalink
Feature/original request headers (#34)
Browse files Browse the repository at this point in the history
Original request headers implemented.
Config structure expanded.
  • Loading branch information
kaancfidan committed Jun 11, 2020
1 parent 7d58a95 commit 0c9d6bc
Show file tree
Hide file tree
Showing 11 changed files with 420 additions and 295 deletions.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
There are currently no unreleased changes.

## [v0.0.1] - 2020-06-12
### Added
- Support for original request path and method specification through headers (see [nginx docs](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/)).
- Server and Authentication sections to Config.

### Removed
- JWT validation related flags moved to YAML configuration.

## v0.0.0 - 2020-06-01
This is the first version that includes the following functionality:
- YAML configuration support
Expand All @@ -16,4 +24,5 @@ This is the first version that includes the following functionality:
- Claims-based authorization
- Pure authorization server and reverse proxy modes

[Unreleased]: https://github.com/kaancfidan/bouncer/compare/v0.0.0...master
[Unreleased]: https://github.com/kaancfidan/bouncer/compare/v0.0.1...master
[v0.0.1]: https://github.com/kaancfidan/bouncer/compare/v0.0.0...v0.0.1
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,6 @@ Every startup setting has an environment variable and a CLI flag counterpart.
| BOUNCER_CONFIG_PATH | -p | Config YAML path. **default = /etc/bouncer/config.yaml** |
| BOUNCER_LISTEN_ADDRESS | -l | TCP listen address. **default = :3512** |
| BOUNCER_UPSTREAM_URL | --url | Upstream URL to be used in reverse proxy mode. If not set, Bouncer runs in pure auth server mode. |
| BOUNCER_VALID_ISSUER | --iss | Valid issuer id. If set Bouncer validates **iss** claim. |
| BOUNCER_VALID_AUDIENCE | --aud | Valid audience id. If set Bouncer validates **aud** claim. |
| BOUNCER_REQUIRE_EXPIRATION | --exp | Require expiration (**exp**) timestamp on tokens. Unless explicitly set to **false**, **default = true** |
| BOUNCER_REQUIRE_NOT_BEFORE | --nbf | Require "not before" (**nbf**) timestamp on tokens. Unless explicitly set to **false**, **default = true** |
| BOUNCER_CLOCK_SKEW | --clk | Clock skew tolerance in seconds. When set **iat**, **exp**, **nbf** claims are checked with the given tolerance. |

## License
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fkaancfidan%2Fbouncer.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fkaancfidan%2Fbouncer?ref=badge_large)
Expand Down
76 changes: 8 additions & 68 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strconv"

"github.com/kaancfidan/bouncer/services"
)
Expand All @@ -20,13 +18,7 @@ type flags struct {
signingKey string
signingMethod string
configPath string
upstreamURL string
listenAddress string
validIssuer string
validAudience string
expRequired string
nbfRequired string
clockSkew string
}

func main() {
Expand All @@ -53,22 +45,6 @@ func main() {
}

func newServer(f *flags, configReader io.Reader) (*services.Server, error) {
var upstream http.Handler

if f.upstreamURL != "" {
// parse upstream URL
parsedURL, err := url.Parse(f.upstreamURL)
if err != nil {
return nil, fmt.Errorf("upstream url could not be parsed: %w", err)
}

if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return nil, fmt.Errorf("upstream url scheme must be http or https")
}

upstream = httputil.NewSingleHostReverseProxy(parsedURL)
}

parser := services.YamlConfigParser{}
cfg, err := parser.ParseConfig(configReader)
if err != nil {
Expand All @@ -80,46 +56,34 @@ func newServer(f *flags, configReader io.Reader) (*services.Server, error) {
return nil, fmt.Errorf("invalid config: %w", err)
}

var clockSkew int
if f.clockSkew != "" {
clockSkew, err = strconv.Atoi(f.clockSkew)
if err != nil {
return nil, fmt.Errorf("clock skew flag %s cannot be converted to integer", f.clockSkew)
}
} else {
clockSkew = 0
var upstream http.Handler
if cfg.Server.ParsedURL != nil {
upstream = httputil.NewSingleHostReverseProxy(cfg.Server.ParsedURL)
}

authenticator, err := services.NewAuthenticator(
[]byte(f.signingKey),
f.signingMethod,
f.validIssuer,
f.validAudience,
f.expRequired != "false",
f.nbfRequired != "false",
clockSkew)
cfg.Authentication)

if err != nil {
return nil, fmt.Errorf("could not create authenticator: %w", err)
}

s := services.NewServer(
return services.NewServer(
upstream,
services.NewRouteMatcher(cfg.RoutePolicies),
services.NewAuthorizer(cfg.ClaimPolicies),
authenticator)

return s, nil
authenticator,
cfg.Server), nil
}

func parseFlags() *flags {
f := flags{
configPath: "/etc/bouncer/config.yaml",
listenAddress: ":3512",
expRequired: "true",
nbfRequired: "true",
}

printVersion := flag.Bool("v", false, "print version and exit")
flag.StringVar(&f.signingKey, "k",
lookupEnv("BOUNCER_SIGNING_KEY", ""),
Expand All @@ -137,30 +101,6 @@ func parseFlags() *flags {
lookupEnv("BOUNCER_LISTEN_ADDRESS", f.listenAddress),
fmt.Sprintf("listen address, default = %s", f.listenAddress))

flag.StringVar(&f.upstreamURL, "url",
lookupEnv("BOUNCER_UPSTREAM_URL", ""),
"URL to be called when the request is authorized")

flag.StringVar(&f.validIssuer, "iss",
lookupEnv("BOUNCER_VALID_ISSUER", ""),
fmt.Sprintf("valid token issuer"))

flag.StringVar(&f.validAudience, "aud",
lookupEnv("BOUNCER_VALID_AUDIENCE", ""),
fmt.Sprintf("valid token audience"))

flag.StringVar(&f.expRequired, "exp",
lookupEnv("BOUNCER_REQUIRE_EXPIRATION", f.expRequired),
fmt.Sprintf("require token expiration timestamp claims, default = %s", f.expRequired))

flag.StringVar(&f.nbfRequired, "nbf",
lookupEnv("BOUNCER_REQUIRE_NOT_BEFORE", f.nbfRequired),
fmt.Sprintf("require token not before timestamp claims, default = %s", f.nbfRequired))

flag.StringVar(&f.clockSkew, "clk",
lookupEnv("BOUNCER_CLOCK_SKEW", f.clockSkew),
fmt.Sprintf("require token not before timestamp claims, default = %s", f.nbfRequired))

flag.Parse()

if *printVersion {
Expand Down
50 changes: 0 additions & 50 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,46 +30,6 @@ func TestNewServer(t *testing.T) {
cfgContent: "",
wantErr: true,
},
{
name: "reverse proxy",
flags: &flags{
signingKey: "SuperSecretKey123!",
signingMethod: "HMAC",
upstreamURL: "http://localhost:8080",
},
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: false,
},
{
name: "clock skewed",
flags: &flags{
signingKey: "SuperSecretKey123!",
signingMethod: "HMAC",
clockSkew: "10",
},
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: false,
},
{
name: "invalid url scheme",
flags: &flags{
signingKey: "SuperSecretKey123!",
signingMethod: "HMAC",
upstreamURL: "tcp://localhost:8080",
},
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: true,
},
{
name: "malformed url",
flags: &flags{
signingKey: "SuperSecretKey123!",
signingMethod: "HMAC",
upstreamURL: "!!http://localhost:8080",
},
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: true,
},
{
name: "invalid config yaml",
flags: &flags{
Expand Down Expand Up @@ -104,16 +64,6 @@ func TestNewServer(t *testing.T) {
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: true,
},
{
name: "invalid clock skew flag",
flags: &flags{
signingKey: "SuperSecretKey123!",
signingMethod: "HMAC",
clockSkew: "not a number",
},
cfgContent: "claimPolicies: {}\nroutePolicies: []",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
37 changes: 35 additions & 2 deletions models/config.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
package models

import "net/url"

// AuthenticationConfig holds JWT validation related parameters
type AuthenticationConfig struct {
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
IgnoreExpiration bool `yaml:"ignoreExpiration"`
IgnoreNotBefore bool `yaml:"ignoreNotBefore"`
ClockSkewInSeconds int `yaml:"clockSkewInSeconds"`
}

// OriginalRequestHeaders contains headers to lookup for original request method and path details
// in the case where the auth request is a sub-request with distinct method and path
type OriginalRequestHeaders struct {
Method string `yaml:"method"`
Path string `yaml:"path"`
}

// ServerConfig holds operation mode (auth server / reverse proxy) related parameters
type ServerConfig struct {
OriginalRequestHeaders *OriginalRequestHeaders `yaml:"originalRequestHeaders"`
UpstreamURL string `yaml:"upstreamUrl"`
ParsedURL *url.URL `yaml:"-"`
}

// ClaimRequirement is a key-value pair for a given claim constraint.
// When multiple claim values are provided, these values are effectively ORed.
type ClaimRequirement struct {
Expand All @@ -15,8 +40,16 @@ type RoutePolicy struct {
AllowAnonymous bool `yaml:"allowAnonymous"`
}

// ClaimPolicyConfig is a type alias for claimPolicies section
type ClaimPolicyConfig map[string][]ClaimRequirement

// RoutePolicyConfig is a type alias for routePolicies section
type RoutePolicyConfig []RoutePolicy

// Config is the overall struct that matches the YAML structure
type Config struct {
ClaimPolicies map[string][]ClaimRequirement `yaml:"claimPolicies"`
RoutePolicies []RoutePolicy `yaml:"routePolicies"`
Server ServerConfig `yaml:"server"`
Authentication AuthenticationConfig `yaml:"authentication"`
ClaimPolicies ClaimPolicyConfig `yaml:"claimPolicies"`
RoutePolicies RoutePolicyConfig `yaml:"routePolicies"`
}
42 changes: 16 additions & 26 deletions services/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

"github.com/dgrijalva/jwt-go"

"github.com/kaancfidan/bouncer/models"
)

// Authenticator interface
Expand All @@ -17,13 +19,9 @@ type Authenticator interface {

// AuthenticatorImpl is a JWT based authentication implementation
type AuthenticatorImpl struct {
signingKey interface{}
signingMethod string
validIssuer string
validAudience string
expirationRequired bool
notBeforeRequired bool
clockSkew int
signingKey interface{}
signingMethod string
config models.AuthenticationConfig
}

type claims struct {
Expand Down Expand Up @@ -69,25 +67,17 @@ func parseSigningKey(signingKey []byte, signingMethod string) (key interface{},
func NewAuthenticator(
signingKey []byte,
signingMethod string,
validIssuer string,
validAudience string,
expirationRequired bool,
notBeforeRequired bool,
clockSkew int) (*AuthenticatorImpl, error) {
config models.AuthenticationConfig) (*AuthenticatorImpl, error) {

key, err := parseSigningKey(signingKey, signingMethod)
if err != nil {
return nil, fmt.Errorf("could not parse signing key: %w", err)
}

return &AuthenticatorImpl{
signingKey: key,
signingMethod: signingMethod,
validIssuer: validIssuer,
validAudience: validAudience,
expirationRequired: expirationRequired,
notBeforeRequired: notBeforeRequired,
clockSkew: clockSkew,
signingKey: key,
signingMethod: signingMethod,
config: config,
}, nil
}

Expand All @@ -114,33 +104,33 @@ func (a AuthenticatorImpl) Authenticate(authHeader string) (map[string]interface
// check claims for authorization
claims := claims{
MapClaims: jwt.MapClaims{},
ClockSkew: a.clockSkew,
ClockSkew: a.config.ClockSkewInSeconds,
}
_, err := jwt.ParseWithClaims(tokenString, &claims, a.keyFactory)

if err != nil {
return nil, fmt.Errorf("error occurred while parsing claims: %w", err)
}

if _, ok := claims.MapClaims["exp"]; !ok && a.expirationRequired {
if _, ok := claims.MapClaims["exp"]; !ok && !a.config.IgnoreExpiration {
return nil, fmt.Errorf("required expiration timestamp not found")
}

if _, ok := claims.MapClaims["nbf"]; !ok && a.notBeforeRequired {
if _, ok := claims.MapClaims["nbf"]; !ok && !a.config.IgnoreNotBefore {
return nil, fmt.Errorf("required not before timestamp not found")
}

// verify audience
if a.validAudience != "" {
checkAud := claims.VerifyAudience(a.validAudience, true)
if a.config.Audience != "" {
checkAud := claims.VerifyAudience(a.config.Audience, true)
if !checkAud {
return nil, fmt.Errorf("invalid audience")
}
}

// verify issuer
if a.validIssuer != "" {
checkIss := claims.VerifyIssuer(a.validIssuer, true)
if a.config.Issuer != "" {
checkIss := claims.VerifyIssuer(a.config.Issuer, true)
if !checkIss {
return nil, fmt.Errorf("invalid issuer")
}
Expand Down
Loading

0 comments on commit 0c9d6bc

Please sign in to comment.