github.com/BTBurke/caddy-jwt@v3.7.1+incompatible/jwt.go (about)

     1  package jwt
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"path"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"github.com/caddyserver/caddy/caddyhttp/httpserver"
    13  	jwt "github.com/dgrijalva/jwt-go"
    14  )
    15  
    16  type TokenSource interface {
    17  	// If the returned string is empty, the token was not found.
    18  	// So far any implementation does not return errors.
    19  	ExtractToken(r *http.Request) string
    20  }
    21  
    22  // Extracts a token from the Authorization header in the form `Bearer <JWT Token>`
    23  type HeaderTokenSource struct {
    24  	HeaderName string
    25  }
    26  
    27  func (hts *HeaderTokenSource) ExtractToken(r *http.Request) string {
    28  	jwtHeader := strings.Split(r.Header.Get("Authorization"), " ")
    29  	if jwtHeader[0] == hts.HeaderName && len(jwtHeader) == 2 {
    30  		return jwtHeader[1]
    31  	}
    32  	return ""
    33  }
    34  
    35  // Extracts a token from a cookie named `CookieName`.
    36  type CookieTokenSource struct {
    37  	CookieName string
    38  }
    39  
    40  func (cts *CookieTokenSource) ExtractToken(r *http.Request) string {
    41  	jwtCookie, err := r.Cookie(cts.CookieName)
    42  	if err == nil {
    43  		return jwtCookie.Value
    44  	}
    45  	return ""
    46  }
    47  
    48  // Extracts a token from a URL query parameter of the form https://example.com?ParamName=<JWT token>
    49  type QueryTokenSource struct {
    50  	ParamName string
    51  }
    52  
    53  func (qts *QueryTokenSource) ExtractToken(r *http.Request) string {
    54  	jwtQuery := r.URL.Query().Get(qts.ParamName)
    55  	if jwtQuery != "" {
    56  		return jwtQuery
    57  	}
    58  	return ""
    59  }
    60  
    61  var (
    62  	// Default TokenSources to be applied in the given order if the
    63  	// user did not explicitly configure them via the token_source option
    64  	DefaultTokenSources = []TokenSource{
    65  		&HeaderTokenSource{
    66  			HeaderName: "Bearer",
    67  		},
    68  		&CookieTokenSource{
    69  			CookieName: "jwt_token",
    70  		},
    71  		&QueryTokenSource{
    72  			ParamName: "token",
    73  		},
    74  	}
    75  )
    76  
    77  func (h Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
    78  	// if the request path is any of the configured paths, validate JWT
    79  	for _, p := range h.Rules {
    80  
    81  		// TODO: this is a hack to work our CVE in Caddy dealing with parsing
    82  		// malformed URLs.  Can be removed once upstream fix for path match
    83  		if r.URL.EscapedPath() == "" {
    84  			return handleUnauthorized(w, r, p, h.Realm), nil
    85  		}
    86  		cleanedPath := path.Clean(r.URL.Path)
    87  		if !httpserver.Path(cleanedPath).Matches(p.Path) {
    88  			continue
    89  		}
    90  
    91  		if r.Method == "OPTIONS" {
    92  			continue
    93  		}
    94  
    95  		// strip potentially spoofed claims
    96  		for header := range r.Header {
    97  			if strings.HasPrefix(header, "Token-Claim-") {
    98  				r.Header.Del(header)
    99  			}
   100  		}
   101  
   102  		// Check excepted paths for this rule and allow access without validating any token
   103  		var isExceptedPath bool
   104  		for _, e := range p.ExceptedPaths {
   105  			if httpserver.Path(r.URL.Path).Matches(e) {
   106  				isExceptedPath = true
   107  			}
   108  		}
   109  		if isExceptedPath {
   110  			continue
   111  		}
   112  		if r.URL.Path == "/" && p.AllowRoot {
   113  			// special case for protecting children of the root path, only allow access to base directory with directive `allowbase`
   114  			continue
   115  		}
   116  
   117  		// Path matches, look for unvalidated token
   118  		uToken, err := ExtractToken(p.TokenSources, r)
   119  		if err != nil {
   120  			if p.Passthrough {
   121  				continue
   122  			}
   123  			return handleUnauthorized(w, r, p, h.Realm), nil
   124  		}
   125  
   126  		var vToken *jwt.Token
   127  
   128  		if len(p.KeyBackends) <= 0 {
   129  			vToken, err = ValidateToken(uToken, &NoopKeyBackend{})
   130  		}
   131  
   132  		// Loop through all possible key files on disk, using cache
   133  		for _, keyBackend := range p.KeyBackends {
   134  			// Validate token
   135  			vToken, err = ValidateToken(uToken, keyBackend)
   136  
   137  			if err == nil {
   138  				// break on first correctly validated token
   139  				break
   140  			}
   141  		}
   142  
   143  		// Check last error of validating token.  If error still exists, no keyfiles matched
   144  		if err != nil || vToken == nil {
   145  			if p.Passthrough {
   146  				continue
   147  			}
   148  			return handleUnauthorized(w, r, p, h.Realm), nil
   149  		}
   150  
   151  		vClaims, err := Flatten(vToken.Claims.(jwt.MapClaims), "", DotStyle)
   152  		if err != nil {
   153  			return handleUnauthorized(w, r, p, h.Realm), nil
   154  		}
   155  
   156  		// If token contains rules with allow or deny, evaluate
   157  		if len(p.AccessRules) > 0 {
   158  			var isAuthorized []bool
   159  			for _, rule := range p.AccessRules {
   160  				v := vClaims[rule.Claim]
   161  				ruleMatches := contains(v, rule.Value) || v == rule.Value
   162  				switch rule.Authorize {
   163  				case ALLOW:
   164  					isAuthorized = append(isAuthorized, ruleMatches)
   165  				case DENY:
   166  					isAuthorized = append(isAuthorized, !ruleMatches)
   167  				default:
   168  					return handleUnauthorized(w, r, p, h.Realm), fmt.Errorf("unknown rule type")
   169  				}
   170  			}
   171  			// test all flags, if any are true then ok to pass
   172  			ok := false
   173  			for _, result := range isAuthorized {
   174  				if result {
   175  					ok = true
   176  				}
   177  			}
   178  			if !ok {
   179  				return handleForbidden(w, r, p, h.Realm), nil
   180  			}
   181  		}
   182  
   183  		// set claims as separate headers for downstream to consume
   184  		for claim, value := range vClaims {
   185  			var headerName string
   186  			switch p.StripHeader {
   187  			case true:
   188  				stripped := strings.SplitAfter(claim, "/")
   189  				finalStrip := stripped[len(stripped)-1]
   190  				headerName = "Token-Claim-" + modTitleCase(finalStrip)
   191  			default:
   192  				escaped := url.PathEscape(claim)
   193  				headerName = "Token-Claim-" + modTitleCase(escaped)
   194  			}
   195  
   196  			switch v := value.(type) {
   197  			case string:
   198  				r.Header.Set(headerName, v)
   199  			case int64:
   200  				r.Header.Set(headerName, strconv.FormatInt(v, 10))
   201  			case bool:
   202  				r.Header.Set(headerName, strconv.FormatBool(v))
   203  			case int32:
   204  				r.Header.Set(headerName, strconv.FormatInt(int64(v), 10))
   205  			case float32:
   206  				r.Header.Set(headerName, strconv.FormatFloat(float64(v), 'f', -1, 32))
   207  			case float64:
   208  				r.Header.Set(headerName, strconv.FormatFloat(v, 'f', -1, 64))
   209  			case []interface{}:
   210  				b := bytes.NewBufferString("")
   211  				for i, item := range v {
   212  					if i > 0 {
   213  						b.WriteString(",")
   214  					}
   215  					b.WriteString(fmt.Sprintf("%v", item))
   216  				}
   217  				r.Header.Set(headerName, b.String())
   218  			default:
   219  				// ignore, because, JWT spec says in https://tools.ietf.org/html/rfc7519#section-4
   220  				//     all claims that are not understood
   221  				//     by implementations MUST be ignored.
   222  			}
   223  		}
   224  
   225  		return h.Next.ServeHTTP(w, r)
   226  	}
   227  	// pass request if no paths protected with JWT
   228  	return h.Next.ServeHTTP(w, r)
   229  }
   230  
   231  // ExtractToken will find a JWT token in the token sources specified.
   232  // If tss is empty, the DefaultTokenSources are used.
   233  func ExtractToken(tss []TokenSource, r *http.Request) (string, error) {
   234  
   235  	effectiveTss := tss
   236  	if len(effectiveTss) == 0 {
   237  		// Defaults are applied here as this keeps the tests the cleanest.
   238  		effectiveTss = DefaultTokenSources
   239  	}
   240  
   241  	for _, tss := range effectiveTss {
   242  		token := tss.ExtractToken(r)
   243  		if token != "" {
   244  			return token, nil
   245  		}
   246  	}
   247  
   248  	return "", fmt.Errorf("no token found")
   249  }
   250  
   251  // ValidateToken will return a parsed token if it passes validation, or an
   252  // error if any part of the token fails validation.  Possible errors include
   253  // malformed tokens, unknown/unspecified signing algorithms, missing secret key,
   254  // tokens that are not valid yet (i.e., 'nbf' field), tokens that are expired,
   255  // and tokens that fail signature verification (forged)
   256  func ValidateToken(uToken string, keyBackend KeyBackend) (*jwt.Token, error) {
   257  	if len(uToken) == 0 {
   258  		return nil, fmt.Errorf("Token length is zero")
   259  	}
   260  	token, err := jwt.Parse(uToken, keyBackend.ProvideKey)
   261  
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  
   266  	return token, nil
   267  }
   268  
   269  // handleUnauthorized checks, which action should be performed if access was denied.
   270  // It returns the status code and writes the Location header in case of a redirect.
   271  // Possible caddy variables in the location value will be substituted.
   272  func handleUnauthorized(w http.ResponseWriter, r *http.Request, rule Rule, realm string) int {
   273  	if rule.Redirect != "" {
   274  		replacer := httpserver.NewReplacer(r, nil, "")
   275  		http.Redirect(w, r, replacer.Replace(rule.Redirect), http.StatusSeeOther)
   276  		return http.StatusSeeOther
   277  	}
   278  
   279  	w.Header().Add("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"%s\",error=\"invalid_token\"", realm))
   280  	return http.StatusUnauthorized
   281  }
   282  
   283  // handleForbidden checks, which action should be performed if access was denied.
   284  // It returns the status code and writes the Location header in case of a redirect.
   285  // Possible caddy variables in the location value will be substituted.
   286  func handleForbidden(w http.ResponseWriter, r *http.Request, rule Rule, realm string) int {
   287  	if rule.Redirect != "" {
   288  		replacer := httpserver.NewReplacer(r, nil, "")
   289  		http.Redirect(w, r, replacer.Replace(rule.Redirect), http.StatusSeeOther)
   290  		return http.StatusSeeOther
   291  	}
   292  	w.Header().Add("WWW-Authenticate", fmt.Sprintf("Bearer realm=\"%s\",error=\"insufficient_scope\"", realm))
   293  	return http.StatusForbidden
   294  }
   295  
   296  // contains checks weather list is a slice ans containts the
   297  // supplied string value.
   298  func contains(list interface{}, value string) bool {
   299  	switch l := list.(type) {
   300  	case []interface{}:
   301  		for _, v := range l {
   302  			if v == value {
   303  				return true
   304  			}
   305  		}
   306  	}
   307  	return false
   308  }
   309  
   310  func modTitleCase(s string) string {
   311  	switch {
   312  	case len(s) == 0:
   313  		return s
   314  	case len(s) == 1:
   315  		return strings.ToUpper(s)
   316  	default:
   317  		return strings.ToUpper(string(s[0])) + s[1:]
   318  	}
   319  }