Compare commits
1 Commits
issue-108-
...
issue-123-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a91635b2a |
12
README.md
12
README.md
@@ -150,10 +150,9 @@ one runs out first:
|
||||
- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding
|
||||
window. Every authenticated request pushes it forward, so a session
|
||||
in continuous use never hits it, while an abandoned one expires a day
|
||||
after its last use. Any non-positive value (`0`, or a negative
|
||||
duration such as `-1s`) disables idle expiry entirely; the absolute
|
||||
cap below still applies. A set-but-unparseable value aborts startup
|
||||
rather than silently falling back to the default.
|
||||
after its last use. Set it to `0` to disable idle expiry entirely;
|
||||
the absolute cap below still applies. A set-but-unparseable value
|
||||
aborts startup rather than silently falling back to the default.
|
||||
- **Absolute expiry** is a fixed 7 days from login. Activity does
|
||||
**not** extend it: after a week, every session ends and the user
|
||||
authenticates again.
|
||||
@@ -165,11 +164,6 @@ idle window rather than on every request, which means a session may
|
||||
expire up to 10% early relative to the user's true last request, but
|
||||
never late.
|
||||
|
||||
Both clocks are anchored by timestamps stored in the session cookie.
|
||||
Sessions issued before this feature existed carry neither, so they are
|
||||
treated as expired: upgrading to a build that has it logs every
|
||||
existing session out once, and those users sign in again.
|
||||
|
||||
#### Invalid values abort startup
|
||||
|
||||
The defaults above apply **only** to variables that are unset (or set
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AddTemplateForTest registers a template under a page name so that
|
||||
// the handlers_test package can drive the render path with a
|
||||
// template of its own.
|
||||
func (s *Handlers) AddTemplateForTest(
|
||||
pageTemplate string,
|
||||
tmpl *template.Template,
|
||||
) {
|
||||
s.templates[pageTemplate] = tmpl
|
||||
}
|
||||
|
||||
// RenderTemplateForTest exposes renderTemplate for use in the
|
||||
// handlers_test package.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -224,13 +225,20 @@ func (s *Handlers) renderTemplate(
|
||||
s.executeTemplate(w, tmpl, wrapper)
|
||||
}
|
||||
|
||||
// executeTemplate runs the template and handles errors.
|
||||
// executeTemplate renders the template into a buffer and writes to
|
||||
// the response only once rendering has fully succeeded. Executing
|
||||
// straight into the ResponseWriter commits a partial body and a 200
|
||||
// status before a mid-render error can be reported, leaving no way
|
||||
// to serve a 500. These pages are small, so holding one in memory is
|
||||
// the right trade.
|
||||
func (s *Handlers) executeTemplate(
|
||||
w http.ResponseWriter,
|
||||
tmpl *template.Template,
|
||||
data any,
|
||||
) {
|
||||
err := tmpl.Execute(w, data)
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := tmpl.Execute(&buf, data)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"failed to execute template", "error", err,
|
||||
@@ -239,5 +247,16 @@ func (s *Handlers) executeTemplate(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
_, err = buf.WriteTo(w)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"failed to write rendered page", "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
@@ -220,6 +222,68 @@ func TestRenderTemplate(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// errMidRender is the failure a test template raises partway through
|
||||
// rendering.
|
||||
var errMidRender = errors.New("deliberate mid-render failure")
|
||||
|
||||
// midRenderFailure is template data whose first method renders and
|
||||
// whose second fails, so the template aborts after output has
|
||||
// already been produced.
|
||||
type midRenderFailure struct{}
|
||||
|
||||
// Prefix is the output a streaming renderer would flush before the
|
||||
// failure below aborts the template.
|
||||
func (midRenderFailure) Prefix() string { return partialPageMarker }
|
||||
|
||||
// Boom aborts template execution.
|
||||
func (midRenderFailure) Boom() (string, error) {
|
||||
return "", errMidRender
|
||||
}
|
||||
|
||||
// partialPageMarker is content the failing template emits before it
|
||||
// aborts.
|
||||
const partialPageMarker = "PARTIAL PAGE CONTENT"
|
||||
|
||||
// TestRenderTemplateMidRenderErrorSendsNoPartialBody proves the
|
||||
// renderer does not commit output it cannot finish: a template that
|
||||
// fails partway through must yield a 500 and a body carrying none of
|
||||
// the content emitted before the failure. Against a renderer that
|
||||
// executes straight into the ResponseWriter this fails on both
|
||||
// counts, returning 200 with the prefix already flushed.
|
||||
func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
h.AddTemplateForTest("failing.html", template.Must(
|
||||
template.New("failing").Parse(
|
||||
`{{.Data.Prefix}}{{.Data.Boom}}TAIL`,
|
||||
),
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.RenderTemplateForTest(
|
||||
w, req, "failing.html", midRenderFailure{},
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
"a failed render must report a 500",
|
||||
)
|
||||
assert.Equal(
|
||||
t, "Internal server error\n", w.Body.String(),
|
||||
"the response must carry no part of the aborted page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -214,11 +214,11 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
// handler runs, while the headers are still ours to
|
||||
// write.
|
||||
if s.session.Touch(sess) {
|
||||
saveErr := s.session.Save(r, w, sess)
|
||||
if saveErr != nil {
|
||||
err = s.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"auth middleware: failed to refresh session",
|
||||
"error", saveErr,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// The tests below exercise the securecookie codecs underneath the
|
||||
// store and nothing else: Session.Get only decodes, so no server-side
|
||||
// expiry check takes part in the result. They exist because
|
||||
// NewCookieStore gives its codecs a 30-day max age that assigning
|
||||
// store.Options does not override, which would let the codec accept a
|
||||
// cookie weeks past the cap the cookie attribute advertises.
|
||||
|
||||
// issuedCookie returns a session cookie the store itself wrote.
|
||||
func issuedCookie(t *testing.T, s *session.Session) string {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
sess.Values["probe"] = "value"
|
||||
require.NoError(t, s.Save(req, w, sess))
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
|
||||
return cookies[0].Value
|
||||
}
|
||||
|
||||
// restamp rewrites the timestamp inside an encoded session cookie and
|
||||
// re-signs it, yielding the cookie the store would have written at
|
||||
// that instant. securecookie stamps the encoding time itself and
|
||||
// exposes no seam to move it, so its wire format is reproduced here:
|
||||
// the base64url payload is "date|value|mac", where mac is HMAC-SHA256
|
||||
// of "name|date|value" under the store's key.
|
||||
func restamp(
|
||||
t *testing.T,
|
||||
encoded string,
|
||||
at time.Time,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
raw, err := base64.URLEncoding.DecodeString(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
parts := strings.SplitN(string(raw), "|", 3)
|
||||
require.Len(t, parts, 3)
|
||||
|
||||
stamped := fmt.Sprintf("%d|%s", at.Unix(), parts[1])
|
||||
|
||||
mac := hmac.New(sha256.New, testKey())
|
||||
_, err = mac.Write([]byte(session.SessionName + "|" + stamped))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := append([]byte(stamped+"|"), mac.Sum(nil)...)
|
||||
|
||||
return base64.URLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
// decodeCookie feeds value back through the store's decode path.
|
||||
func decodeCookie(
|
||||
t *testing.T,
|
||||
s *session.Session,
|
||||
value string,
|
||||
) (*sessions.Session, error) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: session.SessionName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NotNil(t, sess)
|
||||
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func TestCodec_AcceptsCookieInsideAbsoluteCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
sess, err := decodeCookie(t, s, restamp(
|
||||
t,
|
||||
issuedCookie(t, s),
|
||||
time.Now().Add(-(testAbsoluteMaxAge-time.Hour)),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.False(
|
||||
t, sess.IsNew,
|
||||
"a cookie inside the cap must still decode",
|
||||
)
|
||||
assert.Equal(
|
||||
t, "value", sess.Values["probe"],
|
||||
"decoding must yield the values that were saved",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCodec_RejectsCookiePastAbsoluteCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
sess, err := decodeCookie(t, s, restamp(
|
||||
t,
|
||||
issuedCookie(t, s),
|
||||
time.Now().Add(-(testAbsoluteMaxAge+time.Hour)),
|
||||
))
|
||||
require.Error(
|
||||
t, err,
|
||||
"the codec must refuse a cookie older than the cap",
|
||||
)
|
||||
assert.Contains(
|
||||
t, err.Error(), "expired timestamp",
|
||||
"rejection must come from the codec's age check",
|
||||
)
|
||||
assert.True(
|
||||
t, sess.IsNew,
|
||||
"a cookie past the cap must not populate a session",
|
||||
)
|
||||
assert.Nil(
|
||||
t, sess.Values["probe"],
|
||||
"a cookie past the cap must not yield its values",
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package session
|
||||
|
||||
import "github.com/gorilla/sessions"
|
||||
|
||||
// NewStore exposes the production cookie-store constructor so tests
|
||||
// exercise the store the application actually runs with, rather than a
|
||||
// lookalike assembled in the test.
|
||||
func NewStore(key []byte, secure bool) *sessions.CookieStore {
|
||||
return newStore(key, secure)
|
||||
}
|
||||
@@ -100,35 +100,6 @@ type Session struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// cookieOptions returns the cookie attributes used for every session
|
||||
// cookie. MaxAge is deliberately left at its zero value: for a store
|
||||
// it is set through CookieStore.MaxAge (see newStore), and for a
|
||||
// single session it is copied from the store's options.
|
||||
func cookieOptions(secure bool) *sessions.Options {
|
||||
return &sessions.Options{
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
// newStore builds the session cookie store.
|
||||
//
|
||||
// The absolute cap MUST be applied with store.MaxAge and not by
|
||||
// assigning store.Options.MaxAge. NewCookieStore gives the underlying
|
||||
// securecookie codecs a 30-day max age of their own, and assigning
|
||||
// Options never touches Codecs -- so a store configured that way still
|
||||
// decodes a 30-day-old cookie, leaving the cookie attribute and the
|
||||
// codec disagreeing about the same policy. store.MaxAge sets both.
|
||||
func newStore(key []byte, secure bool) *sessions.CookieStore {
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = cookieOptions(secure)
|
||||
store.MaxAge(secondsPerDay * sessionMaxAgeDays)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// New creates a new session manager. The cookie store is
|
||||
// initialized during the fx OnStart phase after the database is
|
||||
// connected, using a session key that is auto-generated and stored
|
||||
@@ -171,8 +142,19 @@ func New(
|
||||
)
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore(keyBytes)
|
||||
|
||||
// Configure cookie options for security
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||
HttpOnly: true,
|
||||
Secure: !params.Config.IsDev(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
s.key = keyBytes
|
||||
s.store = newStore(keyBytes, !params.Config.IsDev())
|
||||
s.store = store
|
||||
s.log.Info("session manager initialized")
|
||||
|
||||
return nil
|
||||
@@ -368,8 +350,13 @@ func (s *Session) Regenerate(
|
||||
// Apply the standard session options (the destroyed old
|
||||
// session had MaxAge = -1, which store.New might inherit
|
||||
// from the cookie).
|
||||
newSess.Options = cookieOptions(!s.config.IsDev())
|
||||
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
|
||||
newSess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||
HttpOnly: true,
|
||||
Secure: !s.config.IsDev(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
return newSess, nil
|
||||
}
|
||||
|
||||
@@ -39,19 +39,6 @@ func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
// testKey returns the fixed session key the tests sign with. The
|
||||
// codec tests re-sign cookies with it, so it must be the same key the
|
||||
// store was built from.
|
||||
func testKey() []byte {
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i + 42)
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// testSession creates a Session with a real cookie store and the
|
||||
// real clock.
|
||||
func testSession(t *testing.T) *session.Session {
|
||||
@@ -72,8 +59,20 @@ func testSessionWithClock(
|
||||
) (*session.Session, *fakeClock) {
|
||||
t.Helper()
|
||||
|
||||
key := testKey()
|
||||
store := session.NewStore(key, false)
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i + 42)
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
@@ -646,34 +645,6 @@ func TestTouch_LazyBelowRefreshThreshold(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_RefreshThresholdIsOneTenthOfIdleWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// testRefreshDivisor restates the documented bound independently
|
||||
// of the implementation constant: the idle timestamp is rewritten
|
||||
// once it is a tenth of the idle window old, which is what makes
|
||||
// "expires up to 10% early, never late" true. Both assertions are
|
||||
// needed to pin it -- a larger divisor fails the first, a smaller
|
||||
// one fails the second.
|
||||
const testRefreshDivisor = 10
|
||||
|
||||
threshold := testIdleTimeout / testRefreshDivisor
|
||||
|
||||
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
||||
|
||||
clock.Advance(threshold - time.Second)
|
||||
assert.False(
|
||||
t, s.Touch(sess),
|
||||
"Touch must not rewrite the session below a tenth of the window",
|
||||
)
|
||||
|
||||
clock.Advance(time.Second)
|
||||
assert.True(
|
||||
t, s.Touch(sess),
|
||||
"Touch must rewrite the session at a tenth of the window",
|
||||
)
|
||||
}
|
||||
|
||||
func TestTouch_UnauthenticatedSessionIsNotRefreshed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user