Compare commits
1 Commits
issue-115-
...
issue-108-
| Author | SHA1 | Date | |
|---|---|---|---|
| 618b07ca0f |
12
README.md
12
README.md
@@ -150,9 +150,10 @@ one runs out first:
|
|||||||
- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding
|
- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding
|
||||||
window. Every authenticated request pushes it forward, so a session
|
window. Every authenticated request pushes it forward, so a session
|
||||||
in continuous use never hits it, while an abandoned one expires a day
|
in continuous use never hits it, while an abandoned one expires a day
|
||||||
after its last use. Set it to `0` to disable idle expiry entirely;
|
after its last use. Any non-positive value (`0`, or a negative
|
||||||
the absolute cap below still applies. A set-but-unparseable value
|
duration such as `-1s`) disables idle expiry entirely; the absolute
|
||||||
aborts startup rather than silently falling back to the default.
|
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
|
- **Absolute expiry** is a fixed 7 days from login. Activity does
|
||||||
**not** extend it: after a week, every session ends and the user
|
**not** extend it: after a week, every session ends and the user
|
||||||
authenticates again.
|
authenticates again.
|
||||||
@@ -164,6 +165,11 @@ 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
|
expire up to 10% early relative to the user's true last request, but
|
||||||
never late.
|
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
|
#### Invalid values abort startup
|
||||||
|
|
||||||
The defaults above apply **only** to variables that are unset (or set
|
The defaults above apply **only** to variables that are unset (or set
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ func slackConfigFields(configJSON string) []ConfigField {
|
|||||||
// and its retry settings. Header values are not shown — they
|
// and its retry settings. Header values are not shown — they
|
||||||
// routinely carry authorization tokens — only how many are
|
// routinely carry authorization tokens — only how many are
|
||||||
// configured.
|
// configured.
|
||||||
//
|
|
||||||
// The destination is masked to scheme and host by the same
|
|
||||||
// rule the Slack target uses. An HTTP target's destination is
|
|
||||||
// commonly a Slack, Discord or Teams incoming-webhook endpoint
|
|
||||||
// whose path segments are the credential, and the field takes
|
|
||||||
// an arbitrary URL, so no segment can be assumed non-secret.
|
|
||||||
func httpConfigFields(t *database.Target) []ConfigField {
|
func httpConfigFields(t *database.Target) []ConfigField {
|
||||||
cfg, err := parseHTTPConfig(t.Config)
|
cfg, err := parseHTTPConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +114,7 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
|
|
||||||
fields := []ConfigField{{
|
fields := []ConfigField{{
|
||||||
Label: "Destination URL",
|
Label: "Destination URL",
|
||||||
Value: MaskURL(cfg.URL),
|
Value: cfg.URL,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if cfg.Timeout > 0 {
|
if cfg.Timeout > 0 {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const (
|
|||||||
|
|
||||||
viewExampleOrigin = "https://example.com"
|
viewExampleOrigin = "https://example.com"
|
||||||
viewExampleHook = viewExampleOrigin + "/hook"
|
viewExampleHook = viewExampleOrigin + "/hook"
|
||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
)
|
)
|
||||||
@@ -163,7 +162,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewExampleHook,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Max Retries": "5",
|
"Max Retries": "5",
|
||||||
@@ -189,41 +188,13 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewExampleHook,
|
||||||
"Max Retries": "0 (fire-and-forget)",
|
"Max Retries": "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_HTTPMasksDestinationURL proves the rule
|
|
||||||
// holds for the http target too: an http destination is
|
|
||||||
// routinely an incoming-webhook endpoint whose path segments
|
|
||||||
// are the credential, so none of them is shown.
|
|
||||||
func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Config: `{"url":"` + slackWebhookURL + `"}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
"https://hooks.slack.com/...",
|
|
||||||
fields["Destination URL"],
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, v := range fields {
|
|
||||||
assert.NotContains(t, v, slackSecretPath)
|
|
||||||
assert.NotContains(t, v, "T00000000")
|
|
||||||
assert.NotContains(t, v, "B00000000")
|
|
||||||
assert.NotContains(t, v, "XXXXXXXXXXXXXXXXXXXXXXXX")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTargetViews_Database(t *testing.T) {
|
func TestNewTargetViews_Database(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -131,47 +131,6 @@ func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) {
|
|||||||
assert.Contains(t, body, "https://hooks.slack.com/...")
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksHTTPDestinationURL is the
|
|
||||||
// regression test for the same leak reached through the http
|
|
||||||
// target: its destination is routinely an incoming-webhook
|
|
||||||
// endpoint whose path segments are the credential, so the
|
|
||||||
// rendered page must not contain them.
|
|
||||||
func TestHandleSourceDetail_MasksHTTPDestinationURL(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
seedConfiguredTarget(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+slackWebhookURL+`"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.NotContains(t, body, slackSecretPath)
|
|
||||||
assert.NotContains(t, body, "T00000000")
|
|
||||||
assert.NotContains(t, body, "B00000000")
|
|
||||||
assert.NotContains(
|
|
||||||
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Contains(t, body, "Destination URL")
|
|
||||||
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
||||||
// other target types render labelled fields rather than the
|
// other target types render labelled fields rather than the
|
||||||
// stored blob.
|
// stored blob.
|
||||||
@@ -213,7 +172,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body, "Destination URL")
|
assert.Contains(t, body, "Destination URL")
|
||||||
assert.Contains(t, body, "https://example.com/...")
|
assert.Contains(t, body, "https://example.com/hook")
|
||||||
assert.Contains(t, body, "Timeout")
|
assert.Contains(t, body, "Timeout")
|
||||||
assert.Contains(t, body, "1 configured")
|
assert.Contains(t, body, "1 configured")
|
||||||
assert.NotContains(t, body, "sekrit")
|
assert.NotContains(t, body, "sekrit")
|
||||||
|
|||||||
@@ -214,11 +214,11 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
// handler runs, while the headers are still ours to
|
// handler runs, while the headers are still ours to
|
||||||
// write.
|
// write.
|
||||||
if s.session.Touch(sess) {
|
if s.session.Touch(sess) {
|
||||||
err = s.session.Save(r, w, sess)
|
saveErr := s.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if saveErr != nil {
|
||||||
s.log.Error(
|
s.log.Error(
|
||||||
"auth middleware: failed to refresh session",
|
"auth middleware: failed to refresh session",
|
||||||
"error", err,
|
"error", saveErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
internal/session/codec_test.go
Normal file
150
internal/session/codec_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
10
internal/session/export_test.go
Normal file
10
internal/session/export_test.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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,6 +100,35 @@ type Session struct {
|
|||||||
now func() time.Time
|
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
|
// New creates a new session manager. The cookie store is
|
||||||
// initialized during the fx OnStart phase after the database is
|
// initialized during the fx OnStart phase after the database is
|
||||||
// connected, using a session key that is auto-generated and stored
|
// connected, using a session key that is auto-generated and stored
|
||||||
@@ -142,19 +171,8 @@ 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.key = keyBytes
|
||||||
s.store = store
|
s.store = newStore(keyBytes, !params.Config.IsDev())
|
||||||
s.log.Info("session manager initialized")
|
s.log.Info("session manager initialized")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -350,13 +368,8 @@ func (s *Session) Regenerate(
|
|||||||
// Apply the standard session options (the destroyed old
|
// Apply the standard session options (the destroyed old
|
||||||
// session had MaxAge = -1, which store.New might inherit
|
// session had MaxAge = -1, which store.New might inherit
|
||||||
// from the cookie).
|
// from the cookie).
|
||||||
newSess.Options = &sessions.Options{
|
newSess.Options = cookieOptions(!s.config.IsDev())
|
||||||
Path: "/",
|
newSess.Options.MaxAge = secondsPerDay * sessionMaxAgeDays
|
||||||
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: !s.config.IsDev(),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSess, nil
|
return newSess, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ func (c *fakeClock) Advance(d time.Duration) {
|
|||||||
c.t = c.t.Add(d)
|
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
|
// testSession creates a Session with a real cookie store and the
|
||||||
// real clock.
|
// real clock.
|
||||||
func testSession(t *testing.T) *session.Session {
|
func testSession(t *testing.T) *session.Session {
|
||||||
@@ -59,20 +72,8 @@ func testSessionWithClock(
|
|||||||
) (*session.Session, *fakeClock) {
|
) (*session.Session, *fakeClock) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
key := make([]byte, testKeySize)
|
key := testKey()
|
||||||
|
store := session.NewStore(key, false)
|
||||||
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{
|
cfg := &config.Config{
|
||||||
Environment: config.EnvironmentDev,
|
Environment: config.EnvironmentDev,
|
||||||
@@ -645,6 +646,34 @@ 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) {
|
func TestTouch_UnauthenticatedSessionIsNotRefreshed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user