Compare commits
3
Commits
3bfddd0862
...
24b767c23b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b767c23b | ||
|
|
755f997b0d | ||
|
|
719dc62b0d |
@@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor(
|
||||
try {
|
||||
repository.changePassword(current = s.current, next = s.next)
|
||||
internal.update {
|
||||
PasswordUiState(message = "Password changed.")
|
||||
PasswordUiState(message = "Password changed. Your other devices have been signed out.")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
|
||||
@@ -385,6 +385,11 @@ func run() error {
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
// Closes keep-alive connections nobody is using. Deliberately no
|
||||
// ReadTimeout or WriteTimeout: either would cut off audio streams and
|
||||
// the SSE event stream. Request bodies get their own deadline in the
|
||||
// server's limitRequestBody middleware instead.
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -140,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
|
||||
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||
})
|
||||
|
||||
@@ -195,7 +195,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -117,7 +117,7 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
// clients skip the conditional GET for the bulk of a session, but
|
||||
// stale art clears within 24h after a re-scan. ServeContent below
|
||||
// still emits Last-Modified for the conditional path when needed.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400, must-revalidate")
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,6 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
// max-age + immutable lets the client cache (LockCachingAudioSource
|
||||
// on the Flutter side, browser cache on web) skip even the
|
||||
// conditional GET on repeat plays.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
||||
// (system playlists re-rendered on rebuild, user playlists when
|
||||
// modified). 5 minutes is short enough for normal edits to feel
|
||||
// fresh, long enough to skip repeat fetches during a session.
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
w.Header().Set("Cache-Control", "private, max-age=300, must-revalidate")
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,36 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
|
||||
return remote
|
||||
}
|
||||
|
||||
// IsHTTPS reports whether the client reached us over HTTPS, reading through
|
||||
// trustedProxyHops reverse proxies by the same rule as ClientIP.
|
||||
//
|
||||
// TLS terminates at the operator's proxy (rule 94), so r.TLS is nil in every
|
||||
// real deployment and only X-Forwarded-Proto can say what the client used.
|
||||
// That header is believed only when hops >= 1: with no trusted proxy it is
|
||||
// just something any client can send. Proxies that append rather than
|
||||
// overwrite produce a comma list, read positionally like X-Forwarded-For.
|
||||
//
|
||||
// This decides only HTTPS-only behaviour (the cookie Secure flag, HSTS). It
|
||||
// must never drive a redirect: plain-HTTP and LAN use keep working.
|
||||
func IsHTTPS(r *http.Request, trustedProxyHops int) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if trustedProxyHops <= 0 {
|
||||
return false
|
||||
}
|
||||
raw := r.Header.Get("X-Forwarded-Proto")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
idx := len(parts) - trustedProxyHops
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(parts[idx]), "https")
|
||||
}
|
||||
|
||||
// forwardedChain returns the X-Forwarded-For entries in wire order, or the
|
||||
// single X-Real-IP value when XFF is absent.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
@@ -168,3 +169,45 @@ func TestClientIP(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hops int
|
||||
proto string
|
||||
tls bool
|
||||
want bool
|
||||
}{
|
||||
// The three cases rule 94 names.
|
||||
{name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false},
|
||||
{name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true},
|
||||
{name: "plain request is not https", hops: 1, want: false},
|
||||
|
||||
{name: "forwarded http is not https", hops: 1, proto: "http", want: false},
|
||||
{name: "case-insensitive", hops: 1, proto: "HTTPS", want: true},
|
||||
{name: "direct TLS is https whatever the hops", hops: 0, tls: true, want: true},
|
||||
// An appending chain is read positionally: with one trusted hop the
|
||||
// last entry is our proxy's word, and a client-supplied first entry
|
||||
// cannot flip it.
|
||||
{name: "appended chain reads our proxy's entry", hops: 1, proto: "https, http", want: false},
|
||||
{name: "two hops read the CDN's entry", hops: 2, proto: "https, http", want: true},
|
||||
{name: "over-counted hops clamp to the first entry", hops: 5, proto: "https", want: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := http.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
if tc.proto != "" {
|
||||
r.Header.Set("X-Forwarded-Proto", tc.proto)
|
||||
}
|
||||
if tc.tls {
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
if got := IsHTTPS(r, tc.hops); got != tc.want {
|
||||
t.Errorf("IsHTTPS(hops=%d, proto=%q) = %v, want %v", tc.hops, tc.proto, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,22 @@ func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, err
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcDeleteExpiredSessions = `-- name: GcDeleteExpiredSessions :execrows
|
||||
DELETE FROM sessions
|
||||
WHERE last_seen_at <= now() - interval '30 days'
|
||||
OR created_at <= now() - interval '365 days'
|
||||
`
|
||||
|
||||
// Sessions past their idle (30 days) or absolute (1 year) limit. They already
|
||||
// fail auth through GetSessionByTokenHash's filter; this only clears the rows.
|
||||
func (q *Queries) GcDeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, gcDeleteExpiredSessions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
|
||||
DELETE FROM scrobble_queue
|
||||
WHERE status = 'failed'
|
||||
|
||||
@@ -69,10 +69,38 @@ func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionFor
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1
|
||||
const deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows
|
||||
DELETE FROM sessions WHERE user_id = $1
|
||||
`
|
||||
|
||||
// Every session the user has, the caller's included. A password reset uses
|
||||
// it: whoever forgot the password is not signed in anywhere they trust, and
|
||||
// whoever may have learned it must be signed out everywhere.
|
||||
func (q *Queries) DeleteSessionsForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteSessionsForUser, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
|
||||
WHERE token_hash = $1
|
||||
AND last_seen_at > now() - interval '30 days'
|
||||
AND created_at > now() - interval '365 days'
|
||||
`
|
||||
|
||||
// Expired sessions are invisible here, so they fail auth the moment they
|
||||
// lapse rather than whenever the GC sweep next runs. Two limits:
|
||||
//
|
||||
// idle 30 days — a token nobody has used in a month is abandoned, and
|
||||
// matches the web cookie's lifetime;
|
||||
// absolute 1 year — even a token in daily use is re-issued yearly, so a
|
||||
// stolen one that is being used quietly does not live
|
||||
// forever.
|
||||
//
|
||||
// Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
|
||||
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
|
||||
var i Session
|
||||
@@ -127,11 +155,17 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
||||
}
|
||||
|
||||
const listSessionsForUser = `-- name: ListSessionsForUser :many
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
|
||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
|
||||
WHERE user_id = $1
|
||||
AND last_seen_at > now() - interval '30 days'
|
||||
AND created_at > now() - interval '365 days'
|
||||
ORDER BY last_seen_at DESC
|
||||
`
|
||||
|
||||
// Most-recently-active first: the row a user is most likely to act on is the
|
||||
// one that moved last, and an unfamiliar entry at the top is the alarm.
|
||||
// Expired rows are left out: they no longer authenticate, so listing them
|
||||
// as active would be wrong. Same limits as GetSessionByTokenHash.
|
||||
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
|
||||
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,3 +68,10 @@ UPDATE system_playlist_runs
|
||||
DELETE FROM password_resets
|
||||
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
|
||||
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');
|
||||
|
||||
-- name: GcDeleteExpiredSessions :execrows
|
||||
-- Sessions past their idle (30 days) or absolute (1 year) limit. They already
|
||||
-- fail auth through GetSessionByTokenHash's filter; this only clears the rows.
|
||||
DELETE FROM sessions
|
||||
WHERE last_seen_at <= now() - interval '30 days'
|
||||
OR created_at <= now() - interval '365 days';
|
||||
|
||||
@@ -7,7 +7,18 @@ VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip))
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSessionByTokenHash :one
|
||||
SELECT * FROM sessions WHERE token_hash = $1;
|
||||
-- Expired sessions are invisible here, so they fail auth the moment they
|
||||
-- lapse rather than whenever the GC sweep next runs. Two limits:
|
||||
-- idle 30 days — a token nobody has used in a month is abandoned, and
|
||||
-- matches the web cookie's lifetime;
|
||||
-- absolute 1 year — even a token in daily use is re-issued yearly, so a
|
||||
-- stolen one that is being used quietly does not live
|
||||
-- forever.
|
||||
-- Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
|
||||
SELECT * FROM sessions
|
||||
WHERE token_hash = $1
|
||||
AND last_seen_at > now() - interval '30 days'
|
||||
AND created_at > now() - interval '365 days';
|
||||
|
||||
-- name: TouchSessionLastSeen :exec
|
||||
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
|
||||
@@ -15,7 +26,13 @@ UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
|
||||
-- name: ListSessionsForUser :many
|
||||
-- Most-recently-active first: the row a user is most likely to act on is the
|
||||
-- one that moved last, and an unfamiliar entry at the top is the alarm.
|
||||
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
|
||||
-- Expired rows are left out: they no longer authenticate, so listing them
|
||||
-- as active would be wrong. Same limits as GetSessionByTokenHash.
|
||||
SELECT * FROM sessions
|
||||
WHERE user_id = $1
|
||||
AND last_seen_at > now() - interval '30 days'
|
||||
AND created_at > now() - interval '365 days'
|
||||
ORDER BY last_seen_at DESC;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions WHERE id = $1;
|
||||
@@ -34,3 +51,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2;
|
||||
-- "Log out everywhere else." Excludes the caller's own session so the action
|
||||
-- doesn't log them out of the page they just used to invoke it.
|
||||
DELETE FROM sessions WHERE user_id = $1 AND id <> $2;
|
||||
|
||||
-- name: DeleteSessionsForUser :execrows
|
||||
-- Every session the user has, the caller's included. A password reset uses
|
||||
-- it: whoever forgot the password is not signed in anywhere they trust, and
|
||||
-- whoever may have learned it must be signed out everywhere.
|
||||
DELETE FROM sessions WHERE user_id = $1;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||
// - GcDeleteExpiredPasswordResets (#575)
|
||||
// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y)
|
||||
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
|
||||
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
|
||||
@@ -86,6 +87,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
||||
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||
w.runSweep(ctx, "delete_expired_sessions", q.GcDeleteExpiredSessions)
|
||||
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
|
||||
// Tags before state: if the process dies between the two, a candidate left
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
// maxRequestBody caps every request body. The largest legitimate one is a
|
||||
// playlist save of a few thousand track ids, well under 1 MiB; 4 MiB leaves
|
||||
// room without letting one request hold arbitrary memory.
|
||||
const maxRequestBody = 4 << 20
|
||||
|
||||
// bodyReadTimeout bounds how long a client may take to send a request body.
|
||||
const bodyReadTimeout = 30 * time.Second
|
||||
|
||||
// limitRequestBody caps the body size and the time allowed to deliver it.
|
||||
//
|
||||
// Why not http.Server.ReadTimeout: that deadline stays armed for the whole
|
||||
// request, and once a handler has consumed the body, net/http's background
|
||||
// read hits it and cancels the request context. That would cut off audio
|
||||
// streams and the SSE event stream at the timeout. Here the deadline is set
|
||||
// only on requests that carry a body, and cleared the moment the body has
|
||||
// been read, so long-running responses are never affected.
|
||||
func limitRequestBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body == nil || r.Body == http.NoBody {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
rc := http.NewResponseController(w)
|
||||
// Best-effort: a ResponseWriter that can't set deadlines (a test
|
||||
// recorder) still gets the size limit.
|
||||
armed := rc.SetReadDeadline(time.Now().Add(bodyReadTimeout)) == nil
|
||||
body := http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||
if armed {
|
||||
body = &deadlineClearingBody{ReadCloser: body, rc: rc}
|
||||
}
|
||||
r.Body = body
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// deadlineClearingBody lifts the read deadline once the body is exhausted.
|
||||
// A deadline change applies to pending reads too, so this also releases the
|
||||
// background read net/http starts at end-of-body.
|
||||
type deadlineClearingBody struct {
|
||||
io.ReadCloser
|
||||
rc *http.ResponseController
|
||||
cleared bool
|
||||
}
|
||||
|
||||
func (b *deadlineClearingBody) Read(p []byte) (int, error) {
|
||||
n, err := b.ReadCloser.Read(p)
|
||||
if err != nil && !b.cleared {
|
||||
b.cleared = true
|
||||
_ = b.rc.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (b *deadlineClearingBody) Close() error {
|
||||
if !b.cleared {
|
||||
b.cleared = true
|
||||
_ = b.rc.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return b.ReadCloser.Close()
|
||||
}
|
||||
|
||||
// requireJSONForCookieWrites refuses a state-changing /api request that is
|
||||
// authenticated by the session cookie unless its body is JSON.
|
||||
//
|
||||
// SameSite=Strict already keeps the cookie off cross-site requests. This is
|
||||
// the backstop for the case SameSite cannot see: a sibling app on the same
|
||||
// registrable domain (another *.fabledsword.com service) counts as same-site.
|
||||
// An HTML form or a no-preflight fetch can only send form-encoded, multipart
|
||||
// or text/plain bodies, so demanding application/json closes that path.
|
||||
// Bearer-token requests and /rest (query-string auth) carry nothing a browser
|
||||
// attaches on its own, so they are not checked. The Android app does send
|
||||
// the cookie, but every body it sends is JSON (Retrofit's kotlinx converter)
|
||||
// and its bodiless writes carry no Content-Type, so it passes unchanged.
|
||||
func requireJSONForCookieWrites(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(auth.SessionCookieName); err != nil || c.Value == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct == "" && (r.ContentLength == 0 || r.Body == nil || r.Body == http.NoBody) {
|
||||
// No body, no content type: nothing a form could have sent.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if mt, _, err := mime.ParseMediaType(ct); err != nil || mt != "application/json" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
_, _ = io.WriteString(w, `{"error":{"code":"unsupported_media_type","message":"request body must be application/json"}}`)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
)
|
||||
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
if _, err := io.ReadAll(r.Body); err != nil {
|
||||
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLimitRequestBody_RejectsOversizedBody(t *testing.T) {
|
||||
h := limitRequestBody(okHandler())
|
||||
|
||||
big := strings.NewReader(strings.Repeat("x", maxRequestBody+1))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", big))
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized body: status = %d, want the handler's read to fail", w.Code)
|
||||
}
|
||||
|
||||
small := strings.NewReader(`{"name":"ok"}`)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/playlists", small))
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("small body: status = %d, want 204", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The cases a cross-site form or no-preflight fetch could produce must be
|
||||
// refused when the session cookie rides along; everything a real client
|
||||
// sends must pass.
|
||||
func TestRequireJSONForCookieWrites(t *testing.T) {
|
||||
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: "tok"}
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
withCookie bool
|
||||
want int
|
||||
}{
|
||||
{"form post with cookie", http.MethodPost, "/api/me/password", "application/x-www-form-urlencoded", "a=b", true, http.StatusUnsupportedMediaType},
|
||||
{"text/plain post with cookie", http.MethodPost, "/api/me/password", "text/plain", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
||||
{"multipart with cookie", http.MethodPut, "/api/me/profile", "multipart/form-data; boundary=x", "--x--", true, http.StatusUnsupportedMediaType},
|
||||
{"body without content type", http.MethodPost, "/api/me/profile", "", `{"a":1}`, true, http.StatusUnsupportedMediaType},
|
||||
|
||||
{"json with cookie", http.MethodPost, "/api/me/password", "application/json", `{}`, true, http.StatusNoContent},
|
||||
{"json with charset", http.MethodPost, "/api/me/password", "application/json; charset=utf-8", `{}`, true, http.StatusNoContent},
|
||||
{"bodiless delete with cookie", http.MethodDelete, "/api/me/sessions/1", "", "", true, http.StatusNoContent},
|
||||
{"form post without cookie (bearer client)", http.MethodPost, "/api/me/password", "text/plain", "x", false, http.StatusNoContent},
|
||||
{"subsonic post is not /api", http.MethodPost, "/rest/scrobble", "application/x-www-form-urlencoded", "id=1", true, http.StatusNoContent},
|
||||
{"GET is never checked", http.MethodGet, "/api/me", "text/plain", "", true, http.StatusNoContent},
|
||||
}
|
||||
h := requireJSONForCookieWrites(okHandler())
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.body != "" {
|
||||
body = strings.NewReader(tc.body)
|
||||
}
|
||||
req := httptest.NewRequest(tc.method, tc.path, body)
|
||||
if tc.contentType != "" {
|
||||
req.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
if tc.withCookie {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(requestLog(s.Logger, netSettings.Hops))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(limitRequestBody)
|
||||
r.Use(requireJSONForCookieWrites)
|
||||
|
||||
r.Get("/healthz", s.handleHealthz)
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
passwordSaving = true;
|
||||
try {
|
||||
await changePassword(passwordForm.current, passwordForm.new);
|
||||
pushToast('Password changed.');
|
||||
pushToast('Password changed. Your other devices have been signed out.');
|
||||
passwordForm = { current: '', new: '', confirm: '' };
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
|
||||
Reference in New Issue
Block a user