diff --git a/caskethttp/basicauth/basicauth.go b/caskethttp/basicauth/basicauth.go index ef612a33..33bac37c 100644 --- a/caskethttp/basicauth/basicauth.go +++ b/caskethttp/basicauth/basicauth.go @@ -30,8 +30,10 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync" + "time" "github.com/jimstudt/http-authentication/basic" "github.com/tmpim/casket/caskethttp/httpserver" @@ -46,8 +48,21 @@ type BasicAuth struct { Next httpserver.Handler SiteRoot string Rules []Rule + Cookie CookieConfig } +type CookieConfig struct { + Enabled bool + TTL time.Duration + Refresh time.Duration + NoRefresh bool + Name string +} + +const defaultCookieTTL = time.Hour * 24 +const defaultCookieName = "casket_basicauth" +const tsCookieSuffix = "_ts" + // ServeHTTP implements the httpserver.Handler interface. func (a BasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { var protected, isAuthenticated bool @@ -62,6 +77,20 @@ func (a BasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error return a.Next.ServeHTTP(w, r) } + // if cookies are enabled for this site, and there's no auth header, pass through the cookie. note this would lead + // to a possible desync if the user somehow changes the auth header while they have a cookie stored + var cookie *http.Cookie + cookieEnabled := a.Cookie.Enabled + if cookieEnabled { + if c, err := r.Cookie(a.Cookie.Name); err == nil && c.Value != "" { + cookie = c + + if r.Header.Get("Authorization") == "" { + r.Header.Set("Authorization", c.Value) + } + } + } + ruleLoop: for _, rule := range a.Rules { for _, res := range rule.Resources { @@ -100,10 +129,21 @@ ruleLoop: // Provide username to be used in log by replacer repl := httpserver.NewReplacer(r, nil, "-") repl.Set("user", username) + + // store the auth header in a cookie if we need to + if cookieEnabled { + if a.shouldUpdateCookie(r, cookie) { + a.setCookies(w, r, r.Header.Get("Authorization")) + } + } } } if protected && !isAuthenticated { + if cookieEnabled { + a.clearCookies(w, r) + } + // browsers show a message that says something like: // "The website says: " // which is kinda dumb, but whatever. @@ -215,3 +255,91 @@ func PlainMatcher(passw string) PasswordMatcher { return subtle.ConstantTimeCompare([]byte(pwSum), []byte(passwSum)) == 1 } } + +func (a BasicAuth) shouldUpdateCookie(r *http.Request, cookie *http.Cookie) bool { + if cookie == nil { + return true // we don't have a cookie, set a new one + } + + if a.Cookie.NoRefresh || a.Cookie.Refresh <= 0 { + return false // no refresh, leave it as-is to expire according to ttl + } + + tsCookie, err := r.Cookie(a.Cookie.Name + tsCookieSuffix) + if err != nil { + return true // timestamp cookie missing, re-issue anyway i guess? + } + + issuedAtUnix, err := strconv.ParseInt(tsCookie.Value, 10, 64) + if err != nil { + return true // whatever + } + + issuedAt := time.Unix(issuedAtUnix, 0) + + // refresh if we're past time + return time.Since(issuedAt) > a.Cookie.Refresh +} + +func (a BasicAuth) setCookies(w http.ResponseWriter, r *http.Request, authHeader string) { + now := time.Now() + + authCookie := &http.Cookie{ + Name: a.Cookie.Name, + Value: authHeader, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } + + tsCookie := &http.Cookie{ + Name: a.Cookie.Name + tsCookieSuffix, + Value: fmt.Sprintf("%d", now.Unix()), + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } + + if a.Cookie.TTL > 0 { + exp := now.Add(a.Cookie.TTL) + maxAge := int(a.Cookie.TTL.Seconds()) + + authCookie.Expires = exp + authCookie.MaxAge = maxAge + + tsCookie.Expires = exp + tsCookie.MaxAge = maxAge + } + + http.SetCookie(w, authCookie) + http.SetCookie(w, tsCookie) +} + +func (a BasicAuth) clearCookies(w http.ResponseWriter, r *http.Request) { + deadAuth := &http.Cookie{ + Name: a.Cookie.Name, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } + + deadTS := &http.Cookie{ + Name: a.Cookie.Name + tsCookieSuffix, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + } + + http.SetCookie(w, deadAuth) + http.SetCookie(w, deadTS) +} diff --git a/caskethttp/basicauth/setup.go b/caskethttp/basicauth/setup.go index c65fe921..336edeac 100644 --- a/caskethttp/basicauth/setup.go +++ b/caskethttp/basicauth/setup.go @@ -15,7 +15,9 @@ package basicauth import ( + "fmt" "strings" + "time" "github.com/tmpim/casket" "github.com/tmpim/casket/caskethttp/httpserver" @@ -33,12 +35,12 @@ func setup(c *casket.Controller) error { cfg := httpserver.GetConfig(c) root := cfg.Root - rules, err := basicAuthParse(c) + rules, cookie, err := basicAuthParse(c) if err != nil { return err } - basic := BasicAuth{Rules: rules} + basic := BasicAuth{Rules: rules, Cookie: cookie} cfg.AddMiddleware(func(next httpserver.Handler) httpserver.Handler { basic.Next = next @@ -49,9 +51,16 @@ func setup(c *casket.Controller) error { return nil } -func basicAuthParse(c *casket.Controller) ([]Rule, error) { +func basicAuthParse(c *casket.Controller) ([]Rule, CookieConfig, error) { var rules []Rule cfg := httpserver.GetConfig(c) + cookie := CookieConfig{ + Enabled: false, + TTL: defaultCookieTTL, + Refresh: 0, + NoRefresh: false, + Name: defaultCookieName, + } var err error for c.Next() { @@ -63,16 +72,16 @@ func basicAuthParse(c *casket.Controller) ([]Rule, error) { case 2: rule.Username = args[0] if rule.Password, err = passwordMatcher(rule.Username, args[1], cfg.Root); err != nil { - return rules, c.Errf("Get password matcher from %s: %v", c.Val(), err) + return rules, cookie, c.Errf("Get password matcher from %s: %v", c.Val(), err) } case 3: rule.Resources = append(rule.Resources, args[0]) rule.Username = args[1] if rule.Password, err = passwordMatcher(rule.Username, args[2], cfg.Root); err != nil { - return rules, c.Errf("Get password matcher from %s: %v", c.Val(), err) + return rules, cookie, c.Errf("Get password matcher from %s: %v", c.Val(), err) } default: - return rules, c.ArgErr() + return rules, cookie, c.ArgErr() } // If nested block is present, process it here @@ -81,29 +90,75 @@ func basicAuthParse(c *casket.Controller) ([]Rule, error) { args = c.RemainingArgs() switch len(args) { case 0: - // Assume single argument is path resource - rule.Resources = append(rule.Resources, val) + if val == "cookie" { + if cookie.Enabled { + return rules, cookie, c.Errf("\"cookie\" subdirective can only be specified once") + } + + cookie.Enabled = true + + for nesting := c.Nesting(); c.NextBlockNesting(nesting); { + switch c.Val() { + case "ttl": + if !c.NextArg() { + return rules, cookie, c.ArgErr() + } + + cookie.TTL, err = time.ParseDuration(c.Val()) + if err != nil { + return rules, cookie, fmt.Errorf("could not parse cookie.ttl: %v", err) + } + case "refresh": + if !c.NextArg() { + return rules, cookie, c.ArgErr() + } + + cookie.Refresh, err = time.ParseDuration(c.Val()) + if err != nil { + return rules, cookie, fmt.Errorf("could not parse cookie.refresh: %v", err) + } + + if cookie.Refresh == 0 { + cookie.NoRefresh = true + } + case "name": + if !c.NextArg() { + return rules, cookie, c.ArgErr() + } + + cookie.Name = c.Val() + } + } + + if cookie.Refresh == 0 && !cookie.NoRefresh { + // refresh wasn't configured, set it to half ttl + cookie.Refresh = cookie.TTL / 2 + } + } else { + // Assume single argument is path resource + rule.Resources = append(rule.Resources, val) + } case 1: if val == "realm" { if rule.Realm == "" { rule.Realm = strings.Replace(args[0], `"`, `\"`, -1) } else { - return rules, c.Errf("\"realm\" subdirective can only be specified once") + return rules, cookie, c.Errf("\"realm\" subdirective can only be specified once") } } else if val == "exclude" { rule.Exclude = append(rule.Exclude, args[0]) } else { - return rules, c.Errf("expecting \"realm\", got \"%s\"", val) + return rules, cookie, c.Errf("expecting \"realm\", \"exclude\", or \"cookie\", got \"%s\"", val) } default: - return rules, c.ArgErr() + return rules, cookie, c.ArgErr() } } rules = append(rules, rule) } - return rules, nil + return rules, cookie, nil } func passwordMatcher(username, passw, siteRoot string) (PasswordMatcher, error) { diff --git a/caskethttp/basicauth/setup_test.go b/caskethttp/basicauth/setup_test.go index 71fa0353..cb8af13c 100644 --- a/caskethttp/basicauth/setup_test.go +++ b/caskethttp/basicauth/setup_test.go @@ -126,10 +126,35 @@ md5:$apr1$l42y8rex$pOA2VJ0x/0TwaFeAF9nX61` {`basicauth sha1 htpasswd=` + htfh.Name(), false, htpasswdPasswd, []Rule{ {Username: "sha1"}, }}, + + {`basicauth user pwd { + cookie + }`, false, "pwd", []Rule{ + {Username: "user"}, + }}, + + {`basicauth user pwd { + cookie { + ttl 12h + refresh 8h + name whatever + } + }`, false, "pwd", []Rule{ + {Username: "user"}, + }}, + + {`basicauth user pwd { + cookie { + ttl 12h + refresh 0 + } + }`, false, "pwd", []Rule{ + {Username: "user"}, + }}, } for i, test := range tests { - actual, err := basicAuthParse(casket.NewTestController("http", test.input)) + actual, _, err := basicAuthParse(casket.NewTestController("http", test.input)) if err == nil && test.shouldErr { t.Errorf("Test %d didn't error, but it should have", i)