feat(auth): sessions expire server-side; password change and reset end other sessions (M462 #4978)

Sessions had no server-side expiry: only the web cookie's 30-day Max-Age
limited them, and a bearer token (Android) lived until revoked by hand.
GetSessionByTokenHash and ListSessionsForUser now ignore sessions idle for
30 days or older than a year, and the GC worker deletes them hourly.

A password change was a plain UPDATE, so a session opened with the old
password survived it. Now:
- self-service change signs out every other device and keeps this one;
- reset by email ends every session the account has;
- an admin reset ends the target's sessions (keeping the admin's own when
  they reset themselves).

The success copy on web and Android says the other devices were signed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:32:39 -04:00
co-authored by Claude Opus 5.5
parent 719dc62b0d
commit 755f997b0d
11 changed files with 262 additions and 7 deletions
+13
View File
@@ -285,6 +285,19 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
return
}
// The target's existing sessions end with the old password. An admin
// resetting their own password keeps the session they're using, the
// same as a self-service change.
sessID, ownSession := auth.SessionIDFromContext(r.Context())
if targetID == caller.ID && ownSession {
_, err = q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{UserID: targetID, ID: sessID})
} else {
_, err = q.DeleteSessionsForUser(r.Context(), targetID)
}
if err != nil {
h.logger.Error("admin reset password: revoke sessions failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil)
w.WriteHeader(http.StatusNoContent)
+7
View File
@@ -104,6 +104,13 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
return
}
// End every session the account has. Whoever needed a reset isn't signed
// in anywhere they rely on, and whoever may have learned the old password
// must not stay signed in on it.
if _, err := q.DeleteSessionsForUser(r.Context(), reset.UserID); err != nil {
h.logger.Error("reset password: revoke sessions failed", "err", err)
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
w.WriteHeader(http.StatusNoContent)
+14
View File
@@ -8,6 +8,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -61,6 +62,19 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
return
}
// Sign out every other device. A password change is often the response
// to suspecting someone else has it, and their session would otherwise
// outlive the password it was opened with. The device making the change
// stays signed in.
if sessID, ok := auth.SessionIDFromContext(r.Context()); ok {
if _, err := q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
UserID: user.ID,
ID: sessID,
}); err != nil {
h.logger.Error("change password: revoke other sessions failed", "err", err)
}
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil)
w.WriteHeader(http.StatusNoContent)
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func sessionExists(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) bool {
t.Helper()
var exists bool
if err := pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, id,
).Scan(&exists); err != nil {
t.Fatalf("exists check: %v", err)
}
return exists
}
// Sessions expire server-side on two limits, and an expired one stops
// authenticating at once rather than when the GC sweep gets to it.
func TestSessionExpiry_IdleAndAbsoluteLimits(t *testing.T) {
_, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
q := dbq.New(pool)
mint := func(setup string) []byte {
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
hash := auth.HashSessionToken(token)
sess, err := q.InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID, TokenHash: hash, UserAgent: "test", Ip: "192.0.2.1",
})
if err != nil {
t.Fatalf("insert: %v", err)
}
if setup != "" {
if _, err := pool.Exec(context.Background(), `UPDATE sessions SET `+setup+` WHERE id = $1`, sess.ID); err != nil {
t.Fatalf("age session: %v", err)
}
}
return hash
}
fresh := mint("")
idle := mint(`last_seen_at = now() - interval '31 days'`)
old := mint(`created_at = now() - interval '366 days'`)
if _, err := q.GetSessionByTokenHash(context.Background(), fresh); err != nil {
t.Errorf("fresh session: %v, want found", err)
}
for name, hash := range map[string][]byte{"idle": idle, "absolute": old} {
if _, err := q.GetSessionByTokenHash(context.Background(), hash); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("%s-expired session: err = %v, want ErrNoRows", name, err)
}
}
listed, err := q.ListSessionsForUser(context.Background(), user.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 {
t.Errorf("listed %d sessions, want only the live one", len(listed))
}
swept, err := q.GcDeleteExpiredSessions(context.Background())
if err != nil {
t.Fatalf("gc: %v", err)
}
if swept != 2 {
t.Errorf("gc swept %d, want the 2 expired rows", swept)
}
}
// Changing your password signs out every other device and keeps this one.
func TestHandleChangePassword_RevokesOtherSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
current := seedSession(t, pool, user.ID, "192.0.2.1")
other := seedSession(t, pool, user.ID, "198.51.100.7")
body := strings.NewReader(`{"current_password":"hunter2","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/password", body)
req.Header.Set("Content-Type", "application/json")
req = withSession(req, user, current)
w := httptest.NewRecorder()
h.handleChangePassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if !sessionExists(t, pool, current) {
t.Error("the device that changed the password was signed out")
}
if sessionExists(t, pool, other) {
t.Error("another device's session survived the password change")
}
}
// A reset by email ends every session the account has.
func TestHandleResetPassword_RevokesAllSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
s1 := seedSession(t, pool, user.ID, "192.0.2.1")
s2 := seedSession(t, pool, user.ID, "198.51.100.7")
const token = "reset-token-for-session-lifecycle-test"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at) VALUES ($1, $2, now() + interval '1 hour')`,
token, user.ID,
); err != nil {
t.Fatalf("seed reset: %v", err)
}
body := strings.NewReader(`{"token":"` + token + `","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleResetPassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if sessionExists(t, pool, s1) || sessionExists(t, pool, s2) {
t.Error("a session survived the password reset")
}
}