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 }