Skip to content
Merged
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
128 changes: 128 additions & 0 deletions caskethttp/basicauth/basicauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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: <realm>"
// which is kinda dumb, but whatever.
Expand Down Expand Up @@ -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)
}
79 changes: 67 additions & 12 deletions caskethttp/basicauth/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
package basicauth

import (
"fmt"
"strings"
"time"

"github.com/tmpim/casket"
"github.com/tmpim/casket/caskethttp/httpserver"
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -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) {
Expand Down
27 changes: 26 additions & 1 deletion caskethttp/basicauth/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading