Compare commits
2
Commits
main
..
42b6916c02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b6916c02 | ||
|
|
e3c4ba03ad |
@@ -538,12 +538,6 @@ its Argon2id hash. There is no second account and no forgot-password
|
|||||||
flow, so the banner and the reset command below are the only two ways
|
flow, so the banner and the reset command below are the only two ways
|
||||||
in.
|
in.
|
||||||
|
|
||||||
A start that finds no `webhooker.db` in `DATA_DIR` also logs
|
|
||||||
`created a new, empty database` at `WARN`, with the file's path,
|
|
||||||
shortly before the banner. On a deployment that has run before, that
|
|
||||||
line means `DATA_DIR` was empty, most often because its volume is not
|
|
||||||
mounted.
|
|
||||||
|
|
||||||
#### Recovering a lost admin password
|
#### Recovering a lost admin password
|
||||||
|
|
||||||
`webhooker resetpw` sets an existing account's password from the
|
`webhooker resetpw` sets an existing account's password from the
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package database_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -85,37 +83,3 @@ func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
|
|||||||
t, ok, "the printed password must open the seeded account",
|
t, ok, "the printed password must open the seeded account",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewDatabase_IsLoggedWithItsPath is the log half of
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/359. A DATA_DIR that is
|
|
||||||
// unexpectedly empty boots exactly like a first start, so the start
|
|
||||||
// that creates the database must say so, and where. Opening that
|
|
||||||
// database again must not.
|
|
||||||
func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
open := func() string {
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = `level=WARN msg="created a new, empty database"`
|
|
||||||
|
|
||||||
first := open()
|
|
||||||
second := open()
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, first,
|
|
||||||
created+" path="+filepath.Join(dir, database.MainDBFileName),
|
|
||||||
)
|
|
||||||
assert.NotContains(
|
|
||||||
t, second, created, "an existing database is not new",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -200,12 +199,6 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
// Construct the main application database path inside DATA_DIR.
|
// Construct the main application database path inside DATA_DIR.
|
||||||
dbPath := filepath.Join(dataDir, MainDBFileName)
|
dbPath := filepath.Join(dataDir, MainDBFileName)
|
||||||
|
|
||||||
// Checked before opening, which creates the file. A DATA_DIR that
|
|
||||||
// is unexpectedly empty -- its volume not mounted, say -- looks
|
|
||||||
// exactly like a first start, so a new database is a warning.
|
|
||||||
_, statErr := os.Stat(dbPath)
|
|
||||||
created := errors.Is(statErr, fs.ErrNotExist)
|
|
||||||
|
|
||||||
// Opened through OpenSQLite so this handle carries the same WAL
|
// Opened through OpenSQLite so this handle carries the same WAL
|
||||||
// journaling, busy timeout, immediate-transaction locking, and pool
|
// journaling, busy timeout, immediate-transaction locking, and pool
|
||||||
// bounds as every other database file. See sqlite_open.go.
|
// bounds as every other database file. See sqlite_open.go.
|
||||||
@@ -236,12 +229,7 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
d.db = db
|
d.db = db
|
||||||
|
d.log.Info("connected to database", "path", dbPath)
|
||||||
if created {
|
|
||||||
d.log.Warn("created a new, empty database", "path", dbPath)
|
|
||||||
} else {
|
|
||||||
d.log.Info("connected to database", "path", dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
return d.migrate()
|
return d.migrate()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -221,21 +220,9 @@ func (e *testEnv) csrfFrom(
|
|||||||
// out of the markup has to be unescaped before it is submitted.
|
// out of the markup has to be unescaped before it is submitted.
|
||||||
token := html.UnescapeString(match[1])
|
token := html.UnescapeString(match[1])
|
||||||
|
|
||||||
// A cookie the page sets replaces the one of the same name, as in
|
combined := make([]*http.Cookie, 0, len(cookies))
|
||||||
// a browser. Sent both, the server would read the first, older one.
|
combined = append(combined, cookies...)
|
||||||
set := w.Result().Cookies()
|
combined = append(combined, w.Result().Cookies()...)
|
||||||
combined := make([]*http.Cookie, 0, len(cookies)+len(set))
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
replaced := slices.ContainsFunc(set, func(n *http.Cookie) bool {
|
|
||||||
return n.Name == c.Name
|
|
||||||
})
|
|
||||||
if !replaced {
|
|
||||||
combined = append(combined, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
combined = append(combined, set...)
|
|
||||||
|
|
||||||
return token, combined
|
return token, combined
|
||||||
}
|
}
|
||||||
@@ -627,59 +614,6 @@ func TestPagesLogin_CorrectPasswordSurvivesASpentBudget(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPagesLogin_CookiesFromAnEarlierDatabase is
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/359. A new database
|
|
||||||
// brings a new session key, and the operator's browser still holds
|
|
||||||
// the session and CSRF cookies signed with the old one. Logging in
|
|
||||||
// must work as from a fresh browser and leave cookies the new key
|
|
||||||
// accepts.
|
|
||||||
func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
username = "operator"
|
|
||||||
password = "correct-horse-battery-staple"
|
|
||||||
)
|
|
||||||
|
|
||||||
earlier := newTestEnv(t)
|
|
||||||
earlierID, _ := earlier.seedUser(t, username, password)
|
|
||||||
_, stale := earlier.csrfFrom(t, "/pages/login", nil)
|
|
||||||
stale = append(stale, earlier.authCookies(t, earlierID, username)...)
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
env.seedUser(t, username, password)
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, "/pages/login", stale)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("username", username)
|
|
||||||
form.Set("password", password)
|
|
||||||
|
|
||||||
w := env.post("/pages/login", form, cookies)
|
|
||||||
require.Equal(
|
|
||||||
t, http.StatusSeeOther, w.Code,
|
|
||||||
"a session cookie from another key must not fail the login",
|
|
||||||
)
|
|
||||||
|
|
||||||
// The response deletes the old session cookie and then sets the
|
|
||||||
// new one; a browser keeps the last.
|
|
||||||
var fresh *http.Cookie
|
|
||||||
|
|
||||||
for _, c := range w.Result().Cookies() {
|
|
||||||
if c.Name == session.SessionName {
|
|
||||||
fresh = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotNil(t, fresh, "login must set a session cookie")
|
|
||||||
assert.Equal(
|
|
||||||
t, "/sources",
|
|
||||||
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
|
||||||
"the new session cookie must authenticate",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The tests below exercise the securecookie codecs underneath the
|
// The tests below exercise the securecookie codecs underneath the
|
||||||
// store and nothing else: they decode through the store itself, so no
|
// store and nothing else: Session.Get only decodes, so no server-side
|
||||||
// server-side expiry check takes part in the result. They exist because
|
// expiry check takes part in the result. They exist because
|
||||||
// NewCookieStore gives its codecs a 30-day max age that assigning
|
// NewCookieStore gives its codecs a 30-day max age that assigning
|
||||||
// store.Options does not override, which would let the codec accept a
|
// store.Options does not override, which would let the codec accept a
|
||||||
// cookie weeks past the cap the cookie attribute advertises.
|
// cookie weeks past the cap the cookie attribute advertises.
|
||||||
@@ -75,11 +75,10 @@ func restamp(
|
|||||||
return base64.URLEncoding.EncodeToString(payload)
|
return base64.URLEncoding.EncodeToString(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeCookie feeds value back through the store's decode path. It
|
// decodeCookie feeds value back through the store's decode path.
|
||||||
// asks the store rather than Session.Get, which treats a cookie that
|
|
||||||
// does not decode as absent and so hides the codec's reason.
|
|
||||||
func decodeCookie(
|
func decodeCookie(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
|
s *session.Session,
|
||||||
value string,
|
value string,
|
||||||
) (*sessions.Session, error) {
|
) (*sessions.Session, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -95,7 +94,7 @@ func decodeCookie(
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
sess, err := session.NewStore(testKey()).Get(req, session.SessionName)
|
sess, err := s.Get(req)
|
||||||
require.NotNil(t, sess)
|
require.NotNil(t, sess)
|
||||||
|
|
||||||
return sess, err
|
return sess, err
|
||||||
@@ -106,7 +105,7 @@ func TestCodec_AcceptsCookieInsideAbsoluteCap(t *testing.T) {
|
|||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
sess, err := decodeCookie(t, restamp(
|
sess, err := decodeCookie(t, s, restamp(
|
||||||
t,
|
t,
|
||||||
issuedCookie(t, s),
|
issuedCookie(t, s),
|
||||||
time.Now().Add(-(testAbsoluteMaxAge-time.Hour)),
|
time.Now().Add(-(testAbsoluteMaxAge-time.Hour)),
|
||||||
@@ -127,7 +126,7 @@ func TestCodec_RejectsCookiePastAbsoluteCap(t *testing.T) {
|
|||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
sess, err := decodeCookie(t, restamp(
|
sess, err := decodeCookie(t, s, restamp(
|
||||||
t,
|
t,
|
||||||
issuedCookie(t, s),
|
issuedCookie(t, s),
|
||||||
time.Now().Add(-(testAbsoluteMaxAge+time.Hour)),
|
time.Now().Add(-(testAbsoluteMaxAge+time.Hour)),
|
||||||
|
|||||||
@@ -224,22 +224,10 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a session for the request.
|
// Get retrieves a session for the request.
|
||||||
//
|
|
||||||
// A session cookie that does not decode -- one signed with an earlier
|
|
||||||
// session key, say, because the database was made anew -- is treated
|
|
||||||
// as absent: the caller gets a new, empty session and no error, and
|
|
||||||
// the next save replaces the cookie.
|
|
||||||
func (s *Session) Get(
|
func (s *Session) Get(
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (*sessions.Session, error) {
|
) (*sessions.Session, error) {
|
||||||
sess, err := s.store.Get(r, SessionName)
|
return s.store.Get(r, SessionName)
|
||||||
if sess == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// For a cookie that does not decode, gorilla/sessions returns a
|
|
||||||
// new, empty session alongside the error that is dropped here.
|
|
||||||
return sess, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetKey returns the raw 32-byte authentication key used for
|
// GetKey returns the raw 32-byte authentication key used for
|
||||||
|
|||||||
Reference in New Issue
Block a user