Browse Source

refactor: rewrite and simplify web sessions management

Frédéric Guillot 2 days ago
parent
commit
182a010ea7
88 changed files with 1638 additions and 1564 deletions
  1. 7 6
      internal/cli/cleanup_tasks.go
  2. 26 0
      internal/database/migrations.go
  3. 0 51
      internal/http/cookie/cookie.go
  4. 26 83
      internal/http/request/context.go
  5. 34 250
      internal/http/request/context_test.go
  6. 0 69
      internal/model/app_session.go
  7. 0 30
      internal/model/user_session.go
  8. 287 0
      internal/model/web_session.go
  9. 429 0
      internal/model/web_session_test.go
  10. 0 29
      internal/model/webauthn.go
  11. 0 145
      internal/storage/session.go
  12. 0 189
      internal/storage/user_session.go
  13. 291 0
      internal/storage/web_session.go
  14. 4 4
      internal/template/templates/common/layout.html
  15. 3 3
      internal/template/templates/views/sessions.html
  16. 1 3
      internal/ui/about.go
  17. 1 3
      internal/ui/api_key_create.go
  18. 1 3
      internal/ui/api_key_list.go
  19. 1 3
      internal/ui/api_key_save.go
  20. 64 0
      internal/ui/auth.go
  21. 102 0
      internal/ui/auth_proxy_middleware.go
  22. 1 3
      internal/ui/category_create.go
  23. 1 3
      internal/ui/category_edit.go
  24. 1 3
      internal/ui/category_entries.go
  25. 1 3
      internal/ui/category_entries_all.go
  26. 1 3
      internal/ui/category_entries_starred.go
  27. 1 3
      internal/ui/category_feeds.go
  28. 1 3
      internal/ui/category_list.go
  29. 6 7
      internal/ui/category_refresh.go
  30. 1 3
      internal/ui/category_save.go
  31. 1 3
      internal/ui/category_update.go
  32. 56 0
      internal/ui/csrf_middleware.go
  33. 1 3
      internal/ui/entry_category.go
  34. 1 3
      internal/ui/entry_feed.go
  35. 1 3
      internal/ui/entry_read.go
  36. 1 3
      internal/ui/entry_search.go
  37. 1 3
      internal/ui/entry_starred.go
  38. 1 3
      internal/ui/entry_tag.go
  39. 1 3
      internal/ui/entry_unread.go
  40. 1 3
      internal/ui/feed_edit.go
  41. 1 3
      internal/ui/feed_entries.go
  42. 1 3
      internal/ui/feed_entries_all.go
  43. 1 3
      internal/ui/feed_list.go
  44. 6 7
      internal/ui/feed_refresh.go
  45. 1 3
      internal/ui/feed_update.go
  46. 1 3
      internal/ui/history_entries.go
  47. 1 3
      internal/ui/integration_show.go
  48. 6 7
      internal/ui/integration_update.go
  49. 12 22
      internal/ui/login_check.go
  50. 1 3
      internal/ui/login_show.go
  51. 4 16
      internal/ui/logout.go
  52. 0 295
      internal/ui/middleware.go
  53. 0 22
      internal/ui/oauth2.go
  54. 16 29
      internal/ui/oauth2_callback.go
  55. 1 4
      internal/ui/oauth2_redirect.go
  56. 4 5
      internal/ui/oauth2_unlink.go
  57. 1 4
      internal/ui/offline.go
  58. 1 3
      internal/ui/opml_import.go
  59. 2 5
      internal/ui/opml_upload.go
  60. 58 0
      internal/ui/routes.go
  61. 1 3
      internal/ui/search.go
  62. 0 75
      internal/ui/session/session.go
  63. 5 7
      internal/ui/session_list.go
  64. 2 2
      internal/ui/session_remove.go
  65. 1 3
      internal/ui/settings_show.go
  66. 4 6
      internal/ui/settings_update.go
  67. 1 3
      internal/ui/share.go
  68. 1 3
      internal/ui/shared_entries.go
  69. 1 3
      internal/ui/starred_entries.go
  70. 1 3
      internal/ui/starred_entry_category.go
  71. 1 1
      internal/ui/static_manifest.go
  72. 1 3
      internal/ui/subscription_add.go
  73. 1 3
      internal/ui/subscription_bookmarklet.go
  74. 1 3
      internal/ui/subscription_choose.go
  75. 2 4
      internal/ui/subscription_submit.go
  76. 1 3
      internal/ui/tag_entries_all.go
  77. 6 4
      internal/ui/ui.go
  78. 1 3
      internal/ui/unread_entries.go
  79. 1 3
      internal/ui/unread_entry_category.go
  80. 1 3
      internal/ui/unread_entry_feed.go
  81. 1 3
      internal/ui/user_create.go
  82. 1 3
      internal/ui/user_edit.go
  83. 1 3
      internal/ui/user_list.go
  84. 1 3
      internal/ui/user_save.go
  85. 1 3
      internal/ui/user_update.go
  86. 14 13
      internal/ui/view/view.go
  87. 91 0
      internal/ui/web_session_middleware.go
  88. 21 29
      internal/ui/webauthn.go

+ 7 - 6
internal/cli/cleanup_tasks.go

@@ -14,12 +14,13 @@ import (
 )
 )
 
 
 func runCleanupTasks(store *storage.Storage) {
 func runCleanupTasks(store *storage.Storage) {
-	nbSessions := store.CleanOldSessions(config.Opts.CleanupRemoveSessionsInterval())
-	nbUserSessions := store.CleanOldUserSessions(config.Opts.CleanupRemoveSessionsInterval())
-	slog.Info("Sessions cleanup completed",
-		slog.Int64("application_sessions_removed", nbSessions),
-		slog.Int64("user_sessions_removed", nbUserSessions),
-	)
+	if nbWebSessions, err := store.CleanOldWebSessions(config.Opts.CleanupRemoveSessionsInterval()); err != nil {
+		slog.Error("Unable to clean old web sessions", slog.Any("error", err))
+	} else {
+		slog.Info("Sessions cleanup completed",
+			slog.Int64("web_sessions_removed", nbWebSessions),
+		)
+	}
 
 
 	startTime := time.Now()
 	startTime := time.Now()
 	if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {
 	if rowsAffected, err := store.ArchiveEntries(model.EntryStatusRead, config.Opts.CleanupArchiveReadInterval(), config.Opts.CleanupArchiveBatchSize()); err != nil {

+ 26 - 0
internal/database/migrations.go

@@ -1431,4 +1431,30 @@ var migrations = [...]func(tx *sql.Tx) error{
 		_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
 		_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN ignore_entry_updates bool default 'f'`)
 		return err
 		return err
 	},
 	},
+	func(tx *sql.Tx) (err error) {
+		_, err = tx.Exec(`
+			DROP TABLE IF EXISTS sessions;
+			DROP TABLE IF EXISTS user_sessions;
+
+			CREATE TABLE web_sessions (
+				id text not null,
+				secret_hash bytea not null,
+				user_id int references users(id) on delete cascade,
+				created_at timestamp with time zone not null default now(),
+				user_agent text not null default '',
+				ip inet,
+				state jsonb not null default '{}'::jsonb,
+				primary key (id),
+				check (jsonb_typeof(state) = 'object')
+			);
+
+			CREATE INDEX web_sessions_user_id_idx
+				ON web_sessions (user_id)
+				WHERE user_id IS NOT NULL;
+
+			CREATE INDEX web_sessions_created_at_idx
+				ON web_sessions (created_at);
+		`)
+		return err
+	},
 }
 }

+ 0 - 51
internal/http/cookie/cookie.go

@@ -1,51 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package cookie // import "miniflux.app/v2/internal/http/cookie"
-
-import (
-	"net/http"
-	"time"
-
-	"miniflux.app/v2/internal/config"
-)
-
-// Cookie names.
-const (
-	CookieAppSessionID  = "MinifluxAppSessionID"
-	CookieUserSessionID = "MinifluxUserSessionID"
-)
-
-// New creates a new cookie.
-func New(name, value string, isHTTPS bool, path string) *http.Cookie {
-	return &http.Cookie{
-		Name:     name,
-		Value:    value,
-		Path:     basePath(path),
-		Secure:   isHTTPS,
-		HttpOnly: true,
-		Expires:  time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
-		SameSite: http.SameSiteLaxMode,
-	}
-}
-
-// Expired returns an expired cookie.
-func Expired(name string, isHTTPS bool, path string) *http.Cookie {
-	return &http.Cookie{
-		Name:     name,
-		Value:    "",
-		Path:     basePath(path),
-		Secure:   isHTTPS,
-		HttpOnly: true,
-		MaxAge:   -1,
-		Expires:  time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
-		SameSite: http.SameSiteLaxMode,
-	}
-}
-
-func basePath(path string) string {
-	if path == "" {
-		return "/"
-	}
-	return path
-}

+ 26 - 83
internal/http/request/context.go

@@ -5,8 +5,6 @@ package request // import "miniflux.app/v2/internal/http/request"
 
 
 import (
 import (
 	"net/http"
 	"net/http"
-	"strconv"
-	"time"
 
 
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 )
 )
@@ -21,26 +19,16 @@ const (
 	UserTimezoneContextKey
 	UserTimezoneContextKey
 	IsAdminUserContextKey
 	IsAdminUserContextKey
 	IsAuthenticatedContextKey
 	IsAuthenticatedContextKey
-	UserSessionTokenContextKey
-	UserLanguageContextKey
-	UserThemeContextKey
-	SessionIDContextKey
-	CSRFContextKey
-	OAuth2StateContextKey
-	OAuth2CodeVerifierContextKey
-	FlashMessageContextKey
-	FlashErrorMessageContextKey
-	LastForceRefreshContextKey
+	WebSessionContextKey
 	ClientIPContextKey
 	ClientIPContextKey
 	GoogleReaderTokenKey
 	GoogleReaderTokenKey
-	WebAuthnDataContextKey
 )
 )
 
 
-// WebAuthnSessionData returns WebAuthn session data from the request context, or nil if absent.
-func WebAuthnSessionData(r *http.Request) *model.WebAuthnSession {
-	if v := r.Context().Value(WebAuthnDataContextKey); v != nil {
-		if value, valid := v.(model.WebAuthnSession); valid {
-			return &value
+// WebSession returns the current web session from the request context, if present.
+func WebSession(r *http.Request) *model.WebSession {
+	if v := r.Context().Value(WebSessionContextKey); v != nil {
+		if value, valid := v.(*model.WebSession); valid {
+			return value
 		}
 		}
 	}
 	}
 	return nil
 	return nil
@@ -58,12 +46,30 @@ func IsAdminUser(r *http.Request) bool {
 
 
 // IsAuthenticated reports whether the user is authenticated.
 // IsAuthenticated reports whether the user is authenticated.
 func IsAuthenticated(r *http.Request) bool {
 func IsAuthenticated(r *http.Request) bool {
-	return getContextBoolValue(r, IsAuthenticatedContextKey)
+	if getContextBoolValue(r, IsAuthenticatedContextKey) {
+		return true
+	}
+
+	if session := WebSession(r); session != nil {
+		return session.IsAuthenticated()
+	}
+
+	return false
 }
 }
 
 
 // UserID returns the logged-in user's ID from the request context.
 // UserID returns the logged-in user's ID from the request context.
 func UserID(r *http.Request) int64 {
 func UserID(r *http.Request) int64 {
-	return getContextInt64Value(r, UserIDContextKey)
+	if userID := getContextInt64Value(r, UserIDContextKey); userID != 0 {
+		return userID
+	}
+
+	if session := WebSession(r); session != nil {
+		if id, ok := session.UserID(); ok {
+			return id
+		}
+	}
+
+	return 0
 }
 }
 
 
 // UserName returns the logged-in user's username, or "unknown" when unset.
 // UserName returns the logged-in user's username, or "unknown" when unset.
@@ -84,69 +90,6 @@ func UserTimezone(r *http.Request) string {
 	return value
 	return value
 }
 }
 
 
-// UserLanguage returns the user's locale, defaulting to "en_US" when unset.
-func UserLanguage(r *http.Request) string {
-	language := getContextStringValue(r, UserLanguageContextKey)
-	if language == "" {
-		language = "en_US"
-	}
-	return language
-}
-
-// UserTheme returns the user's theme, defaulting to "system_serif" when unset.
-func UserTheme(r *http.Request) string {
-	theme := getContextStringValue(r, UserThemeContextKey)
-	if theme == "" {
-		theme = "system_serif"
-	}
-	return theme
-}
-
-// CSRF returns the CSRF token from the request context.
-func CSRF(r *http.Request) string {
-	return getContextStringValue(r, CSRFContextKey)
-}
-
-// SessionID returns the current session ID from the request context.
-func SessionID(r *http.Request) string {
-	return getContextStringValue(r, SessionIDContextKey)
-}
-
-// UserSessionToken returns the current user session token from the request context.
-func UserSessionToken(r *http.Request) string {
-	return getContextStringValue(r, UserSessionTokenContextKey)
-}
-
-// OAuth2State returns the OAuth2 state value from the request context.
-func OAuth2State(r *http.Request) string {
-	return getContextStringValue(r, OAuth2StateContextKey)
-}
-
-// OAuth2CodeVerifier returns the OAuth2 PKCE code verifier from the request context.
-func OAuth2CodeVerifier(r *http.Request) string {
-	return getContextStringValue(r, OAuth2CodeVerifierContextKey)
-}
-
-// FlashMessage returns the flash message from the request context, if any.
-func FlashMessage(r *http.Request) string {
-	return getContextStringValue(r, FlashMessageContextKey)
-}
-
-// FlashErrorMessage returns the flash error message from the request context, if any.
-func FlashErrorMessage(r *http.Request) string {
-	return getContextStringValue(r, FlashErrorMessageContextKey)
-}
-
-// LastForceRefresh returns the last force refresh timestamp from the request context.
-func LastForceRefresh(r *http.Request) time.Time {
-	jsonStringValue := getContextStringValue(r, LastForceRefreshContextKey)
-	timestamp, err := strconv.ParseInt(jsonStringValue, 10, 64)
-	if err != nil {
-		return time.Time{}
-	}
-	return time.Unix(timestamp, 0)
-}
-
 // ClientIP returns the client IP address stored in the request context.
 // ClientIP returns the client IP address stored in the request context.
 func ClientIP(r *http.Request) string {
 func ClientIP(r *http.Request) string {
 	return getContextStringValue(r, ClientIPContextKey)
 	return getContextStringValue(r, ClientIPContextKey)

+ 34 - 250
internal/http/request/context_test.go

@@ -7,11 +7,16 @@ import (
 	"context"
 	"context"
 	"net/http"
 	"net/http"
 	"testing"
 	"testing"
-	"time"
 
 
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 )
 )
 
 
+func newRequestWithWebSession(session *model.WebSession) *http.Request {
+	r, _ := http.NewRequest("GET", "http://example.org", nil)
+	ctx := context.WithValue(r.Context(), WebSessionContextKey, session)
+	return r.WithContext(ctx)
+}
+
 func TestContextStringValue(t *testing.T) {
 func TestContextStringValue(t *testing.T) {
 	r, _ := http.NewRequest("GET", "http://example.org", nil)
 	r, _ := http.NewRequest("GET", "http://example.org", nil)
 	ctx := r.Context()
 	ctx := r.Context()
@@ -171,6 +176,15 @@ func TestIsAuthenticated(t *testing.T) {
 	if result != expected {
 	if result != expected {
 		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
 		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
 	}
 	}
+
+	session := &model.WebSession{}
+	session.SetUser(&model.User{ID: 42})
+	r = newRequestWithWebSession(session)
+
+	result = IsAuthenticated(r)
+	if !result {
+		t.Errorf("Unexpected context value, got %v instead of true", result)
+	}
 }
 }
 
 
 func TestUserID(t *testing.T) {
 func TestUserID(t *testing.T) {
@@ -193,6 +207,17 @@ func TestUserID(t *testing.T) {
 	if result != expected {
 	if result != expected {
 		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
 		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
 	}
 	}
+
+	session := &model.WebSession{}
+	session.SetUser(&model.User{ID: 456})
+	r = newRequestWithWebSession(session)
+
+	result = UserID(r)
+	expected = int64(456)
+
+	if result != expected {
+		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
+	}
 }
 }
 
 
 func TestUserName(t *testing.T) {
 func TestUserName(t *testing.T) {
@@ -239,262 +264,21 @@ func TestUserTimezone(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestUserLanguage(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := UserLanguage(r)
-	expected := "en_US"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, UserLanguageContextKey, "fr_FR")
-	r = r.WithContext(ctx)
-
-	result = UserLanguage(r)
-	expected = "fr_FR"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestUserTheme(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := UserTheme(r)
-	expected := "system_serif"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, UserThemeContextKey, "dark_serif")
-	r = r.WithContext(ctx)
-
-	result = UserTheme(r)
-	expected = "dark_serif"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestCSRF(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := CSRF(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, CSRFContextKey, "secret")
-	r = r.WithContext(ctx)
-
-	result = CSRF(r)
-	expected = "secret"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestSessionID(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := SessionID(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, SessionIDContextKey, "id")
-	r = r.WithContext(ctx)
-
-	result = SessionID(r)
-	expected = "id"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestUserSessionToken(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := UserSessionToken(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, UserSessionTokenContextKey, "token")
-	r = r.WithContext(ctx)
-
-	result = UserSessionToken(r)
-	expected = "token"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2State(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := OAuth2State(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, OAuth2StateContextKey, "state")
-	r = r.WithContext(ctx)
-
-	result = OAuth2State(r)
-	expected = "state"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestOAuth2CodeVerifier(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := OAuth2CodeVerifier(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, OAuth2CodeVerifierContextKey, "verifier")
-	r = r.WithContext(ctx)
-
-	result = OAuth2CodeVerifier(r)
-	expected = "verifier"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestFlashMessage(t *testing.T) {
+func TestWebSession(t *testing.T) {
 	r, _ := http.NewRequest("GET", "http://example.org", nil)
 	r, _ := http.NewRequest("GET", "http://example.org", nil)
 
 
-	result := FlashMessage(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
+	if result := WebSession(r); result != nil {
+		t.Fatalf("Unexpected context value, got %v instead of nil", result)
 	}
 	}
 
 
+	session := &model.WebSession{ID: "session-id"}
 	ctx := r.Context()
 	ctx := r.Context()
-	ctx = context.WithValue(ctx, FlashMessageContextKey, "message")
-	r = r.WithContext(ctx)
-
-	result = FlashMessage(r)
-	expected = "message"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestFlashErrorMessage(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := FlashErrorMessage(r)
-	expected := ""
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, FlashErrorMessageContextKey, "error message")
-	r = r.WithContext(ctx)
-
-	result = FlashErrorMessage(r)
-	expected = "error message"
-
-	if result != expected {
-		t.Errorf(`Unexpected context value, got %q instead of %q`, result, expected)
-	}
-}
-
-func TestLastForceRefresh(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := LastForceRefresh(r)
-	expected := time.Time{}
-
-	if !result.Equal(expected) {
-		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, LastForceRefreshContextKey, "not-a-timestamp")
-	r = r.WithContext(ctx)
-
-	result = LastForceRefresh(r)
-	expected = time.Time{}
-
-	if !result.Equal(expected) {
-		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
-	}
-
-	ctx = r.Context()
-	ctx = context.WithValue(ctx, LastForceRefreshContextKey, "1700000000")
-	r = r.WithContext(ctx)
-
-	result = LastForceRefresh(r)
-	expected = time.Unix(1700000000, 0)
-
-	if !result.Equal(expected) {
-		t.Errorf(`Unexpected context value, got %v instead of %v`, result, expected)
-	}
-}
-
-func TestWebAuthnSessionData(t *testing.T) {
-	r, _ := http.NewRequest("GET", "http://example.org", nil)
-
-	result := WebAuthnSessionData(r)
-	if result != nil {
-		t.Errorf("Unexpected context value, got %v instead of nil", result)
-	}
-
-	ctx := r.Context()
-	ctx = context.WithValue(ctx, WebAuthnDataContextKey, "invalid")
-	r = r.WithContext(ctx)
-
-	result = WebAuthnSessionData(r)
-	if result != nil {
-		t.Errorf("Unexpected context value, got %v instead of nil", result)
-	}
-
-	session := model.WebAuthnSession{}
-	ctx = r.Context()
-	ctx = context.WithValue(ctx, WebAuthnDataContextKey, session)
+	ctx = context.WithValue(ctx, WebSessionContextKey, session)
 	r = r.WithContext(ctx)
 	r = r.WithContext(ctx)
 
 
-	result = WebAuthnSessionData(r)
-	if result == nil {
-		t.Errorf("Unexpected context value, got nil instead of session")
+	result := WebSession(r)
+	if result == nil || result.ID != "session-id" {
+		t.Fatalf("Unexpected context value, got %#v instead of session-id", result)
 	}
 	}
 }
 }
 
 

+ 0 - 69
internal/model/app_session.go

@@ -1,69 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package model // import "miniflux.app/v2/internal/model"
-
-import (
-	"database/sql/driver"
-	"encoding/json"
-	"errors"
-	"fmt"
-)
-
-// SessionData represents the data attached to the session.
-type SessionData struct {
-	CSRF                string          `json:"csrf"`
-	OAuth2State         string          `json:"oauth2_state"`
-	OAuth2CodeVerifier  string          `json:"oauth2_code_verifier"`
-	FlashMessage        string          `json:"flash_message"`
-	FlashErrorMessage   string          `json:"flash_error_message"`
-	Language            string          `json:"language"`
-	Theme               string          `json:"theme"`
-	LastForceRefresh    string          `json:"last_force_refresh"`
-	WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
-}
-
-func (s *SessionData) String() string {
-	return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
-		s.CSRF,
-		s.OAuth2State,
-		s.OAuth2CodeVerifier,
-		s.FlashMessage,
-		s.FlashErrorMessage,
-		s.Language,
-		s.Theme,
-		s.LastForceRefresh,
-		s.WebAuthnSessionData,
-	)
-}
-
-// Value converts the session data to JSON.
-func (s *SessionData) Value() (driver.Value, error) {
-	j, err := json.Marshal(s)
-	return j, err
-}
-
-// Scan converts raw JSON data.
-func (s *SessionData) Scan(src any) error {
-	source, ok := src.([]byte)
-	if !ok {
-		return errors.New("session: unable to assert type of src")
-	}
-
-	err := json.Unmarshal(source, s)
-	if err != nil {
-		return fmt.Errorf("session: %v", err)
-	}
-
-	return err
-}
-
-// Session represents a session in the system.
-type Session struct {
-	ID   string
-	Data *SessionData
-}
-
-func (s *Session) String() string {
-	return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
-}

+ 0 - 30
internal/model/user_session.go

@@ -1,30 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package model // import "miniflux.app/v2/internal/model"
-
-import (
-	"fmt"
-	"time"
-
-	"miniflux.app/v2/internal/timezone"
-)
-
-// UserSession represents a user session in the system.
-type UserSession struct {
-	ID        int64
-	UserID    int64
-	Token     string
-	CreatedAt time.Time
-	UserAgent string
-	IP        string
-}
-
-func (u *UserSession) String() string {
-	return fmt.Sprintf(`ID=%d, UserID=%d, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
-}
-
-// UseTimezone converts creation date to the given timezone.
-func (u *UserSession) UseTimezone(tz string) {
-	u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
-}

+ 287 - 0
internal/model/web_session.go

@@ -0,0 +1,287 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package model // import "miniflux.app/v2/internal/model"
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/subtle"
+	"database/sql"
+	"encoding/json"
+	"time"
+
+	"github.com/go-webauthn/webauthn/webauthn"
+
+	"miniflux.app/v2/internal/timezone"
+)
+
+const (
+	defaultSessionLanguage = "en_US"
+	defaultSessionTheme    = "system_serif"
+)
+
+// WebSession represents a browser session persisted in the web_sessions table.
+type WebSession struct {
+	ID         string
+	SecretHash []byte
+	CreatedAt  time.Time
+	UserAgent  string
+	IP         string
+	userID     *int64
+	state      webSessionState
+	dirty      bool
+}
+
+// webSessionState stores transient browser session state as a JSON blob.
+type webSessionState struct {
+	CSRF               string                `json:"csrf,omitempty"`
+	SuccessMessage     string                `json:"success_message,omitempty"`
+	ErrorMessage       string                `json:"error_message,omitempty"`
+	OAuth2             *WebSessionOAuth2     `json:"oauth2,omitempty"`
+	WebAuthn           *webauthn.SessionData `json:"webauthn,omitempty"`
+	LastForceRefreshAt *time.Time            `json:"last_force_refresh_at,omitempty"`
+	Language           string                `json:"language,omitempty"`
+	Theme              string                `json:"theme,omitempty"`
+}
+
+// WebSessionOAuth2 stores transient OAuth2 flow state.
+type WebSessionOAuth2 struct {
+	State        string `json:"state,omitempty"`
+	CodeVerifier string `json:"code_verifier,omitempty"`
+}
+
+// NewWebSession builds an unauthenticated browser session with a fresh
+// identity and returns it along with the raw session secret.
+func NewWebSession(userAgent, ip string) (*WebSession, string) {
+	secret := rand.Text()
+	session := &WebSession{
+		ID:         rand.Text(),
+		SecretHash: hashWebSessionSecret(secret),
+		UserAgent:  userAgent,
+		IP:         ip,
+	}
+	session.state.CSRF = rand.Text()
+	return session, secret
+}
+
+// Rotate assigns a new ID and secret in place, returning the previous ID
+// and the new raw secret. Rotating on authentication prevents session fixation.
+func (s *WebSession) Rotate() (oldID, newSecret string) {
+	oldID = s.ID
+	newSecret = rand.Text()
+	s.ID = rand.Text()
+	s.SecretHash = hashWebSessionSecret(newSecret)
+	return oldID, newSecret
+}
+
+// VerifySecret reports whether the given raw secret matches the stored hash.
+func (s *WebSession) VerifySecret(secret string) bool {
+	if secret == "" || len(s.SecretHash) == 0 {
+		return false
+	}
+	actual := hashWebSessionSecret(secret)
+	return subtle.ConstantTimeCompare(actual, s.SecretHash) == 1
+}
+
+func hashWebSessionSecret(secret string) []byte {
+	sum := sha256.Sum256([]byte(secret))
+	return sum[:]
+}
+
+// IsDirty reports whether the session has been modified since it was loaded.
+func (s *WebSession) IsDirty() bool {
+	return s.dirty
+}
+
+// IsAuthenticated reports whether the session is bound to a user.
+func (s *WebSession) IsAuthenticated() bool {
+	return s.userID != nil
+}
+
+// UserID returns the authenticated user ID and whether the session is bound to a user.
+func (s *WebSession) UserID() (int64, bool) {
+	if s.userID == nil {
+		return 0, false
+	}
+	return *s.userID, true
+}
+
+// NullUserID returns the session user ID as a sql.NullInt64 for storage writes.
+func (s *WebSession) NullUserID() sql.NullInt64 {
+	if s.userID == nil {
+		return sql.NullInt64{}
+	}
+	return sql.NullInt64{Int64: *s.userID, Valid: true}
+}
+
+// ScanUserID sets the session user ID from a sql.NullInt64 loaded from storage.
+func (s *WebSession) ScanUserID(v sql.NullInt64) {
+	if !v.Valid {
+		s.userID = nil
+		return
+	}
+	id := v.Int64
+	s.userID = &id
+}
+
+// UseTimezone converts creation date to the given timezone.
+func (s *WebSession) UseTimezone(tz string) {
+	s.CreatedAt = timezone.Convert(tz, s.CreatedAt)
+}
+
+// CSRF returns the CSRF token for this session.
+func (s *WebSession) CSRF() string {
+	return s.state.CSRF
+}
+
+// Language returns the session language, or a default when unset.
+func (s *WebSession) Language() string {
+	if s.state.Language != "" {
+		return s.state.Language
+	}
+	return defaultSessionLanguage
+}
+
+// Theme returns the session theme, or a default when unset.
+func (s *WebSession) Theme() string {
+	if s.state.Theme != "" {
+		return s.state.Theme
+	}
+	return defaultSessionTheme
+}
+
+// OAuth2State returns the OAuth2 state parameter, or empty if not in an OAuth2 flow.
+func (s *WebSession) OAuth2State() string {
+	if s.state.OAuth2 != nil {
+		return s.state.OAuth2.State
+	}
+	return ""
+}
+
+// OAuth2CodeVerifier returns the PKCE code verifier, or empty if not in an OAuth2 flow.
+func (s *WebSession) OAuth2CodeVerifier() string {
+	if s.state.OAuth2 != nil {
+		return s.state.OAuth2.CodeVerifier
+	}
+	return ""
+}
+
+// ConsumeWebAuthnSession returns and clears the pending WebAuthn session data.
+func (s *WebSession) ConsumeWebAuthnSession() *webauthn.SessionData {
+	data := s.state.WebAuthn
+	if data == nil {
+		return nil
+	}
+
+	s.dirty = true
+	s.state.WebAuthn = nil
+	return data
+}
+
+// LastForceRefresh returns the last force refresh timestamp, or zero time if unset.
+func (s *WebSession) LastForceRefresh() time.Time {
+	if s.state.LastForceRefreshAt != nil {
+		return *s.state.LastForceRefreshAt
+	}
+	return time.Time{}
+}
+
+// ConsumeMessages returns and clears the success and error messages.
+func (s *WebSession) ConsumeMessages() (string, string) {
+	successMessage := s.state.SuccessMessage
+	errorMessage := s.state.ErrorMessage
+
+	if successMessage != "" || errorMessage != "" {
+		s.dirty = true
+		s.state.SuccessMessage = ""
+		s.state.ErrorMessage = ""
+	}
+
+	return successMessage, errorMessage
+}
+
+// SetLanguage updates the language.
+func (s *WebSession) SetLanguage(language string) {
+	s.dirty = true
+	s.state.Language = language
+}
+
+// SetTheme updates the theme.
+func (s *WebSession) SetTheme(theme string) {
+	s.dirty = true
+	s.state.Theme = theme
+}
+
+// SetSuccessMessage stores a success message shown on the next page load.
+func (s *WebSession) SetSuccessMessage(message string) {
+	s.dirty = true
+	s.state.SuccessMessage = message
+}
+
+// SetErrorMessage stores an error message shown on the next page load.
+func (s *WebSession) SetErrorMessage(message string) {
+	s.dirty = true
+	s.state.ErrorMessage = message
+}
+
+// StartOAuth2Flow stores the OAuth2 state parameter and PKCE code verifier.
+func (s *WebSession) StartOAuth2Flow(state, codeVerifier string) {
+	s.dirty = true
+	s.state.OAuth2 = &WebSessionOAuth2{
+		State:        state,
+		CodeVerifier: codeVerifier,
+	}
+}
+
+// ClearOAuth2Flow discards any pending OAuth2 flow state.
+func (s *WebSession) ClearOAuth2Flow() {
+	s.dirty = true
+	s.state.OAuth2 = nil
+}
+
+// SetUser binds the session to an authenticated user and copies their preferences.
+func (s *WebSession) SetUser(user *User) {
+	if user == nil {
+		return
+	}
+
+	s.dirty = true
+	userID := user.ID
+	s.userID = &userID
+	s.state.Language = user.Language
+	s.state.Theme = user.Theme
+}
+
+// ClearUser removes the user binding from the session.
+func (s *WebSession) ClearUser() {
+	s.dirty = true
+	s.userID = nil
+}
+
+// MarkForceRefreshed records the current time as the last force refresh.
+func (s *WebSession) MarkForceRefreshed() {
+	s.dirty = true
+	now := time.Now().UTC()
+	s.state.LastForceRefreshAt = &now
+}
+
+// SetWebAuthn stores or clears WebAuthn session data.
+func (s *WebSession) SetWebAuthn(data *webauthn.SessionData) {
+	s.dirty = true
+	s.state.WebAuthn = data
+}
+
+// MarshalState serializes the session state to JSON for storage.
+func (s *WebSession) MarshalState() ([]byte, error) {
+	return json.Marshal(s.state)
+}
+
+// UnmarshalState populates the session state from raw JSON bytes.
+func (s *WebSession) UnmarshalState(data []byte) error {
+	s.state = webSessionState{}
+	if len(data) == 0 {
+		return nil
+	}
+	return json.Unmarshal(data, &s.state)
+}

+ 429 - 0
internal/model/web_session_test.go

@@ -0,0 +1,429 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package model
+
+import (
+	"bytes"
+	"database/sql"
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/go-webauthn/webauthn/webauthn"
+)
+
+func TestNewWebSession(t *testing.T) {
+	const userAgent = "test-agent"
+	const ip = "127.0.0.1"
+
+	session, secret := NewWebSession(userAgent, ip)
+
+	if session == nil {
+		t.Fatal("NewWebSession returned a nil session")
+	}
+	if secret == "" {
+		t.Error("NewWebSession returned an empty secret")
+	}
+	if session.ID == "" {
+		t.Error("NewWebSession produced an empty ID")
+	}
+	if session.ID == secret {
+		t.Error("session ID and secret must not be equal")
+	}
+	if len(session.SecretHash) == 0 {
+		t.Error("NewWebSession produced an empty SecretHash")
+	}
+	if session.CSRF() == "" {
+		t.Error("NewWebSession produced an empty CSRF token")
+	}
+	if session.UserAgent != userAgent {
+		t.Errorf("UserAgent = %q, want %q", session.UserAgent, userAgent)
+	}
+	if session.IP != ip {
+		t.Errorf("IP = %q, want %q", session.IP, ip)
+	}
+	if session.IsAuthenticated() {
+		t.Error("a fresh session must not be authenticated")
+	}
+	if session.IsDirty() {
+		t.Error("a fresh session must not be dirty")
+	}
+	if !session.VerifySecret(secret) {
+		t.Error("VerifySecret rejected the secret returned by NewWebSession")
+	}
+}
+
+func TestNewWebSession_ProducesUniqueIdentities(t *testing.T) {
+	s1, secret1 := NewWebSession("", "")
+	s2, secret2 := NewWebSession("", "")
+
+	if s1.ID == s2.ID {
+		t.Error("successive NewWebSession calls produced the same ID")
+	}
+	if secret1 == secret2 {
+		t.Error("successive NewWebSession calls produced the same secret")
+	}
+	if bytes.Equal(s1.SecretHash, s2.SecretHash) {
+		t.Error("successive NewWebSession calls produced the same SecretHash")
+	}
+	if s1.CSRF() == s2.CSRF() {
+		t.Error("successive NewWebSession calls produced the same CSRF token")
+	}
+}
+
+func TestWebSession_Rotate(t *testing.T) {
+	session, originalSecret := NewWebSession("agent", "ip")
+	originalID := session.ID
+	originalHash := bytes.Clone(session.SecretHash)
+	originalCSRF := session.CSRF()
+
+	// Bind a user so we can verify Rotate preserves the user binding.
+	session.SetUser(&User{ID: 42})
+
+	oldID, newSecret := session.Rotate()
+
+	if oldID != originalID {
+		t.Errorf("Rotate returned oldID = %q, want %q", oldID, originalID)
+	}
+	if newSecret == "" {
+		t.Error("Rotate returned an empty new secret")
+	}
+	if newSecret == originalSecret {
+		t.Error("Rotate returned the same secret as before")
+	}
+	if session.ID == originalID {
+		t.Error("Rotate did not change the session ID")
+	}
+	if bytes.Equal(session.SecretHash, originalHash) {
+		t.Error("Rotate did not change the SecretHash")
+	}
+	if session.VerifySecret(originalSecret) {
+		t.Error("VerifySecret must reject the pre-rotation secret")
+	}
+	if !session.VerifySecret(newSecret) {
+		t.Error("VerifySecret must accept the post-rotation secret")
+	}
+	if session.CSRF() != originalCSRF {
+		t.Error("Rotate must preserve the CSRF token so in-flight forms remain valid")
+	}
+	if !session.IsAuthenticated() {
+		t.Error("Rotate must preserve the user binding")
+	}
+	if id, _ := session.UserID(); id != 42 {
+		t.Errorf("Rotate corrupted user ID: got %d, want 42", id)
+	}
+}
+
+func TestWebSession_VerifySecret(t *testing.T) {
+	good, goodSecret := NewWebSession("", "")
+
+	testCases := []struct {
+		name   string
+		hash   []byte
+		secret string
+		want   bool
+	}{
+		{"correct secret", good.SecretHash, goodSecret, true},
+		{"wrong secret", good.SecretHash, "not-the-right-secret", false},
+		{"empty secret", good.SecretHash, "", false},
+		{"nil hash", nil, goodSecret, false},
+		{"empty hash and secret", nil, "", false},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			s := &WebSession{SecretHash: tc.hash}
+			if got := s.VerifySecret(tc.secret); got != tc.want {
+				t.Errorf("VerifySecret(%q) = %v, want %v", tc.secret, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWebSession_UserBindingLifecycle(t *testing.T) {
+	session, _ := NewWebSession("", "")
+
+	if session.IsAuthenticated() {
+		t.Error("a fresh session must not be authenticated")
+	}
+	if id, ok := session.UserID(); ok || id != 0 {
+		t.Errorf("UserID() = (%d, %v), want (0, false)", id, ok)
+	}
+
+	user := &User{ID: 99, Language: "fr_FR", Theme: "dark_serif"}
+	session.SetUser(user)
+
+	if !session.IsAuthenticated() {
+		t.Error("session must be authenticated after SetUser")
+	}
+	if id, ok := session.UserID(); !ok || id != 99 {
+		t.Errorf("UserID() = (%d, %v), want (99, true)", id, ok)
+	}
+	if session.Language() != "fr_FR" {
+		t.Errorf("SetUser did not copy Language: got %q, want %q", session.Language(), "fr_FR")
+	}
+	if session.Theme() != "dark_serif" {
+		t.Errorf("SetUser did not copy Theme: got %q, want %q", session.Theme(), "dark_serif")
+	}
+	if !session.IsDirty() {
+		t.Error("SetUser must mark the session dirty")
+	}
+
+	session.ClearUser()
+	if session.IsAuthenticated() {
+		t.Error("session must not be authenticated after ClearUser")
+	}
+	if id, ok := session.UserID(); ok || id != 0 {
+		t.Errorf("UserID() after ClearUser = (%d, %v), want (0, false)", id, ok)
+	}
+}
+
+func TestWebSession_SetUser_NilIsNoop(t *testing.T) {
+	session, _ := NewWebSession("", "")
+	session.SetUser(nil)
+
+	if session.IsAuthenticated() {
+		t.Error("SetUser(nil) must not authenticate the session")
+	}
+	if session.IsDirty() {
+		t.Error("SetUser(nil) must not mark the session dirty")
+	}
+}
+
+func TestWebSession_UserIDStorageRoundTrip(t *testing.T) {
+	testCases := []struct {
+		name string
+		in   sql.NullInt64
+	}{
+		{"null", sql.NullInt64{}},
+		{"zero valid", sql.NullInt64{Int64: 0, Valid: true}},
+		{"positive valid", sql.NullInt64{Int64: 42, Valid: true}},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			session := &WebSession{}
+			session.ScanUserID(tc.in)
+
+			if got := session.NullUserID(); got != tc.in {
+				t.Errorf("round-trip = %+v, want %+v", got, tc.in)
+			}
+			if got := session.IsAuthenticated(); got != tc.in.Valid {
+				t.Errorf("IsAuthenticated() = %v, want %v", got, tc.in.Valid)
+			}
+		})
+	}
+}
+
+func TestWebSession_ScanUserID_ClearsPreviousValue(t *testing.T) {
+	session := &WebSession{}
+	session.ScanUserID(sql.NullInt64{Int64: 1, Valid: true})
+	session.ScanUserID(sql.NullInt64{})
+
+	if session.IsAuthenticated() {
+		t.Error("ScanUserID with an invalid value must clear the user binding")
+	}
+}
+
+func TestWebSession_LanguageAndThemeDefaults(t *testing.T) {
+	session := &WebSession{}
+
+	if got := session.Language(); got != defaultSessionLanguage {
+		t.Errorf("default Language() = %q, want %q", got, defaultSessionLanguage)
+	}
+	if got := session.Theme(); got != defaultSessionTheme {
+		t.Errorf("default Theme() = %q, want %q", got, defaultSessionTheme)
+	}
+
+	session.SetLanguage("de_DE")
+	session.SetTheme("light_sans_serif")
+
+	if got := session.Language(); got != "de_DE" {
+		t.Errorf("Language() = %q, want %q", got, "de_DE")
+	}
+	if got := session.Theme(); got != "light_sans_serif" {
+		t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
+	}
+	if !session.IsDirty() {
+		t.Error("SetLanguage/SetTheme must mark the session dirty")
+	}
+}
+
+func TestWebSession_OAuth2FlowLifecycle(t *testing.T) {
+	session := &WebSession{}
+
+	if session.OAuth2State() != "" {
+		t.Error("OAuth2State() must be empty by default")
+	}
+	if session.OAuth2CodeVerifier() != "" {
+		t.Error("OAuth2CodeVerifier() must be empty by default")
+	}
+
+	session.StartOAuth2Flow("state-token", "code-verifier")
+
+	if got := session.OAuth2State(); got != "state-token" {
+		t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
+	}
+	if got := session.OAuth2CodeVerifier(); got != "code-verifier" {
+		t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
+	}
+	if !session.IsDirty() {
+		t.Error("StartOAuth2Flow must mark the session dirty")
+	}
+
+	session.ClearOAuth2Flow()
+
+	if session.OAuth2State() != "" {
+		t.Errorf("OAuth2State() after Clear = %q, want empty", session.OAuth2State())
+	}
+	if session.OAuth2CodeVerifier() != "" {
+		t.Errorf("OAuth2CodeVerifier() after Clear = %q, want empty", session.OAuth2CodeVerifier())
+	}
+}
+
+func TestWebSession_ConsumeMessages(t *testing.T) {
+	t.Run("no messages", func(t *testing.T) {
+		session := &WebSession{}
+
+		success, errMsg := session.ConsumeMessages()
+		if success != "" || errMsg != "" {
+			t.Errorf("ConsumeMessages() = (%q, %q), want empty", success, errMsg)
+		}
+		if session.IsDirty() {
+			t.Error("ConsumeMessages with no messages must not mark the session dirty")
+		}
+	})
+
+	t.Run("returns and clears", func(t *testing.T) {
+		session := &WebSession{}
+		session.SetSuccessMessage("saved")
+		session.SetErrorMessage("nope")
+		session.dirty = false // isolate the dirty contribution of ConsumeMessages
+
+		success, errMsg := session.ConsumeMessages()
+		if success != "saved" || errMsg != "nope" {
+			t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "nope")
+		}
+		if !session.IsDirty() {
+			t.Error("ConsumeMessages with messages must mark the session dirty")
+		}
+
+		success, errMsg = session.ConsumeMessages()
+		if success != "" || errMsg != "" {
+			t.Errorf("second ConsumeMessages() = (%q, %q), want empty", success, errMsg)
+		}
+	})
+}
+
+func TestWebSession_ConsumeWebAuthnSession(t *testing.T) {
+	t.Run("no data", func(t *testing.T) {
+		session := &WebSession{}
+
+		if got := session.ConsumeWebAuthnSession(); got != nil {
+			t.Errorf("ConsumeWebAuthnSession() = %v, want nil", got)
+		}
+		if session.IsDirty() {
+			t.Error("ConsumeWebAuthnSession with no data must not mark the session dirty")
+		}
+	})
+
+	t.Run("returns and clears", func(t *testing.T) {
+		data := &webauthn.SessionData{}
+		session := &WebSession{}
+		session.SetWebAuthn(data)
+		session.dirty = false // isolate the dirty contribution of ConsumeWebAuthnSession
+
+		if got := session.ConsumeWebAuthnSession(); got != data {
+			t.Errorf("ConsumeWebAuthnSession() = %p, want %p", got, data)
+		}
+		if !session.IsDirty() {
+			t.Error("ConsumeWebAuthnSession with data must mark the session dirty")
+		}
+		if got := session.ConsumeWebAuthnSession(); got != nil {
+			t.Errorf("second ConsumeWebAuthnSession() = %v, want nil", got)
+		}
+	})
+}
+
+func TestWebSession_MarkForceRefreshed(t *testing.T) {
+	session := &WebSession{}
+
+	if got := session.LastForceRefresh(); !got.IsZero() {
+		t.Errorf("default LastForceRefresh() = %v, want zero time", got)
+	}
+
+	before := time.Now().UTC()
+	session.MarkForceRefreshed()
+	after := time.Now().UTC()
+
+	got := session.LastForceRefresh()
+	if got.Before(before) || got.After(after) {
+		t.Errorf("LastForceRefresh() = %v, want between %v and %v", got, before, after)
+	}
+	if !session.IsDirty() {
+		t.Error("MarkForceRefreshed must mark the session dirty")
+	}
+}
+
+func TestWebSession_StateRoundTrip(t *testing.T) {
+	original := &WebSession{}
+	original.SetLanguage("de_DE")
+	original.SetTheme("light_sans_serif")
+	original.SetSuccessMessage("saved")
+	original.SetErrorMessage("oops")
+	original.StartOAuth2Flow("state-token", "code-verifier")
+	original.MarkForceRefreshed()
+	originalRefreshAt := original.LastForceRefresh()
+
+	data, err := original.MarshalState()
+	if err != nil {
+		t.Fatalf("MarshalState() error: %v", err)
+	}
+	if !json.Valid(data) {
+		t.Errorf("MarshalState() produced invalid JSON: %s", data)
+	}
+
+	restored := &WebSession{}
+	if err := restored.UnmarshalState(data); err != nil {
+		t.Fatalf("UnmarshalState() error: %v", err)
+	}
+
+	if got := restored.Language(); got != "de_DE" {
+		t.Errorf("Language() = %q, want %q", got, "de_DE")
+	}
+	if got := restored.Theme(); got != "light_sans_serif" {
+		t.Errorf("Theme() = %q, want %q", got, "light_sans_serif")
+	}
+	if got := restored.OAuth2State(); got != "state-token" {
+		t.Errorf("OAuth2State() = %q, want %q", got, "state-token")
+	}
+	if got := restored.OAuth2CodeVerifier(); got != "code-verifier" {
+		t.Errorf("OAuth2CodeVerifier() = %q, want %q", got, "code-verifier")
+	}
+	if got := restored.LastForceRefresh(); !got.Equal(originalRefreshAt) {
+		t.Errorf("LastForceRefresh() = %v, want %v", got, originalRefreshAt)
+	}
+
+	success, errMsg := restored.ConsumeMessages()
+	if success != "saved" || errMsg != "oops" {
+		t.Errorf("ConsumeMessages() = (%q, %q), want (%q, %q)", success, errMsg, "saved", "oops")
+	}
+}
+
+func TestWebSession_UnmarshalState_EmptyDataResetsState(t *testing.T) {
+	session := &WebSession{}
+	session.SetLanguage("fr_FR")
+	session.StartOAuth2Flow("s", "v")
+
+	if err := session.UnmarshalState(nil); err != nil {
+		t.Fatalf("UnmarshalState(nil) error: %v", err)
+	}
+
+	if got := session.Language(); got != defaultSessionLanguage {
+		t.Errorf("UnmarshalState(nil) did not reset Language: got %q", got)
+	}
+	if session.OAuth2State() != "" {
+		t.Error("UnmarshalState(nil) did not reset OAuth2 state")
+	}
+}

+ 0 - 29
internal/model/webauthn.go

@@ -4,41 +4,12 @@
 package model // import "miniflux.app/v2/internal/model"
 package model // import "miniflux.app/v2/internal/model"
 
 
 import (
 import (
-	"database/sql/driver"
 	"encoding/hex"
 	"encoding/hex"
-	"encoding/json"
-	"errors"
-	"fmt"
 	"time"
 	"time"
 
 
 	"github.com/go-webauthn/webauthn/webauthn"
 	"github.com/go-webauthn/webauthn/webauthn"
 )
 )
 
 
-// WebAuthnSession handles marshalling / unmarshalling session data
-type WebAuthnSession struct {
-	*webauthn.SessionData
-}
-
-func (s WebAuthnSession) Value() (driver.Value, error) {
-	return json.Marshal(s)
-}
-
-func (s *WebAuthnSession) Scan(value any) error {
-	b, ok := value.([]byte)
-	if !ok {
-		return errors.New("type assertion to []byte failed")
-	}
-
-	return json.Unmarshal(b, &s)
-}
-
-func (s WebAuthnSession) String() string {
-	if s.SessionData == nil {
-		return "{}"
-	}
-	return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.Challenge, s.UserID)
-}
-
 type WebAuthnCredential struct {
 type WebAuthnCredential struct {
 	Credential webauthn.Credential
 	Credential webauthn.Credential
 	Name       string
 	Name       string

+ 0 - 145
internal/storage/session.go

@@ -1,145 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package storage // import "miniflux.app/v2/internal/storage"
-
-import (
-	"crypto/rand"
-	"database/sql"
-	"fmt"
-	"time"
-
-	"miniflux.app/v2/internal/model"
-)
-
-// CreateAppSessionWithUserPrefs creates a new application session with the given user preferences.
-func (s *Storage) CreateAppSessionWithUserPrefs(userID int64) (*model.Session, error) {
-	user, err := s.UserByID(userID)
-	if err != nil {
-		return nil, err
-	}
-
-	session := model.Session{
-		ID: rand.Text(),
-		Data: &model.SessionData{
-			CSRF:     rand.Text(),
-			Theme:    user.Theme,
-			Language: user.Language,
-		},
-	}
-
-	return s.createAppSession(&session)
-}
-
-// CreateAppSession creates a new application session.
-func (s *Storage) CreateAppSession() (*model.Session, error) {
-	session := model.Session{
-		ID: rand.Text(),
-		Data: &model.SessionData{
-			CSRF: rand.Text(),
-		},
-	}
-
-	return s.createAppSession(&session)
-}
-
-func (s *Storage) createAppSession(session *model.Session) (*model.Session, error) {
-	query := `INSERT INTO sessions (id, data) VALUES ($1, $2)`
-	_, err := s.db.Exec(query, session.ID, session.Data)
-	if err != nil {
-		return nil, fmt.Errorf(`store: unable to create app session: %v`, err)
-	}
-
-	return session, nil
-}
-
-// SetAppSessionTextField sets a text field in the session data.
-func (s *Storage) SetAppSessionTextField(sessionID, field string, value any) error {
-	query := `
-		UPDATE
-			sessions
-		SET
-			data = jsonb_set(data, ARRAY[$2::text], to_jsonb($1::text), true)
-		WHERE
-			id=$3
-	`
-	_, err := s.db.Exec(query, value, field, sessionID)
-	if err != nil {
-		return fmt.Errorf(`store: unable to update session text field %q: %v`, field, err)
-	}
-
-	return nil
-}
-
-// SetAppSessionJSONField sets a JSON field in the session data.
-func (s *Storage) SetAppSessionJSONField(sessionID, field string, value any) error {
-	query := `
-		UPDATE
-			sessions
-		SET
-			data = jsonb_set(data, ARRAY[$2::text], $1, true)
-		WHERE
-			id=$3
-	`
-	_, err := s.db.Exec(query, value, field, sessionID)
-	if err != nil {
-		return fmt.Errorf(`store: unable to update session JSON field %q: %v`, field, err)
-	}
-
-	return nil
-}
-
-// AppSession returns the given session.
-func (s *Storage) AppSession(id string) (*model.Session, error) {
-	var session model.Session
-
-	query := "SELECT id, data FROM sessions WHERE id=$1"
-	err := s.db.QueryRow(query, id).Scan(
-		&session.ID,
-		&session.Data,
-	)
-
-	switch {
-	case err == sql.ErrNoRows:
-		return nil, fmt.Errorf(`store: session not found: %s`, id)
-	case err != nil:
-		return nil, fmt.Errorf(`store: unable to fetch session: %v`, err)
-	default:
-		return &session, nil
-	}
-}
-
-// FlushAllSessions removes all sessions from the database.
-func (s *Storage) FlushAllSessions() (err error) {
-	_, err = s.db.Exec(`DELETE FROM user_sessions`)
-	if err != nil {
-		return err
-	}
-
-	_, err = s.db.Exec(`DELETE FROM sessions`)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// CleanOldSessions removes sessions older than specified interval (24h minimum).
-func (s *Storage) CleanOldSessions(interval time.Duration) int64 {
-	query := `
-		DELETE FROM
-			sessions
-		WHERE
-			created_at < now() - $1::interval
-	`
-
-	days := max(int(interval/(24*time.Hour)), 1)
-
-	result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
-	if err != nil {
-		return 0
-	}
-
-	n, _ := result.RowsAffected()
-	return n
-}

+ 0 - 189
internal/storage/user_session.go

@@ -1,189 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package storage // import "miniflux.app/v2/internal/storage"
-
-import (
-	"crypto/rand"
-	"database/sql"
-	"errors"
-	"fmt"
-	"time"
-
-	"miniflux.app/v2/internal/model"
-)
-
-// UserSessions returns the list of sessions for the given user.
-func (s *Storage) UserSessions(userID int64) ([]model.UserSession, error) {
-	query := `
-		SELECT
-			id,
-			user_id,
-			token,
-			created_at,
-			user_agent,
-			ip
-		FROM
-			user_sessions
-		WHERE
-			user_id=$1 ORDER BY id DESC
-	`
-	rows, err := s.db.Query(query, userID)
-	if err != nil {
-		return nil, fmt.Errorf(`store: unable to fetch user sessions: %v`, err)
-	}
-	defer rows.Close()
-
-	var sessions []model.UserSession
-	for rows.Next() {
-		var session model.UserSession
-		err := rows.Scan(
-			&session.ID,
-			&session.UserID,
-			&session.Token,
-			&session.CreatedAt,
-			&session.UserAgent,
-			&session.IP,
-		)
-		if err != nil {
-			return nil, fmt.Errorf(`store: unable to fetch user session row: %v`, err)
-		}
-
-		sessions = append(sessions, session)
-	}
-
-	return sessions, nil
-}
-
-// CreateUserSessionFromUsername creates a new user session.
-func (s *Storage) CreateUserSessionFromUsername(username, userAgent, ip string) (sessionID string, userID int64, err error) {
-	token := rand.Text()
-	if ip == "" {
-		ip = "127.0.0.1"
-	}
-
-	tx, err := s.db.Begin()
-	if err != nil {
-		return "", 0, fmt.Errorf(`store: unable to start transaction: %v`, err)
-	}
-
-	err = tx.QueryRow(`SELECT id FROM users WHERE username = LOWER($1)`, username).Scan(&userID)
-	if err != nil {
-		tx.Rollback()
-		return "", 0, fmt.Errorf(`store: unable to fetch user ID: %v`, err)
-	}
-
-	_, err = tx.Exec(
-		`INSERT INTO user_sessions (token, user_id, user_agent, ip) VALUES ($1, $2, $3, $4)`,
-		token,
-		userID,
-		userAgent,
-		ip,
-	)
-	if err != nil {
-		tx.Rollback()
-		return "", 0, fmt.Errorf(`store: unable to create user session: %v`, err)
-	}
-
-	if err := tx.Commit(); err != nil {
-		return "", 0, fmt.Errorf(`store: unable to commit transaction: %v`, err)
-	}
-
-	return token, userID, nil
-}
-
-// UserSessionByToken finds a session by the token.
-func (s *Storage) UserSessionByToken(token string) (*model.UserSession, error) {
-	var session model.UserSession
-
-	query := `
-		SELECT
-			id,
-			user_id,
-			token,
-			created_at,
-			user_agent,
-			ip
-		FROM
-			user_sessions
-		WHERE
-			token = $1
-	`
-	err := s.db.QueryRow(query, token).Scan(
-		&session.ID,
-		&session.UserID,
-		&session.Token,
-		&session.CreatedAt,
-		&session.UserAgent,
-		&session.IP,
-	)
-
-	switch {
-	case err == sql.ErrNoRows:
-		return nil, nil
-	case err != nil:
-		return nil, fmt.Errorf(`store: unable to fetch user session: %v`, err)
-	default:
-		return &session, nil
-	}
-}
-
-// RemoveUserSessionByToken remove a session by using the token.
-func (s *Storage) RemoveUserSessionByToken(userID int64, token string) error {
-	query := `DELETE FROM user_sessions WHERE user_id=$1 AND token=$2`
-	result, err := s.db.Exec(query, userID, token)
-	if err != nil {
-		return fmt.Errorf(`store: unable to remove this user session: %v`, err)
-	}
-
-	count, err := result.RowsAffected()
-	if err != nil {
-		return fmt.Errorf(`store: unable to remove this user session: %v`, err)
-	}
-
-	if count != 1 {
-		return errors.New(`store: nothing has been removed`)
-	}
-
-	return nil
-}
-
-// RemoveUserSessionByID remove a session by using the ID.
-func (s *Storage) RemoveUserSessionByID(userID, sessionID int64) error {
-	query := `DELETE FROM user_sessions WHERE user_id=$1 AND id=$2`
-	result, err := s.db.Exec(query, userID, sessionID)
-	if err != nil {
-		return fmt.Errorf(`store: unable to remove this user session: %v`, err)
-	}
-
-	count, err := result.RowsAffected()
-	if err != nil {
-		return fmt.Errorf(`store: unable to remove this user session: %v`, err)
-	}
-
-	if count != 1 {
-		return errors.New(`store: nothing has been removed`)
-	}
-
-	return nil
-}
-
-// CleanOldUserSessions removes user sessions older than specified interval (24h minimum).
-func (s *Storage) CleanOldUserSessions(interval time.Duration) int64 {
-	query := `
-		DELETE FROM
-			user_sessions
-		WHERE
-			created_at < now() - $1::interval
-	`
-
-	days := max(int(interval/(24*time.Hour)), 1)
-
-	result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
-	if err != nil {
-		return 0
-	}
-
-	n, _ := result.RowsAffected()
-	return n
-}

+ 291 - 0
internal/storage/web_session.go

@@ -0,0 +1,291 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package storage // import "miniflux.app/v2/internal/storage"
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+
+	"miniflux.app/v2/internal/model"
+)
+
+// CreateWebSession persists a new web session built via model.NewWebSession.
+func (s *Storage) CreateWebSession(session *model.WebSession) error {
+	if session == nil {
+		return errors.New(`store: web session is nil`)
+	}
+
+	stateJSON, err := session.MarshalState()
+	if err != nil {
+		return fmt.Errorf(`store: unable to serialize web session state: %v`, err)
+	}
+
+	query := `
+		INSERT INTO web_sessions (
+			id,
+			secret_hash,
+			user_agent,
+			ip,
+			state
+		)
+		VALUES ($1, $2, $3, $4, $5)
+		RETURNING created_at
+	`
+
+	err = s.db.QueryRow(
+		query,
+		session.ID,
+		session.SecretHash,
+		session.UserAgent,
+		sql.NullString{String: session.IP, Valid: session.IP != ""},
+		stateJSON,
+	).Scan(&session.CreatedAt)
+	if err != nil {
+		return fmt.Errorf(`store: unable to create web session: %v`, err)
+	}
+
+	return nil
+}
+
+// WebSessionsByUserID returns web sessions for the given user.
+func (s *Storage) WebSessionsByUserID(userID int64) ([]model.WebSession, error) {
+	query := `
+		SELECT
+			id,
+			secret_hash,
+			user_id,
+			created_at,
+			user_agent,
+			ip,
+			state
+		FROM
+			web_sessions
+		WHERE
+			user_id=$1
+		ORDER BY
+			created_at DESC
+	`
+
+	rows, err := s.db.Query(query, userID)
+	if err != nil {
+		return nil, fmt.Errorf(`store: unable to fetch web sessions: %v`, err)
+	}
+	defer rows.Close()
+
+	var sessions []model.WebSession
+
+	for rows.Next() {
+		session, err := scanWebSession(rows)
+		if err != nil {
+			return nil, fmt.Errorf(`store: unable to fetch web session row: %v`, err)
+		}
+
+		sessions = append(sessions, *session)
+	}
+
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf(`store: unable to fetch web sessions: %v`, err)
+	}
+
+	return sessions, nil
+}
+
+// WebSessionByID returns the web session identified by id, or nil if not found.
+func (s *Storage) WebSessionByID(sessionID string) (*model.WebSession, error) {
+	if sessionID == "" {
+		return nil, nil
+	}
+
+	row := s.db.QueryRow(`
+		SELECT
+			id,
+			secret_hash,
+			user_id,
+			created_at,
+			user_agent,
+			ip,
+			state
+		FROM
+			web_sessions
+		WHERE
+			id=$1
+	`, sessionID)
+
+	session, err := scanWebSession(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, fmt.Errorf(`store: unable to fetch web session: %v`, err)
+	}
+
+	return session, nil
+}
+
+// RotateWebSession persists a session whose identity has been rotated via
+// (*model.WebSession).Rotate(), updating the row previously keyed by oldID.
+func (s *Storage) RotateWebSession(oldID string, session *model.WebSession) error {
+	if session == nil {
+		return errors.New(`store: web session is nil`)
+	}
+
+	if oldID == "" || session.ID == "" {
+		return errors.New(`store: web session ID cannot be empty`)
+	}
+
+	stateJSON, err := session.MarshalState()
+	if err != nil {
+		return fmt.Errorf(`store: unable to serialize web session state: %v`, err)
+	}
+
+	err = s.db.QueryRow(`
+		UPDATE
+			web_sessions
+		SET
+			id=$2,
+			secret_hash=$3,
+			user_id=$4,
+			state=$5,
+			created_at=now()
+		WHERE
+			id=$1
+		RETURNING created_at
+	`,
+		oldID,
+		session.ID,
+		session.SecretHash,
+		session.NullUserID(),
+		stateJSON,
+	).Scan(&session.CreatedAt)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return errors.New(`store: nothing has been updated`)
+		}
+		return fmt.Errorf(`store: unable to rotate web session: %v`, err)
+	}
+
+	return nil
+}
+
+// UpdateWebSession updates the mutable fields of a web session.
+func (s *Storage) UpdateWebSession(session *model.WebSession) error {
+	if session == nil {
+		return errors.New(`store: web session is nil`)
+	}
+
+	if session.ID == "" {
+		return errors.New(`store: web session ID cannot be empty`)
+	}
+
+	query := `
+		UPDATE
+			web_sessions
+		SET
+			user_id=$2,
+			state=$3
+		WHERE
+			id=$1
+	`
+
+	stateJSON, err := session.MarshalState()
+	if err != nil {
+		return fmt.Errorf(`store: unable to serialize web session state: %v`, err)
+	}
+
+	result, err := s.db.Exec(
+		query,
+		session.ID,
+		session.NullUserID(),
+		stateJSON,
+	)
+	if err != nil {
+		return fmt.Errorf(`store: unable to update web session: %v`, err)
+	}
+
+	count, err := result.RowsAffected()
+	if err != nil {
+		return fmt.Errorf(`store: unable to update web session: %v`, err)
+	}
+
+	if count != 1 {
+		return errors.New(`store: nothing has been updated`)
+	}
+
+	return nil
+}
+
+// RemoveUserWebSession removes a web session for the given user if present.
+func (s *Storage) RemoveUserWebSession(userID int64, sessionID string) error {
+	if _, err := s.db.Exec(`DELETE FROM web_sessions WHERE user_id=$1 AND id=$2`, userID, sessionID); err != nil {
+		return fmt.Errorf(`store: unable to remove this web session: %v`, err)
+	}
+
+	return nil
+}
+
+// CleanOldWebSessions removes web sessions older than the specified interval (24h minimum).
+func (s *Storage) CleanOldWebSessions(interval time.Duration) (int64, error) {
+	query := `
+		DELETE FROM
+			web_sessions
+		WHERE
+			created_at < now() - $1::interval
+	`
+
+	days := max(int(interval/(24*time.Hour)), 1)
+
+	result, err := s.db.Exec(query, fmt.Sprintf("%d days", days))
+	if err != nil {
+		return 0, fmt.Errorf(`store: unable to clean old web sessions: %v`, err)
+	}
+
+	n, _ := result.RowsAffected()
+	return n, nil
+}
+
+// FlushAllSessions removes all sessions from the database.
+func (s *Storage) FlushAllSessions() error {
+	if _, err := s.db.Exec(`DELETE FROM web_sessions`); err != nil {
+		return fmt.Errorf(`store: unable to delete all web sessions: %v`, err)
+	}
+	return nil
+}
+
+type webSessionScanner interface {
+	Scan(dest ...any) error
+}
+
+func scanWebSession(scanner webSessionScanner) (*model.WebSession, error) {
+	var session model.WebSession
+	var userID sql.NullInt64
+	var ip sql.NullString
+	var stateRaw []byte
+
+	err := scanner.Scan(
+		&session.ID,
+		&session.SecretHash,
+		&userID,
+		&session.CreatedAt,
+		&session.UserAgent,
+		&ip,
+		&stateRaw,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	session.ScanUserID(userID)
+
+	if ip.Valid {
+		session.IP = ip.String
+	}
+
+	if err := session.UnmarshalState(stateRaw); err != nil {
+		return nil, err
+	}
+
+	return &session, nil
+}

+ 4 - 4
internal/template/templates/common/layout.html

@@ -114,11 +114,11 @@
         </nav>
         </nav>
     </header>
     </header>
     {{ end }}
     {{ end }}
-    {{ if .flashMessage }}
-        <div role="alert" class="flash-message alert alert-success">{{ .flashMessage }}</div>
+    {{ if .successMessage }}
+        <div role="alert" class="flash-message alert alert-success">{{ .successMessage }}</div>
     {{ end }}
     {{ end }}
-    {{ if .flashErrorMessage }}
-        <div role="alert" class="flash-error-message alert alert-error">{{ .flashErrorMessage }}</div>
+    {{ if .errorMessage }}
+        <div role="alert" class="flash-error-message alert alert-error">{{ .errorMessage }}</div>
     {{ end }}
     {{ end }}
 
 
     {{template "page_header" .}}
     {{template "page_header" .}}

+ 3 - 3
internal/template/templates/views/sessions.html

@@ -16,12 +16,12 @@
         <th>{{ t "page.sessions.table.actions" }}</th>
         <th>{{ t "page.sessions.table.actions" }}</th>
     </tr>
     </tr>
     {{ range .sessions }}
     {{ range .sessions }}
-    <tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
+    <tr {{ if eq .ID $.currentSessionID }}class="row-highlighted"{{ end }}>
         <td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
         <td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
         <td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
         <td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
         <td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
         <td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
         <td class="column-20">
         <td class="column-20">
-            {{ if eq .Token $.currentSessionToken }}
+            {{ if eq .ID $.currentSessionID }}
                 {{ t "page.sessions.table.current_session" }}
                 {{ t "page.sessions.table.current_session" }}
             {{ else }}
             {{ else }}
                 <a href="#"
                 <a href="#"
@@ -30,7 +30,7 @@
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-yes="{{ t "confirm.yes" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-no="{{ t "confirm.no" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
                     data-label-loading="{{ t "confirm.loading" }}"
-                    data-url="{{ routePath "/sessions/%d/remove" .ID }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
+                    data-url="{{ routePath "/sessions/%s/remove" .ID }}">{{ icon "delete" }}{{ t "action.remove" }}</a>
             {{ end }}
             {{ end }}
         </td>
         </td>
     </tr>
     </tr>

+ 1 - 3
internal/ui/about.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/version"
 	"miniflux.app/v2/internal/version"
 )
 )
@@ -24,8 +23,7 @@ func (h *handler) showAboutPage(w http.ResponseWriter, r *http.Request) {
 
 
 	dbSize, dbErr := h.store.DBSize()
 	dbSize, dbErr := h.store.DBSize()
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("version", version.Version)
 	view.Set("version", version.Version)
 	view.Set("commit", version.Commit)
 	view.Set("commit", version.Commit)
 	view.Set("build_date", version.BuildDate)
 	view.Set("build_date", version.BuildDate)

+ 1 - 3
internal/ui/api_key_create.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -20,8 +19,7 @@ func (h *handler) showCreateAPIKeyPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", &form.APIKeyForm{})
 	view.Set("form", &form.APIKeyForm{})
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/api_key_list.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -25,8 +24,7 @@ func (h *handler) showAPIKeysPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("apiKeys", apiKeys)
 	view.Set("apiKeys", apiKeys)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/api_key_save.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -28,8 +27,7 @@ func (h *handler) saveAPIKey(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	if validationErr := validator.ValidateAPIKeyCreation(h.store, user.ID, apiKeyCreationRequest); validationErr != nil {
 	if validationErr := validator.ValidateAPIKeyCreation(h.store, user.ID, apiKeyCreationRequest); validationErr != nil {
-		sess := session.New(h.store, request.SessionID(r))
-		view := view.New(h.tpl, r, sess)
+		view := view.New(h.tpl, r)
 		view.Set("form", apiKeyForm)
 		view.Set("form", apiKeyForm)
 		view.Set("menu", "settings")
 		view.Set("menu", "settings")
 		view.Set("user", user)
 		view.Set("user", user)

+ 64 - 0
internal/ui/auth.go

@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/oauth2"
+	"miniflux.app/v2/internal/storage"
+)
+
+const sessionCookieName = "MinifluxSessionID"
+
+// authenticateWebSession binds the current browser session to the given user,
+// rotates its identifier and secret, and refreshes the client cookie.
+func authenticateWebSession(w http.ResponseWriter, r *http.Request, store *storage.Storage, user *model.User) error {
+	session := request.WebSession(r)
+	session.SetUser(user)
+
+	oldID, secret := session.Rotate()
+	if err := store.RotateWebSession(oldID, session); err != nil {
+		return err
+	}
+
+	setSessionCookie(w, session, secret)
+	return nil
+}
+
+// setSessionCookie writes the session cookie to the response with the
+// security attributes used by miniflux (HttpOnly, SameSite=Lax, Secure
+// when HTTPS).
+func setSessionCookie(w http.ResponseWriter, session *model.WebSession, secret string) {
+	path := config.Opts.BasePath()
+	if path == "" {
+		path = "/"
+	}
+
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookieName,
+		Value:    session.ID + "." + secret,
+		Path:     path,
+		Secure:   config.Opts.HTTPS(),
+		HttpOnly: true,
+		Expires:  time.Now().Add(config.Opts.CleanupRemoveSessionsInterval()),
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func getOAuth2Manager(ctx context.Context) *oauth2.Manager {
+	return oauth2.NewManager(
+		ctx,
+		config.Opts.OAuth2Provider(),
+		config.Opts.OAuth2ClientID(),
+		config.Opts.OAuth2ClientSecret(),
+		config.Opts.OAuth2RedirectURL(),
+		config.Opts.OAuth2OIDCDiscoveryEndpoint(),
+	)
+}

+ 102 - 0
internal/ui/auth_proxy_middleware.go

@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"log/slog"
+	"net/http"
+
+	"miniflux.app/v2/internal/config"
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/storage"
+)
+
+type authProxyMiddleware struct {
+	basePath string
+	store    *storage.Storage
+}
+
+func newAuthProxyMiddleware(basePath string, store *storage.Storage) *authProxyMiddleware {
+	return &authProxyMiddleware{basePath: basePath, store: store}
+}
+
+func (m *authProxyMiddleware) handle(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if request.IsAuthenticated(r) || config.Opts.AuthProxyHeader() == "" {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		remoteIP := request.FindRemoteIP(r)
+		trustedNetworks := config.Opts.TrustedReverseProxyNetworks()
+		if !request.IsTrustedIP(remoteIP, trustedNetworks) {
+			slog.Warn("[AuthProxy] Rejecting authentication request from untrusted proxy",
+				slog.String("remote_ip", remoteIP),
+				slog.String("client_ip", request.ClientIP(r)),
+				slog.String("user_agent", r.UserAgent()),
+				slog.Any("trusted_networks", trustedNetworks),
+			)
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		username := r.Header.Get(config.Opts.AuthProxyHeader())
+		if username == "" {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		clientIP := request.ClientIP(r)
+		slog.Debug("[AuthProxy] Received authenticated requested",
+			slog.String("client_ip", clientIP),
+			slog.String("remote_ip", remoteIP),
+			slog.String("user_agent", r.UserAgent()),
+			slog.String("username", username),
+		)
+
+		user, err := m.store.UserByUsername(username)
+		if err != nil {
+			response.HTMLServerError(w, r, err)
+			return
+		}
+
+		if user == nil {
+			if !config.Opts.IsAuthProxyUserCreationAllowed() {
+				slog.Debug("[AuthProxy] User doesn't exist and user creation is not allowed",
+					slog.Bool("authentication_failed", true),
+					slog.String("client_ip", clientIP),
+					slog.String("remote_ip", remoteIP),
+					slog.String("user_agent", r.UserAgent()),
+					slog.String("username", username),
+				)
+				response.HTMLForbidden(w, r)
+				return
+			}
+
+			if user, err = m.store.CreateUser(&model.UserCreationRequest{Username: username}); err != nil {
+				response.HTMLServerError(w, r, err)
+				return
+			}
+		}
+
+		slog.Info("[AuthProxy] User authenticated successfully",
+			slog.Bool("authentication_successful", true),
+			slog.String("client_ip", clientIP),
+			slog.String("remote_ip", remoteIP),
+			slog.String("user_agent", r.UserAgent()),
+			slog.Int64("user_id", user.ID),
+			slog.String("username", user.Username),
+		)
+
+		m.store.SetLastLogin(user.ID)
+		if err := authenticateWebSession(w, r, m.store, user); err != nil {
+			response.HTMLServerError(w, r, err)
+			return
+		}
+
+		response.HTMLRedirect(w, r, m.basePath+"/"+user.DefaultHomePage)
+	})
+}

+ 1 - 3
internal/ui/category_create.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -19,8 +18,7 @@ func (h *handler) showCreateCategoryPage(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "categories")
 	view.Set("menu", "categories")
 	view.Set("user", user)
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 3
internal/ui/category_edit.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -36,8 +35,7 @@ func (h *handler) showEditCategoryPage(w http.ResponseWriter, r *http.Request) {
 		HideGlobally: category.HideGlobally,
 		HideGlobally: category.HideGlobally,
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", categoryForm)
 	view.Set("form", categoryForm)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("menu", "categories")
 	view.Set("menu", "categories")

+ 1 - 3
internal/ui/category_entries.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -48,8 +47,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("entries", entries)
 	view.Set("entries", entries)

+ 1 - 3
internal/ui/category_entries_all.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -48,8 +47,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("entries", entries)
 	view.Set("entries", entries)

+ 1 - 3
internal/ui/category_entries_starred.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -49,8 +48,7 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("entries", entries)
 	view.Set("entries", entries)

+ 1 - 3
internal/ui/category_feeds.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -37,8 +36,7 @@ func (h *handler) showCategoryFeedsPage(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("feeds", feeds)
 	view.Set("feeds", feeds)
 	view.Set("total", len(feeds))
 	view.Set("total", len(feeds))

+ 1 - 3
internal/ui/category_list.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -25,8 +24,7 @@ func (h *handler) showCategoryListPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("total", len(categories))
 	view.Set("total", len(categories))
 	view.Set("menu", "categories")
 	view.Set("menu", "categories")

+ 6 - 7
internal/ui/category_refresh.go

@@ -12,7 +12,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) refreshCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {
 func (h *handler) refreshCategoryEntriesPage(w http.ResponseWriter, r *http.Request) {
@@ -27,13 +26,13 @@ func (h *handler) refreshCategoryFeedsPage(w http.ResponseWriter, r *http.Reques
 
 
 func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64 {
 func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64 {
 	categoryID := request.RouteInt64Param(r, "categoryID")
 	categoryID := request.RouteInt64Param(r, "categoryID")
-	printer := locale.NewPrinter(request.UserLanguage(r))
-	sess := session.New(h.store, request.SessionID(r))
+	sess := request.WebSession(r)
+	printer := locale.NewPrinter(sess.Language())
 
 
 	// Avoid accidental and excessive refreshes.
 	// Avoid accidental and excessive refreshes.
-	if time.Since(request.LastForceRefresh(r)) < config.Opts.ForceRefreshInterval() {
+	if time.Since(sess.LastForceRefresh()) < config.Opts.ForceRefreshInterval() {
 		interval := int(config.Opts.ForceRefreshInterval().Minutes())
 		interval := int(config.Opts.ForceRefreshInterval().Minutes())
-		sess.NewFlashErrorMessage(printer.Plural("alert.too_many_feeds_refresh", interval, interval))
+		sess.SetErrorMessage(printer.Plural("alert.too_many_feeds_refresh", interval, interval))
 	} else {
 	} else {
 		userID := request.UserID(r)
 		userID := request.UserID(r)
 		// We allow the end-user to force refresh all its feeds in this category
 		// We allow the end-user to force refresh all its feeds in this category
@@ -59,8 +58,8 @@ func (h *handler) refreshCategory(w http.ResponseWriter, r *http.Request) int64
 
 
 		go h.pool.Push(jobs)
 		go h.pool.Push(jobs)
 
 
-		sess.SetLastForceRefresh()
-		sess.NewFlashMessage(printer.Print("alert.background_feed_refresh"))
+		sess.MarkForceRefreshed()
+		sess.SetSuccessMessage(printer.Print("alert.background_feed_refresh"))
 	}
 	}
 
 
 	return categoryID
 	return categoryID

+ 1 - 3
internal/ui/category_save.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -24,8 +23,7 @@ func (h *handler) saveCategory(w http.ResponseWriter, r *http.Request) {
 
 
 	categoryForm := form.NewCategoryForm(r)
 	categoryForm := form.NewCategoryForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", categoryForm)
 	view.Set("form", categoryForm)
 	view.Set("menu", "categories")
 	view.Set("menu", "categories")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/category_update.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -36,8 +35,7 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
 
 
 	categoryForm := form.NewCategoryForm(r)
 	categoryForm := form.NewCategoryForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", categoryForm)
 	view.Set("form", categoryForm)
 	view.Set("category", category)
 	view.Set("category", category)
 	view.Set("menu", "categories")
 	view.Set("menu", "categories")

+ 56 - 0
internal/ui/csrf_middleware.go

@@ -0,0 +1,56 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"errors"
+	"log/slog"
+	"net/http"
+
+	"miniflux.app/v2/internal/crypto"
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response"
+)
+
+type csrfMiddleware struct {
+	basePath string
+}
+
+func newCSRFMiddleware(basePath string) *csrfMiddleware {
+	return &csrfMiddleware{basePath: basePath}
+}
+
+// handle validates the CSRF token on state-changing requests. It must be
+// chained inside handleWebSession so that the session is already present
+// in the request context.
+func (m *csrfMiddleware) handle(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodPost && !m.validate(w, r) {
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+func (m *csrfMiddleware) validate(w http.ResponseWriter, r *http.Request) bool {
+	csrfToken := request.WebSession(r).CSRF()
+	formValue := r.FormValue("csrf")
+	headerValue := r.Header.Get("X-Csrf-Token")
+
+	if crypto.ConstantTimeCmp(csrfToken, formValue) || crypto.ConstantTimeCmp(csrfToken, headerValue) {
+		return true
+	}
+
+	slog.Warn("Invalid or missing CSRF token",
+		slog.String("url", r.RequestURI),
+	)
+
+	if r.URL.Path == "/login" {
+		response.HTMLRedirect(w, r, m.basePath+"/")
+	} else {
+		response.HTMLBadRequest(w, r, errors.New("invalid or missing CSRF"))
+	}
+
+	return false
+}

+ 1 - 3
internal/ui/entry_category.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -73,8 +72,7 @@ func (h *handler) showCategoryEntryPage(w http.ResponseWriter, r *http.Request)
 		prevEntryRoute = h.routePath("/category/%d/entry/%d", categoryID, prevEntry.ID)
 		prevEntryRoute = h.routePath("/category/%d/entry/%d", categoryID, prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/entry_feed.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -73,8 +72,7 @@ func (h *handler) showFeedEntryPage(w http.ResponseWriter, r *http.Request) {
 		prevEntryRoute = h.routePath("/feed/%d/entry/%d", feedID, prevEntry.ID)
 		prevEntryRoute = h.routePath("/feed/%d/entry/%d", feedID, prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/entry_read.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -60,8 +59,7 @@ func (h *handler) showReadEntryPage(w http.ResponseWriter, r *http.Request) {
 		prevEntryRoute = h.routePath("/history/entry/%d", prevEntry.ID)
 		prevEntryRoute = h.routePath("/history/entry/%d", prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/entry_search.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -81,8 +80,7 @@ func (h *handler) showSearchEntryPage(w http.ResponseWriter, r *http.Request) {
 		prevEntryRoute = h.routePath("/search/entry/%d", prevEntry.ID)
 		prevEntryRoute = h.routePath("/search/entry/%d", prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("searchQuery", searchQuery)
 	view.Set("searchQuery", searchQuery)
 	view.Set("searchUnreadOnly", unreadOnly)
 	view.Set("searchUnreadOnly", unreadOnly)
 	view.Set("entry", entry)
 	view.Set("entry", entry)

+ 1 - 3
internal/ui/entry_starred.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -70,8 +69,7 @@ func (h *handler) showStarredEntryPage(w http.ResponseWriter, r *http.Request) {
 		prevEntryRoute = h.routePath("/starred/entry/%d", prevEntry.ID)
 		prevEntryRoute = h.routePath("/starred/entry/%d", prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/entry_tag.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -73,8 +72,7 @@ func (h *handler) showTagEntryPage(w http.ResponseWriter, r *http.Request) {
 		prevEntryRoute = h.routePath("/tags/%s/entry/%d", url.PathEscape(tagName), prevEntry.ID)
 		prevEntryRoute = h.routePath("/tags/%s/entry/%d", url.PathEscape(tagName), prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/entry_unread.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -83,8 +82,7 @@ func (h *handler) showUnreadEntryPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/feed_edit.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -76,8 +75,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
 		ProxyURL:                    feed.ProxyURL,
 		ProxyURL:                    feed.ProxyURL,
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", feedForm)
 	view.Set("form", feedForm)
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("feed", feed)
 	view.Set("feed", feed)

+ 1 - 3
internal/ui/feed_entries.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -48,8 +47,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("feed", feed)
 	view.Set("feed", feed)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("total", count)
 	view.Set("total", count)

+ 1 - 3
internal/ui/feed_entries_all.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -48,8 +47,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("feed", feed)
 	view.Set("feed", feed)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("total", count)
 	view.Set("total", count)

+ 1 - 3
internal/ui/feed_list.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -25,8 +24,7 @@ func (h *handler) showFeedsPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("feeds", feeds)
 	view.Set("feeds", feeds)
 	view.Set("total", len(feeds))
 	view.Set("total", len(feeds))
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")

+ 6 - 7
internal/ui/feed_refresh.go

@@ -13,7 +13,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
 func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
@@ -32,13 +31,13 @@ func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
 }
 }
 
 
 func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
 func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
-	printer := locale.NewPrinter(request.UserLanguage(r))
-	sess := session.New(h.store, request.SessionID(r))
+	sess := request.WebSession(r)
+	printer := locale.NewPrinter(sess.Language())
 
 
 	// Avoid accidental and excessive refreshes.
 	// Avoid accidental and excessive refreshes.
-	if time.Since(request.LastForceRefresh(r)) < config.Opts.ForceRefreshInterval() {
+	if time.Since(sess.LastForceRefresh()) < config.Opts.ForceRefreshInterval() {
 		interval := int(config.Opts.ForceRefreshInterval().Minutes())
 		interval := int(config.Opts.ForceRefreshInterval().Minutes())
-		sess.NewFlashErrorMessage(printer.Plural("alert.too_many_feeds_refresh", interval, interval))
+		sess.SetErrorMessage(printer.Plural("alert.too_many_feeds_refresh", interval, interval))
 	} else {
 	} else {
 		userID := request.UserID(r)
 		userID := request.UserID(r)
 		// We allow the end-user to force refresh all its feeds
 		// We allow the end-user to force refresh all its feeds
@@ -62,8 +61,8 @@ func (h *handler) refreshAllFeeds(w http.ResponseWriter, r *http.Request) {
 
 
 		go h.pool.Push(jobs)
 		go h.pool.Push(jobs)
 
 
-		sess.SetLastForceRefresh()
-		sess.NewFlashMessage(printer.Print("alert.background_feed_refresh"))
+		sess.MarkForceRefreshed()
+		sess.SetSuccessMessage(printer.Print("alert.background_feed_refresh"))
 	}
 	}
 
 
 	response.HTMLRedirect(w, r, h.routePath("/feeds"))
 	response.HTMLRedirect(w, r, h.routePath("/feeds"))

+ 1 - 3
internal/ui/feed_update.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -43,8 +42,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
 
 
 	feedForm := form.NewFeedForm(r)
 	feedForm := form.NewFeedForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", feedForm)
 	view.Set("form", feedForm)
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("feed", feed)
 	view.Set("feed", feed)

+ 1 - 3
internal/ui/history_entries.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -35,8 +34,7 @@ func (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("pagination", getPagination(h.routePath("/history"), count, offset, user.EntriesPerPage))
 	view.Set("pagination", getPagination(h.routePath("/history"), count, offset, user.EntriesPerPage))

+ 1 - 3
internal/ui/integration_show.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -148,8 +147,7 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		ArchiveorgEnabled:                integration.ArchiveorgEnabled,
 		ArchiveorgEnabled:                integration.ArchiveorgEnabled,
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", integrationForm)
 	view.Set("form", integrationForm)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 6 - 7
internal/ui/integration_update.go

@@ -13,12 +13,11 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
-	printer := locale.NewPrinter(request.UserLanguage(r))
-	sess := session.New(h.store, request.SessionID(r))
+	sess := request.WebSession(r)
+	printer := locale.NewPrinter(sess.Language())
 	userID := request.UserID(r)
 	userID := request.UserID(r)
 
 
 	integration, err := h.store.Integration(userID)
 	integration, err := h.store.Integration(userID)
@@ -31,7 +30,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 	integrationForm.Merge(integration)
 	integrationForm.Merge(integration)
 
 
 	if integration.FeverUsername != "" && h.store.HasDuplicateFeverUsername(userID, integration.FeverUsername) {
 	if integration.FeverUsername != "" && h.store.HasDuplicateFeverUsername(userID, integration.FeverUsername) {
-		sess.NewFlashErrorMessage(printer.Print("error.duplicate_fever_username"))
+		sess.SetErrorMessage(printer.Print("error.duplicate_fever_username"))
 		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		return
 		return
 	}
 	}
@@ -45,7 +44,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	if integration.GoogleReaderUsername != "" && h.store.HasDuplicateGoogleReaderUsername(userID, integration.GoogleReaderUsername) {
 	if integration.GoogleReaderUsername != "" && h.store.HasDuplicateGoogleReaderUsername(userID, integration.GoogleReaderUsername) {
-		sess.NewFlashErrorMessage(printer.Print("error.duplicate_googlereader_username"))
+		sess.SetErrorMessage(printer.Print("error.duplicate_googlereader_username"))
 		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		response.HTMLRedirect(w, r, h.routePath("/integrations"))
 		return
 		return
 	}
 	}
@@ -76,7 +75,7 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 
 
 	if integrationForm.LinktacoEnabled {
 	if integrationForm.LinktacoEnabled {
 		if integrationForm.LinktacoAPIToken == "" || integrationForm.LinktacoOrgSlug == "" {
 		if integrationForm.LinktacoAPIToken == "" || integrationForm.LinktacoOrgSlug == "" {
-			sess.NewFlashErrorMessage(printer.Print("error.linktaco_missing_required_fields"))
+			sess.SetErrorMessage(printer.Print("error.linktaco_missing_required_fields"))
 			response.HTMLRedirect(w, r, h.routePath("/integrations"))
 			response.HTMLRedirect(w, r, h.routePath("/integrations"))
 			return
 			return
 		}
 		}
@@ -91,6 +90,6 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess.NewFlashMessage(printer.Print("alert.prefs_saved"))
+	sess.SetSuccessMessage(printer.Print("alert.prefs_saved"))
 	response.HTMLRedirect(w, r, h.routePath("/integrations"))
 	response.HTMLRedirect(w, r, h.routePath("/integrations"))
 }
 }

+ 12 - 22
internal/ui/login_check.go

@@ -4,24 +4,22 @@
 package ui // import "miniflux.app/v2/internal/ui"
 package ui // import "miniflux.app/v2/internal/ui"
 
 
 import (
 import (
+	"errors"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
 
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/urllib"
 	"miniflux.app/v2/internal/urllib"
 )
 )
 
 
 func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 	clientIP := request.ClientIP(r)
 	clientIP := request.ClientIP(r)
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	redirectURL := r.FormValue("redirect_url")
 	redirectURL := r.FormValue("redirect_url")
 	view.Set("redirectURL", redirectURL)
 	view.Set("redirectURL", redirectURL)
 
 
@@ -35,11 +33,11 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	authForm := form.NewAuthForm(r)
 	authForm := form.NewAuthForm(r)
-	view.Set("errorMessage", locale.NewLocalizedError("error.bad_credentials").Translate(request.UserLanguage(r)))
+	view.Set("errorMessage", locale.NewLocalizedError("error.bad_credentials").Translate(request.WebSession(r).Language()))
 	view.Set("form", authForm)
 	view.Set("form", authForm)
 
 
 	if validationErr := authForm.Validate(); validationErr != nil {
 	if validationErr := authForm.Validate(); validationErr != nil {
-		translatedErrorMessage := validationErr.Translate(request.UserLanguage(r))
+		translatedErrorMessage := validationErr.Translate(request.WebSession(r).Language())
 		slog.Warn("Validation error during login check",
 		slog.Warn("Validation error during login check",
 			slog.Bool("authentication_failed", true),
 			slog.Bool("authentication_failed", true),
 			slog.String("client_ip", clientIP),
 			slog.String("client_ip", clientIP),
@@ -63,38 +61,30 @@ func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sessionToken, userID, err := h.store.CreateUserSessionFromUsername(authForm.Username, r.UserAgent(), clientIP)
+	user, err := h.store.UserByUsername(authForm.Username)
 	if err != nil {
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)
 		return
 		return
 	}
 	}
+	if user == nil {
+		response.HTMLServerError(w, r, errors.New("authenticated user not found"))
+		return
+	}
 
 
 	slog.Info("User authenticated successfully with username/password",
 	slog.Info("User authenticated successfully with username/password",
 		slog.Bool("authentication_successful", true),
 		slog.Bool("authentication_successful", true),
 		slog.String("client_ip", clientIP),
 		slog.String("client_ip", clientIP),
 		slog.String("user_agent", r.UserAgent()),
 		slog.String("user_agent", r.UserAgent()),
-		slog.Int64("user_id", userID),
+		slog.Int64("user_id", user.ID),
 		slog.String("username", authForm.Username),
 		slog.String("username", authForm.Username),
 	)
 	)
 
 
-	h.store.SetLastLogin(userID)
-
-	user, err := h.store.UserByID(userID)
-	if err != nil {
+	h.store.SetLastLogin(user.ID)
+	if err := authenticateWebSession(w, r, h.store, user); err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)
 		return
 		return
 	}
 	}
 
 
-	sess.SetLanguage(user.Language)
-	sess.SetTheme(user.Theme)
-
-	http.SetCookie(w, cookie.New(
-		cookie.CookieUserSessionID,
-		sessionToken,
-		config.Opts.HTTPS(),
-		config.Opts.BasePath(),
-	))
-
 	if redirectURL != "" && urllib.IsRelativePath(redirectURL) {
 	if redirectURL != "" && urllib.IsRelativePath(redirectURL) {
 		response.HTMLRedirect(w, r, redirectURL)
 		response.HTMLRedirect(w, r, redirectURL)
 		return
 		return

+ 1 - 3
internal/ui/login_show.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -24,8 +23,7 @@ func (h *handler) showLoginPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	redirectURL := request.QueryStringParam(r, "redirect_url", "")
 	redirectURL := request.QueryStringParam(r, "redirect_url", "")
 	view.Set("redirectURL", redirectURL)
 	view.Set("redirectURL", redirectURL)
 	response.HTML(w, r, view.Render("login"))
 	response.HTML(w, r, view.Render("login"))

+ 4 - 16
internal/ui/logout.go

@@ -6,34 +6,22 @@ package ui // import "miniflux.app/v2/internal/ui"
 import (
 import (
 	"net/http"
 	"net/http"
 
 
-	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
 func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
-	sess := session.New(h.store, request.SessionID(r))
 	user, err := h.store.UserByID(request.UserID(r))
 	user, err := h.store.UserByID(request.UserID(r))
 	if err != nil {
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)
 		return
 		return
 	}
 	}
 
 
-	sess.SetLanguage(user.Language)
-	sess.SetTheme(user.Theme)
-
-	if err := h.store.RemoveUserSessionByToken(user.ID, request.UserSessionToken(r)); err != nil {
-		response.HTMLServerError(w, r, err)
-		return
+	if s := request.WebSession(r); s != nil {
+		s.ClearUser()
+		s.SetLanguage(user.Language)
+		s.SetTheme(user.Theme)
 	}
 	}
 
 
-	http.SetCookie(w, cookie.Expired(
-		cookie.CookieUserSessionID,
-		config.Opts.HTTPS(),
-		config.Opts.BasePath(),
-	))
-
 	response.HTMLRedirect(w, r, h.routePath("/"))
 	response.HTMLRedirect(w, r, h.routePath("/"))
 }
 }

+ 0 - 295
internal/ui/middleware.go

@@ -1,295 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui // import "miniflux.app/v2/internal/ui"
-
-import (
-	"context"
-	"errors"
-	"log/slog"
-	"net/http"
-	"net/url"
-	"strings"
-
-	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/crypto"
-	"miniflux.app/v2/internal/http/cookie"
-	"miniflux.app/v2/internal/http/request"
-	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
-)
-
-type middleware struct {
-	basePath string
-	store    *storage.Storage
-}
-
-func newMiddleware(basePath string, store *storage.Storage) *middleware {
-	return &middleware{basePath, store}
-}
-
-func (m *middleware) handleUserSession(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		session := m.getUserSessionFromCookie(r)
-
-		if session == nil {
-			if isPublicRoute(r) {
-				next.ServeHTTP(w, r)
-			} else {
-				slog.Debug("Redirecting to login page because no user session has been found",
-					slog.String("url", r.RequestURI),
-				)
-				loginURL, _ := url.Parse(m.basePath + "/")
-				values := loginURL.Query()
-				values.Set("redirect_url", r.RequestURI)
-				loginURL.RawQuery = values.Encode()
-				response.HTMLRedirect(w, r, loginURL.String())
-			}
-		} else {
-			slog.Debug("User session found",
-				slog.String("url", r.RequestURI),
-				slog.Int64("user_id", session.UserID),
-				slog.Int64("user_session_id", session.ID),
-			)
-
-			ctx := r.Context()
-			ctx = context.WithValue(ctx, request.UserIDContextKey, session.UserID)
-			ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
-			ctx = context.WithValue(ctx, request.UserSessionTokenContextKey, session.Token)
-
-			next.ServeHTTP(w, r.WithContext(ctx))
-		}
-	})
-}
-
-func (m *middleware) handleAppSession(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if isStaticAssetRoute(r) {
-			next.ServeHTTP(w, r)
-			return
-		}
-
-		var err error
-		session := m.getAppSessionValueFromCookie(r)
-
-		if session == nil {
-			if request.IsAuthenticated(r) {
-				userID := request.UserID(r)
-				slog.Debug("Cookie expired but user is logged: creating a new app session",
-					slog.Int64("user_id", userID),
-				)
-				session, err = m.store.CreateAppSessionWithUserPrefs(userID)
-				if err != nil {
-					response.HTMLServerError(w, r, err)
-					return
-				}
-			} else {
-				slog.Debug("App session not found, creating a new one")
-				session, err = m.store.CreateAppSession()
-				if err != nil {
-					response.HTMLServerError(w, r, err)
-					return
-				}
-			}
-
-			http.SetCookie(w, cookie.New(cookie.CookieAppSessionID, session.ID, config.Opts.HTTPS(), config.Opts.BasePath()))
-		}
-
-		if r.Method == http.MethodPost {
-			formValue := r.FormValue("csrf")
-			headerValue := r.Header.Get("X-Csrf-Token")
-
-			if !crypto.ConstantTimeCmp(session.Data.CSRF, formValue) && !crypto.ConstantTimeCmp(session.Data.CSRF, headerValue) {
-				slog.Warn("Invalid or missing CSRF token",
-					slog.String("url", r.RequestURI),
-				)
-
-				if r.URL.Path == "/login" {
-					response.HTMLRedirect(w, r, m.basePath+"/")
-					return
-				}
-
-				response.HTMLBadRequest(w, r, errors.New("invalid or missing CSRF"))
-				return
-			}
-		}
-
-		ctx := r.Context()
-		ctx = context.WithValue(ctx, request.SessionIDContextKey, session.ID)
-		ctx = context.WithValue(ctx, request.CSRFContextKey, session.Data.CSRF)
-		ctx = context.WithValue(ctx, request.OAuth2StateContextKey, session.Data.OAuth2State)
-		ctx = context.WithValue(ctx, request.OAuth2CodeVerifierContextKey, session.Data.OAuth2CodeVerifier)
-		ctx = context.WithValue(ctx, request.FlashMessageContextKey, session.Data.FlashMessage)
-		ctx = context.WithValue(ctx, request.FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
-		ctx = context.WithValue(ctx, request.UserLanguageContextKey, session.Data.Language)
-		ctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme)
-		ctx = context.WithValue(ctx, request.LastForceRefreshContextKey, session.Data.LastForceRefresh)
-		ctx = context.WithValue(ctx, request.WebAuthnDataContextKey, session.Data.WebAuthnSessionData)
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
-}
-
-func (m *middleware) getAppSessionValueFromCookie(r *http.Request) *model.Session {
-	cookieValue := request.CookieValue(r, cookie.CookieAppSessionID)
-	if cookieValue == "" {
-		return nil
-	}
-
-	session, err := m.store.AppSession(cookieValue)
-	if err != nil {
-		slog.Debug("Unable to fetch app session from the database; another session will be created",
-			slog.Any("error", err),
-		)
-		return nil
-	}
-
-	return session
-}
-
-// isStaticAssetRoute checks if the request path corresponds to a static
-// asset route that does not require an app session.
-func isStaticAssetRoute(r *http.Request) bool {
-	path := r.URL.Path
-
-	switch path {
-	case "/favicon.ico", "/robots.txt":
-		return true
-	}
-
-	return strings.HasPrefix(path, "/stylesheets/") ||
-		strings.HasPrefix(path, "/js/") ||
-		strings.HasPrefix(path, "/icon/") ||
-		strings.HasPrefix(path, "/feed-icon/")
-}
-
-// isPublicRoute checks if the request path corresponds to a route that
-// does not require authentication. The path is expected to have the base
-// path already stripped.
-func isPublicRoute(r *http.Request) bool {
-	if isStaticAssetRoute(r) {
-		return true
-	}
-
-	path := r.URL.Path
-
-	switch path {
-	case "/", "/login", "/manifest.json",
-		"/healthcheck", "/offline",
-		"/webauthn/login/begin", "/webauthn/login/finish":
-		return true
-	}
-
-	return strings.HasPrefix(path, "/oauth2/") && (strings.HasSuffix(path, "/redirect") || strings.HasSuffix(path, "/callback")) ||
-		strings.HasPrefix(path, "/share/") ||
-		strings.HasPrefix(path, "/proxy/")
-}
-
-func (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
-	cookieValue := request.CookieValue(r, cookie.CookieUserSessionID)
-	if cookieValue == "" {
-		return nil
-	}
-
-	session, err := m.store.UserSessionByToken(cookieValue)
-	if err != nil {
-		slog.Error("Unable to fetch user session from the database",
-			slog.Any("error", err),
-		)
-		return nil
-	}
-
-	return session
-}
-
-func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if request.IsAuthenticated(r) || config.Opts.AuthProxyHeader() == "" {
-			next.ServeHTTP(w, r)
-			return
-		}
-
-		remoteIP := request.FindRemoteIP(r)
-		trustedNetworks := config.Opts.TrustedReverseProxyNetworks()
-		if !request.IsTrustedIP(remoteIP, trustedNetworks) {
-			slog.Warn("[AuthProxy] Rejecting authentication request from untrusted proxy",
-				slog.String("remote_ip", remoteIP),
-				slog.String("client_ip", request.ClientIP(r)),
-				slog.String("user_agent", r.UserAgent()),
-				slog.Any("trusted_networks", trustedNetworks),
-			)
-			next.ServeHTTP(w, r)
-			return
-		}
-
-		username := r.Header.Get(config.Opts.AuthProxyHeader())
-		if username == "" {
-			next.ServeHTTP(w, r)
-			return
-		}
-
-		clientIP := request.ClientIP(r)
-		slog.Debug("[AuthProxy] Received authenticated requested",
-			slog.String("client_ip", clientIP),
-			slog.String("remote_ip", remoteIP),
-			slog.String("user_agent", r.UserAgent()),
-			slog.String("username", username),
-		)
-
-		user, err := m.store.UserByUsername(username)
-		if err != nil {
-			response.HTMLServerError(w, r, err)
-			return
-		}
-
-		if user == nil {
-			if !config.Opts.IsAuthProxyUserCreationAllowed() {
-				slog.Debug("[AuthProxy] User doesn't exist and user creation is not allowed",
-					slog.Bool("authentication_failed", true),
-					slog.String("client_ip", clientIP),
-					slog.String("remote_ip", remoteIP),
-					slog.String("user_agent", r.UserAgent()),
-					slog.String("username", username),
-				)
-				response.HTMLForbidden(w, r)
-				return
-			}
-
-			if user, err = m.store.CreateUser(&model.UserCreationRequest{Username: username}); err != nil {
-				response.HTMLServerError(w, r, err)
-				return
-			}
-		}
-
-		sessionToken, _, err := m.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)
-		if err != nil {
-			response.HTMLServerError(w, r, err)
-			return
-		}
-
-		slog.Info("[AuthProxy] User authenticated successfully",
-			slog.Bool("authentication_successful", true),
-			slog.String("client_ip", clientIP),
-			slog.String("remote_ip", remoteIP),
-			slog.String("user_agent", r.UserAgent()),
-			slog.Int64("user_id", user.ID),
-			slog.String("username", user.Username),
-		)
-
-		m.store.SetLastLogin(user.ID)
-
-		sess := session.New(m.store, request.SessionID(r))
-		sess.SetLanguage(user.Language)
-		sess.SetTheme(user.Theme)
-
-		http.SetCookie(w, cookie.New(
-			cookie.CookieUserSessionID,
-			sessionToken,
-			config.Opts.HTTPS(),
-			config.Opts.BasePath(),
-		))
-
-		response.HTMLRedirect(w, r, m.basePath+"/"+user.DefaultHomePage)
-	})
-}

+ 0 - 22
internal/ui/oauth2.go

@@ -1,22 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package ui // import "miniflux.app/v2/internal/ui"
-
-import (
-	"context"
-
-	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/oauth2"
-)
-
-func getOAuth2Manager(ctx context.Context) *oauth2.Manager {
-	return oauth2.NewManager(
-		ctx,
-		config.Opts.OAuth2Provider(),
-		config.Opts.OAuth2ClientID(),
-		config.Opts.OAuth2ClientSecret(),
-		config.Opts.OAuth2RedirectURL(),
-		config.Opts.OAuth2OIDCDiscoveryEndpoint(),
-	)
-}

+ 16 - 29
internal/ui/oauth2_callback.go

@@ -10,12 +10,10 @@ import (
 	"net/http"
 	"net/http"
 
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
-	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
@@ -33,13 +31,18 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
+	sess := request.WebSession(r)
+
 	state := request.QueryStringParam(r, "state", "")
 	state := request.QueryStringParam(r, "state", "")
-	if subtle.ConstantTimeCompare([]byte(state), []byte(request.OAuth2State(r))) == 0 {
+	if subtle.ConstantTimeCompare([]byte(state), []byte(sess.OAuth2State())) == 0 {
 		slog.Warn("Invalid OAuth2 state value received")
 		slog.Warn("Invalid OAuth2 state value received")
 		response.HTMLRedirect(w, r, h.routePath("/"))
 		response.HTMLRedirect(w, r, h.routePath("/"))
 		return
 		return
 	}
 	}
 
 
+	codeVerifier := sess.OAuth2CodeVerifier()
+	sess.ClearOAuth2Flow()
+
 	authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
 	authProvider, err := getOAuth2Manager(r.Context()).FindProvider(provider)
 	if err != nil {
 	if err != nil {
 		slog.Error("Unable to initialize OAuth2 provider",
 		slog.Error("Unable to initialize OAuth2 provider",
@@ -50,7 +53,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	profile, err := authProvider.Profile(r.Context(), code, request.OAuth2CodeVerifier(r))
+	profile, err := authProvider.Profile(r.Context(), code, codeVerifier)
 	if err != nil {
 	if err != nil {
 		slog.Warn("Unable to get OAuth2 profile from provider",
 		slog.Warn("Unable to get OAuth2 profile from provider",
 			slog.String("provider", provider),
 			slog.String("provider", provider),
@@ -60,11 +63,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	sess.SetOAuth2State("")
-	sess.SetOAuth2CodeVerifier("")
-
-	printer := locale.NewPrinter(request.UserLanguage(r))
+	printer := locale.NewPrinter(sess.Language())
 
 
 	if request.IsAuthenticated(r) {
 	if request.IsAuthenticated(r) {
 		loggedUser, err := h.store.UserByID(request.UserID(r))
 		loggedUser, err := h.store.UserByID(request.UserID(r))
@@ -79,7 +78,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 				slog.String("oauth2_provider", provider),
 				slog.String("oauth2_provider", provider),
 				slog.String("oauth2_profile_id", profile.ID),
 				slog.String("oauth2_profile_id", profile.ID),
 			)
 			)
-			sess.NewFlashErrorMessage(printer.Print("error.duplicate_linked_account"))
+			sess.SetErrorMessage(printer.Print("error.duplicate_linked_account"))
 			response.HTMLRedirect(w, r, h.routePath("/settings"))
 			response.HTMLRedirect(w, r, h.routePath("/settings"))
 			return
 			return
 		}
 		}
@@ -92,7 +91,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 				slog.String("existing_profile_id", existingProfileID),
 				slog.String("existing_profile_id", existingProfileID),
 				slog.String("new_profile_id", profile.ID),
 				slog.String("new_profile_id", profile.ID),
 			)
 			)
-			sess.NewFlashErrorMessage(printer.Print("error.duplicate_linked_account"))
+			sess.SetErrorMessage(printer.Print("error.duplicate_linked_account"))
 			response.HTMLRedirect(w, r, h.routePath("/settings"))
 			response.HTMLRedirect(w, r, h.routePath("/settings"))
 			return
 			return
 		}
 		}
@@ -103,7 +102,7 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		sess.NewFlashMessage(printer.Print("alert.account_linked"))
+		sess.SetSuccessMessage(printer.Print("alert.account_linked"))
 		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		return
 		return
 	}
 	}
@@ -135,31 +134,19 @@ func (h *handler) oauth2Callback(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	clientIP := request.ClientIP(r)
-	sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), clientIP)
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
 	slog.Info("User authenticated successfully using OAuth2",
 	slog.Info("User authenticated successfully using OAuth2",
 		slog.Bool("authentication_successful", true),
 		slog.Bool("authentication_successful", true),
-		slog.String("client_ip", clientIP),
+		slog.String("client_ip", request.ClientIP(r)),
 		slog.String("user_agent", r.UserAgent()),
 		slog.String("user_agent", r.UserAgent()),
 		slog.Int64("user_id", user.ID),
 		slog.Int64("user_id", user.ID),
 		slog.String("username", user.Username),
 		slog.String("username", user.Username),
 	)
 	)
 
 
 	h.store.SetLastLogin(user.ID)
 	h.store.SetLastLogin(user.ID)
-	sess.SetLanguage(user.Language)
-	sess.SetTheme(user.Theme)
-
-	http.SetCookie(w, cookie.New(
-		cookie.CookieUserSessionID,
-		sessionToken,
-		config.Opts.HTTPS(),
-		config.Opts.BasePath(),
-	))
+	if err := authenticateWebSession(w, r, h.store, user); err != nil {
+		response.HTMLServerError(w, r, err)
+		return
+	}
 
 
 	response.HTMLRedirect(w, r, h.basePath+"/"+user.DefaultHomePage)
 	response.HTMLRedirect(w, r, h.basePath+"/"+user.DefaultHomePage)
 }
 }

+ 1 - 4
internal/ui/oauth2_redirect.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/oauth2"
 	"miniflux.app/v2/internal/oauth2"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
 func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
@@ -33,9 +32,7 @@ func (h *handler) oauth2Redirect(w http.ResponseWriter, r *http.Request) {
 
 
 	auth := oauth2.GenerateAuthorization(authProvider.Config())
 	auth := oauth2.GenerateAuthorization(authProvider.Config())
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	sess.SetOAuth2State(auth.State())
-	sess.SetOAuth2CodeVerifier(auth.CodeVerifier())
+	request.WebSession(r).StartOAuth2Flow(auth.State(), auth.CodeVerifier())
 
 
 	response.HTMLRedirect(w, r, auth.RedirectURL())
 	response.HTMLRedirect(w, r, auth.RedirectURL())
 }
 }

+ 4 - 5
internal/ui/oauth2_unlink.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
-	"miniflux.app/v2/internal/ui/session"
 )
 )
 
 
 func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
@@ -52,10 +51,10 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	printer := locale.NewPrinter(request.UserLanguage(r))
+	sess := request.WebSession(r)
+	printer := locale.NewPrinter(sess.Language())
 	if !hasPassword {
 	if !hasPassword {
-		sess.NewFlashErrorMessage(printer.Print("error.unlink_account_without_password"))
+		sess.SetErrorMessage(printer.Print("error.unlink_account_without_password"))
 		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		response.HTMLRedirect(w, r, h.routePath("/settings"))
 		return
 		return
 	}
 	}
@@ -66,6 +65,6 @@ func (h *handler) oauth2Unlink(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess.NewFlashMessage(printer.Print("alert.account_unlinked"))
+	sess.SetSuccessMessage(printer.Print("alert.account_unlinked"))
 	response.HTMLRedirect(w, r, h.routePath("/settings"))
 	response.HTMLRedirect(w, r, h.routePath("/settings"))
 }
 }

+ 1 - 4
internal/ui/offline.go

@@ -6,14 +6,11 @@ package ui // import "miniflux.app/v2/internal/ui"
 import (
 import (
 	"net/http"
 	"net/http"
 
 
-	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
 func (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) {
 func (h *handler) showOfflinePage(w http.ResponseWriter, r *http.Request) {
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	response.HTML(w, r, view.Render("offline"))
 	response.HTML(w, r, view.Render("offline"))
 }
 }

+ 1 - 3
internal/ui/opml_import.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -19,8 +18,7 @@ func (h *handler) showImportPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 2 - 5
internal/ui/opml_upload.go

@@ -15,7 +15,6 @@ import (
 	"miniflux.app/v2/internal/proxyrotator"
 	"miniflux.app/v2/internal/proxyrotator"
 	"miniflux.app/v2/internal/reader/fetcher"
 	"miniflux.app/v2/internal/reader/fetcher"
 	"miniflux.app/v2/internal/reader/opml"
 	"miniflux.app/v2/internal/reader/opml"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -44,8 +43,7 @@ func (h *handler) uploadOPML(w http.ResponseWriter, r *http.Request) {
 		slog.Int64("file_size", fileHeader.Size),
 		slog.Int64("file_size", fileHeader.Size),
 	)
 	)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
@@ -84,8 +82,7 @@ func (h *handler) fetchOPML(w http.ResponseWriter, r *http.Request) {
 		slog.String("opml_file_url", opmlFileURL),
 		slog.String("opml_file_url", opmlFileURL),
 	)
 	)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 58 - 0
internal/ui/routes.go

@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"net/http"
+	"net/url"
+	"strings"
+)
+
+// isStaticAssetRoute checks if the request path corresponds to a static
+// asset route that does not require a web session.
+func isStaticAssetRoute(r *http.Request) bool {
+	path := r.URL.Path
+
+	switch path {
+	case "/favicon.ico", "/robots.txt":
+		return true
+	}
+
+	return strings.HasPrefix(path, "/stylesheets/") ||
+		strings.HasPrefix(path, "/js/") ||
+		strings.HasPrefix(path, "/icon/") ||
+		strings.HasPrefix(path, "/feed-icon/")
+}
+
+// isPublicRoute checks if the request path corresponds to a route that
+// does not require authentication. The path is expected to have the base
+// path already stripped.
+func isPublicRoute(r *http.Request) bool {
+	if isStaticAssetRoute(r) {
+		return true
+	}
+
+	path := r.URL.Path
+
+	switch path {
+	case "/", "/login", "/manifest.json",
+		"/healthcheck", "/offline",
+		"/webauthn/login/begin", "/webauthn/login/finish":
+		return true
+	}
+
+	return strings.HasPrefix(path, "/oauth2/") && (strings.HasSuffix(path, "/redirect") || strings.HasSuffix(path, "/callback")) ||
+		strings.HasPrefix(path, "/share/") ||
+		strings.HasPrefix(path, "/proxy/")
+}
+
+// loginRedirectURL builds the login page URL with the given request URI
+// stored in the redirect_url query parameter.
+func loginRedirectURL(basePath, requestURI string) string {
+	loginURL, _ := url.Parse(basePath + "/")
+	values := loginURL.Query()
+	values.Set("redirect_url", requestURI)
+	loginURL.RawQuery = values.Encode()
+	return loginURL.String()
+}

+ 1 - 3
internal/ui/search.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -45,8 +44,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	pagination := getPagination(h.routePath("/search"), entriesCount, offset, user.EntriesPerPage)
 	pagination := getPagination(h.routePath("/search"), entriesCount, offset, user.EntriesPerPage)
 	pagination.SearchQuery = searchQuery
 	pagination.SearchQuery = searchQuery
 	pagination.UnreadOnly = unreadOnly
 	pagination.UnreadOnly = unreadOnly

+ 0 - 75
internal/ui/session/session.go

@@ -1,75 +0,0 @@
-// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
-// SPDX-License-Identifier: Apache-2.0
-
-package session // import "miniflux.app/v2/internal/ui/session"
-
-import (
-	"time"
-
-	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/storage"
-)
-
-// Session handles session data.
-type Session struct {
-	store     *storage.Storage
-	sessionID string
-}
-
-// New returns a new session handler.
-func New(store *storage.Storage, sessionID string) *Session {
-	return &Session{store, sessionID}
-}
-
-func (s *Session) SetLastForceRefresh() {
-	s.store.SetAppSessionTextField(s.sessionID, "last_force_refresh", time.Now().UTC().Unix())
-}
-
-func (s *Session) SetOAuth2State(state string) {
-	s.store.SetAppSessionTextField(s.sessionID, "oauth2_state", state)
-}
-
-func (s *Session) SetOAuth2CodeVerifier(codeVerfier string) {
-	s.store.SetAppSessionTextField(s.sessionID, "oauth2_code_verifier", codeVerfier)
-}
-
-// NewFlashMessage creates a new flash message.
-func (s *Session) NewFlashMessage(message string) {
-	s.store.SetAppSessionTextField(s.sessionID, "flash_message", message)
-}
-
-// FlashMessage returns the current flash message if any.
-func (s *Session) FlashMessage(message string) string {
-	if message != "" {
-		s.store.SetAppSessionTextField(s.sessionID, "flash_message", "")
-	}
-	return message
-}
-
-// NewFlashErrorMessage creates a new flash error message.
-func (s *Session) NewFlashErrorMessage(message string) {
-	s.store.SetAppSessionTextField(s.sessionID, "flash_error_message", message)
-}
-
-// FlashErrorMessage returns the last flash error message if any.
-func (s *Session) FlashErrorMessage(message string) string {
-	if message != "" {
-		s.store.SetAppSessionTextField(s.sessionID, "flash_error_message", "")
-	}
-	return message
-}
-
-// SetLanguage updates the language field in session.
-func (s *Session) SetLanguage(language string) {
-	s.store.SetAppSessionTextField(s.sessionID, "language", language)
-}
-
-// SetTheme updates the theme field in session.
-func (s *Session) SetTheme(theme string) {
-	s.store.SetAppSessionTextField(s.sessionID, "theme", theme)
-}
-
-// SetWebAuthnSessionData sets the WebAuthn session data.
-func (s *Session) SetWebAuthnSessionData(sessionData *model.WebAuthnSession) {
-	s.store.SetAppSessionJSONField(s.sessionID, "webauthn_session_data", sessionData)
-}

+ 5 - 7
internal/ui/session_list.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -19,19 +18,18 @@ func (h *handler) showSessionsPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sessions, err := h.store.UserSessions(user.ID)
+	sessions, err := h.store.WebSessionsByUserID(user.ID)
 	if err != nil {
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)
 		return
 		return
 	}
 	}
 
 
-	for _, sess := range sessions {
-		sess.UseTimezone(user.Timezone)
+	for i := range sessions {
+		sessions[i].UseTimezone(user.Timezone)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
-	view.Set("currentSessionToken", request.UserSessionToken(r))
+	view := view.New(h.tpl, r)
+	view.Set("currentSessionID", request.WebSession(r).ID)
 	view.Set("sessions", sessions)
 	view.Set("sessions", sessions)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 2 - 2
internal/ui/session_remove.go

@@ -11,8 +11,8 @@ import (
 )
 )
 
 
 func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
 func (h *handler) removeSession(w http.ResponseWriter, r *http.Request) {
-	sessionID := request.RouteInt64Param(r, "sessionID")
-	err := h.store.RemoveUserSessionByID(request.UserID(r), sessionID)
+	sessionID := request.RouteStringParam(r, "sessionID")
+	err := h.store.RemoveUserWebSession(request.UserID(r), sessionID)
 	if err != nil {
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		response.HTMLServerError(w, r, err)
 		return
 		return

+ 1 - 3
internal/ui/settings_show.go

@@ -12,7 +12,6 @@ import (
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -57,8 +56,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", settingsForm)
 	view.Set("form", settingsForm)
 	view.Set("readBehaviors", map[string]any{
 	view.Set("readBehaviors", map[string]any{
 		"NoAutoMarkAsRead":                           form.NoAutoMarkAsRead,
 		"NoAutoMarkAsRead":                           form.NoAutoMarkAsRead,

+ 4 - 6
internal/ui/settings_update.go

@@ -12,7 +12,6 @@ import (
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/timezone"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -32,8 +31,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 
 
 	settingsForm := form.NewSettingsForm(r)
 	settingsForm := form.NewSettingsForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", settingsForm)
 	view.Set("form", settingsForm)
 	view.Set("readBehaviors", map[string]any{
 	view.Set("readBehaviors", map[string]any{
 		"NoAutoMarkAsRead":                           form.NoAutoMarkAsRead,
 		"NoAutoMarkAsRead":                           form.NoAutoMarkAsRead,
@@ -92,8 +90,8 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess.SetLanguage(user.Language)
-	sess.SetTheme(user.Theme)
-	sess.NewFlashMessage(locale.NewPrinter(request.UserLanguage(r)).Printf("alert.prefs_saved"))
+	sess := request.WebSession(r)
+	sess.SetUser(user)
+	sess.SetSuccessMessage(locale.NewPrinter(sess.Language()).Printf("alert.prefs_saved"))
 	response.HTMLRedirect(w, r, h.routePath("/settings"))
 	response.HTMLRedirect(w, r, h.routePath("/settings"))
 }
 }

+ 1 - 3
internal/ui/share.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 
 
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -54,8 +53,7 @@ func (h *handler) sharedEntry(w http.ResponseWriter, r *http.Request) {
 			return
 			return
 		}
 		}
 
 
-		sess := session.New(h.store, request.SessionID(r))
-		view := view.New(h.tpl, r, sess)
+		view := view.New(h.tpl, r)
 		view.Set("entry", entry)
 		view.Set("entry", entry)
 
 
 		b.WithHeader("Content-Type", "text/html; charset=utf-8")
 		b.WithHeader("Content-Type", "text/html; charset=utf-8")

+ 1 - 3
internal/ui/shared_entries.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -34,8 +33,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("pagination", getPagination(h.routePath("/shares"), count, offset, user.EntriesPerPage))
 	view.Set("pagination", getPagination(h.routePath("/shares"), count, offset, user.EntriesPerPage))

+ 1 - 3
internal/ui/starred_entries.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -36,8 +35,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("pagination", getPagination(h.routePath("/starred"), count, offset, user.EntriesPerPage))
 	view.Set("pagination", getPagination(h.routePath("/starred"), count, offset, user.EntriesPerPage))

+ 1 - 3
internal/ui/starred_entry_category.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -75,8 +74,7 @@ func (h *handler) showStarredCategoryEntryPage(w http.ResponseWriter, r *http.Re
 		prevEntryRoute = h.routePath("/starred/category/%d/entry/%d", categoryID, prevEntry.ID)
 		prevEntryRoute = h.routePath("/starred/category/%d/entry/%d", categoryID, prevEntry.ID)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 1
internal/ui/static_manifest.go

@@ -76,7 +76,7 @@ func (h *handler) showWebManifest(w http.ResponseWriter, r *http.Request) {
 		labelSearchMenu = printer.Print("menu.search")
 		labelSearchMenu = printer.Print("menu.search")
 		labelSettingsMenu = printer.Print("menu.settings")
 		labelSettingsMenu = printer.Print("menu.settings")
 	}
 	}
-	themeColor := model.ThemeColor(request.UserTheme(r), "light")
+	themeColor := model.ThemeColor(request.WebSession(r).Theme(), "light")
 	manifest := &webManifest{
 	manifest := &webManifest{
 		Name:            "Miniflux",
 		Name:            "Miniflux",
 		ShortName:       "Miniflux",
 		ShortName:       "Miniflux",

+ 1 - 3
internal/ui/subscription_add.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -27,8 +26,7 @@ func (h *handler) showAddSubscriptionPage(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/subscription_bookmarklet.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -44,8 +43,7 @@ func (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {
 		bookmarkletURL = urlRe.FindString(text)
 		bookmarkletURL = urlRe.FindString(text)
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", form.SubscriptionForm{URL: bookmarkletURL})
 	view.Set("form", form.SubscriptionForm{URL: bookmarkletURL})
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")

+ 1 - 3
internal/ui/subscription_choose.go

@@ -12,7 +12,6 @@ import (
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -29,8 +28,7 @@ func (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("categories", categories)
 	view.Set("categories", categories)
 	view.Set("menu", "feeds")
 	view.Set("menu", "feeds")
 	view.Set("user", user)
 	view.Set("user", user)

+ 2 - 4
internal/ui/subscription_submit.go

@@ -16,7 +16,6 @@ import (
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	feedHandler "miniflux.app/v2/internal/reader/handler"
 	"miniflux.app/v2/internal/reader/subscription"
 	"miniflux.app/v2/internal/reader/subscription"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -33,8 +32,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	v := view.New(h.tpl, r, sess)
+	v := view.New(h.tpl, r)
 	v.Set("categories", categories)
 	v.Set("categories", categories)
 	v.Set("menu", "feeds")
 	v.Set("menu", "feeds")
 	v.Set("user", user)
 	v.Set("user", user)
@@ -155,7 +153,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) {
 
 
 		response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 		response.HTMLRedirect(w, r, h.routePath("/feed/%d/entries", feed.ID))
 	case n > 1:
 	case n > 1:
-		view := view.New(h.tpl, r, sess)
+		view := view.New(h.tpl, r)
 		view.Set("subscriptions", subscriptions)
 		view.Set("subscriptions", subscriptions)
 		view.Set("form", subscriptionForm)
 		view.Set("form", subscriptionForm)
 		view.Set("menu", "feeds")
 		view.Set("menu", "feeds")

+ 1 - 3
internal/ui/tag_entries_all.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -44,8 +43,7 @@ func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("tagName", tagName)
 	view.Set("tagName", tagName)
 	view.Set("total", count)
 	view.Set("total", count)
 	view.Set("entries", entries)
 	view.Set("entries", entries)

+ 6 - 4
internal/ui/ui.go

@@ -16,7 +16,9 @@ import (
 // The returned handler expects the base path to be stripped from the request URL.
 // The returned handler expects the base path to be stripped from the request URL.
 func Serve(store *storage.Storage, pool *worker.Pool) http.Handler {
 func Serve(store *storage.Storage, pool *worker.Pool) http.Handler {
 	basePath := config.Opts.BasePath()
 	basePath := config.Opts.BasePath()
-	middleware := newMiddleware(basePath, store)
+	webSessionMiddleware := newWebSessionMiddleware(basePath, store)
+	csrfMiddleware := newCSRFMiddleware(basePath)
+	authProxyMiddleware := newAuthProxyMiddleware(basePath, store)
 
 
 	templateEngine := template.NewEngine(basePath)
 	templateEngine := template.NewEngine(basePath)
 	templateEngine.ParseTemplates()
 	templateEngine.ParseTemplates()
@@ -159,7 +161,7 @@ func Serve(store *storage.Storage, pool *worker.Pool) http.Handler {
 	// Authentication pages.
 	// Authentication pages.
 	mux.HandleFunc("POST /login", handler.checkLogin)
 	mux.HandleFunc("POST /login", handler.checkLogin)
 	mux.HandleFunc("GET /logout", handler.logout)
 	mux.HandleFunc("GET /logout", handler.logout)
-	mux.Handle("GET /{$}", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage)))
+	mux.Handle("GET /{$}", authProxyMiddleware.handle(http.HandlerFunc(handler.showLoginPage)))
 
 
 	// WebAuthn flow.
 	// WebAuthn flow.
 	if config.Opts.WebAuthn() {
 	if config.Opts.WebAuthn() {
@@ -179,6 +181,6 @@ func Serve(store *storage.Storage, pool *worker.Pool) http.Handler {
 		w.Write([]byte("User-agent: *\nDisallow: /"))
 		w.Write([]byte("User-agent: *\nDisallow: /"))
 	})
 	})
 
 
-	// Apply middleware chain: user session then app session.
-	return middleware.handleUserSession(middleware.handleAppSession(mux))
+	// Apply middleware chain: web session -> CSRF validation -> handlers.
+	return webSessionMiddleware.handle(csrfMiddleware.handle(mux))
 }
 }

+ 1 - 3
internal/ui/unread_entries.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -53,8 +52,7 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entries", entries)
 	view.Set("entries", entries)
 	view.Set("pagination", getPagination(h.routePath("/unread"), countUnread, offset, user.EntriesPerPage))
 	view.Set("pagination", getPagination(h.routePath("/unread"), countUnread, offset, user.EntriesPerPage))
 	view.Set("menu", "unread")
 	view.Set("menu", "unread")

+ 1 - 3
internal/ui/unread_entry_category.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -92,8 +91,7 @@ func (h *handler) showUnreadCategoryEntryPage(w http.ResponseWriter, r *http.Req
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/unread_entry_feed.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -92,8 +91,7 @@ func (h *handler) showUnreadFeedEntryPage(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("entry", entry)
 	view.Set("entry", entry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("prevEntry", prevEntry)
 	view.Set("nextEntry", nextEntry)
 	view.Set("nextEntry", nextEntry)

+ 1 - 3
internal/ui/user_create.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -25,8 +24,7 @@ func (h *handler) showCreateUserPage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", &form.UserForm{})
 	view.Set("form", &form.UserForm{})
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/user_edit.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -43,8 +42,7 @@ func (h *handler) showEditUserPage(w http.ResponseWriter, r *http.Request) {
 		IsAdmin:  selectedUser.IsAdmin,
 		IsAdmin:  selectedUser.IsAdmin,
 	}
 	}
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("form", userForm)
 	view.Set("form", userForm)
 	view.Set("selected_user", selectedUser)
 	view.Set("selected_user", selectedUser)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")

+ 1 - 3
internal/ui/user_list.go

@@ -8,7 +8,6 @@ import (
 
 
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -32,8 +31,7 @@ func (h *handler) showUsersPage(w http.ResponseWriter, r *http.Request) {
 
 
 	users.UseTimezone(user.Timezone)
 	users.UseTimezone(user.Timezone)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("users", users)
 	view.Set("users", users)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)

+ 1 - 3
internal/ui/user_save.go

@@ -11,7 +11,6 @@ import (
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/validator"
 	"miniflux.app/v2/internal/validator"
 )
 )
@@ -30,8 +29,7 @@ func (h *handler) saveUser(w http.ResponseWriter, r *http.Request) {
 
 
 	userForm := form.NewUserForm(r)
 	userForm := form.NewUserForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", user)
 	view.Set("user", user)
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(user.ID))

+ 1 - 3
internal/ui/user_update.go

@@ -10,7 +10,6 @@ import (
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -40,8 +39,7 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
 
 
 	userForm := form.NewUserForm(r)
 	userForm := form.NewUserForm(r)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 	view.Set("menu", "settings")
 	view.Set("menu", "settings")
 	view.Set("user", loggedUser)
 	view.Set("user", loggedUser)
 	view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))
 	view.Set("countUnread", h.store.CountUnreadEntries(loggedUser.ID))

+ 14 - 13
internal/ui/view/view.go

@@ -9,7 +9,6 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/template"
 	"miniflux.app/v2/internal/template"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/static"
 	"miniflux.app/v2/internal/ui/static"
 )
 )
 
 
@@ -32,18 +31,20 @@ func (v *view) Render(template string) []byte {
 }
 }
 
 
 // New returns a new view with default parameters.
 // New returns a new view with default parameters.
-func New(tpl *template.Engine, r *http.Request, sess *session.Session) *view {
-	theme := request.UserTheme(r)
+func New(tpl *template.Engine, r *http.Request) *view {
+	webSession := request.WebSession(r)
+	theme := webSession.Theme()
+	successMessage, errorMessage := webSession.ConsumeMessages()
 	return &view{tpl, r, map[string]any{
 	return &view{tpl, r, map[string]any{
-		"menu":              "",
-		"csrf":              request.CSRF(r),
-		"flashMessage":      sess.FlashMessage(request.FlashMessage(r)),
-		"flashErrorMessage": sess.FlashErrorMessage(request.FlashErrorMessage(r)),
-		"theme":             theme,
-		"language":          request.UserLanguage(r),
-		"theme_checksum":    static.StylesheetBundles[theme+".css"].Checksum,
-		"app_js_checksum":   static.JavascriptBundles["app.js"].Checksum,
-		"sw_js_checksum":    static.JavascriptBundles["service-worker.js"].Checksum,
-		"webAuthnEnabled":   config.Opts.WebAuthn(),
+		"menu":            "",
+		"csrf":            webSession.CSRF(),
+		"successMessage":  successMessage,
+		"errorMessage":    errorMessage,
+		"theme":           theme,
+		"language":        webSession.Language(),
+		"theme_checksum":  static.StylesheetBundles[theme+".css"].Checksum,
+		"app_js_checksum": static.JavascriptBundles["app.js"].Checksum,
+		"sw_js_checksum":  static.JavascriptBundles["service-worker.js"].Checksum,
+		"webAuthnEnabled": config.Opts.WebAuthn(),
 	}}
 	}}
 }
 }

+ 91 - 0
internal/ui/web_session_middleware.go

@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ui // import "miniflux.app/v2/internal/ui"
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/http/response"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/storage"
+)
+
+type webSessionMiddleware struct {
+	basePath string
+	store    *storage.Storage
+}
+
+func newWebSessionMiddleware(basePath string, store *storage.Storage) *webSessionMiddleware {
+	return &webSessionMiddleware{basePath: basePath, store: store}
+}
+
+func (m *webSessionMiddleware) handle(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if isStaticAssetRoute(r) {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		session, err := m.loadWebSessionFromCookie(r)
+		if err != nil {
+			response.HTMLServerError(w, r, err)
+			return
+		}
+		if session == nil {
+			var secret string
+			session, secret = model.NewWebSession(r.UserAgent(), request.ClientIP(r))
+			if err := m.store.CreateWebSession(session); err != nil {
+				response.HTMLServerError(w, r, err)
+				return
+			}
+			setSessionCookie(w, session, secret)
+		}
+
+		ctx := context.WithValue(r.Context(), request.WebSessionContextKey, session)
+		r = r.WithContext(ctx)
+
+		if !request.IsAuthenticated(r) && !isPublicRoute(r) {
+			response.HTMLRedirect(w, r, loginRedirectURL(m.basePath, r.RequestURI))
+			return
+		}
+
+		next.ServeHTTP(w, r)
+
+		if session.IsDirty() {
+			if err := m.store.UpdateWebSession(session); err != nil {
+				slog.Error("Unable to persist web session changes",
+					slog.String("session_id", session.ID),
+					slog.Any("error", err),
+				)
+			}
+		}
+	})
+}
+
+func (m *webSessionMiddleware) loadWebSessionFromCookie(r *http.Request) (*model.WebSession, error) {
+	cookieValue := request.CookieValue(r, sessionCookieName)
+	if cookieValue == "" {
+		return nil, nil
+	}
+
+	sessionID, secret, ok := strings.Cut(cookieValue, ".")
+	if !ok || sessionID == "" || secret == "" {
+		return nil, nil
+	}
+
+	session, err := m.store.WebSessionByID(sessionID)
+	if err != nil {
+		return nil, err
+	}
+
+	if session == nil || !session.VerifySecret(secret) {
+		return nil, nil
+	}
+
+	return session, nil
+}

+ 21 - 29
internal/ui/webauthn.go

@@ -6,6 +6,7 @@ package ui // import "miniflux.app/v2/internal/ui"
 import (
 import (
 	"bytes"
 	"bytes"
 	"encoding/hex"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"fmt"
 	"log/slog"
 	"log/slog"
 	"net/http"
 	"net/http"
@@ -16,13 +17,11 @@ import (
 
 
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/crypto"
 	"miniflux.app/v2/internal/crypto"
-	"miniflux.app/v2/internal/http/cookie"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 
 
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/ui/form"
 	"miniflux.app/v2/internal/ui/form"
-	"miniflux.app/v2/internal/ui/session"
 	"miniflux.app/v2/internal/ui/view"
 	"miniflux.app/v2/internal/ui/view"
 )
 )
 
 
@@ -112,8 +111,7 @@ func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) {
 		response.JSONServerError(w, r, err)
 		response.JSONServerError(w, r, err)
 		return
 		return
 	}
 	}
-	s := session.New(h.store, request.SessionID(r))
-	s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})
+	request.WebSession(r).SetWebAuthn(sessionData)
 	response.JSON(w, r, options)
 	response.JSON(w, r, options)
 }
 }
 
 
@@ -133,9 +131,13 @@ func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) {
 		response.JSONServerError(w, r, err)
 		response.JSONServerError(w, r, err)
 		return
 		return
 	}
 	}
-	sessionData := request.WebAuthnSessionData(r)
+	sessionData := request.WebSession(r).ConsumeWebAuthnSession()
+	if sessionData == nil {
+		response.JSONBadRequest(w, r, errors.New("missing webauthn session data"))
+		return
+	}
 	webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil}
 	webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil}
-	cred, err := web.FinishRegistration(webAuthnUser, *sessionData.SessionData, r)
+	cred, err := web.FinishRegistration(webAuthnUser, *sessionData, r)
 	if err != nil {
 	if err != nil {
 		response.JSONServerError(w, r, err)
 		response.JSONServerError(w, r, err)
 		return
 		return
@@ -190,8 +192,7 @@ func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	s := session.New(h.store, request.SessionID(r))
-	s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})
+	request.WebSession(r).SetWebAuthn(sessionData)
 	response.JSON(w, r, assertion)
 	response.JSON(w, r, assertion)
 }
 }
 
 
@@ -216,7 +217,11 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 		slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),
 		slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),
 	)
 	)
 
 
-	sessionData := request.WebAuthnSessionData(r)
+	sessionData := request.WebSession(r).ConsumeWebAuthnSession()
+	if sessionData == nil {
+		response.JSONBadRequest(w, r, errors.New("missing webauthn session data"))
+		return
+	}
 
 
 	var user *model.User
 	var user *model.User
 	username := request.QueryStringParam(r, "username", "")
 	username := request.QueryStringParam(r, "username", "")
@@ -255,7 +260,7 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 			)
 			)
 		}
 		}
 
 
-		credCredential, err := web.ValidateLogin(webAuthUser, *sessionData.SessionData, parsedResponse)
+		credCredential, err := web.ValidateLogin(webAuthUser, *sessionData, parsedResponse)
 		if err != nil {
 		if err != nil {
 			slog.Warn("WebAuthn: ValidateLogin failed", slog.Any("error", err))
 			slog.Warn("WebAuthn: ValidateLogin failed", slog.Any("error", err))
 			response.JSONUnauthorized(w, r)
 			response.JSONUnauthorized(w, r)
@@ -298,7 +303,7 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 			return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil
 			return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil
 		}
 		}
 
 
-		_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse)
+		_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData, parsedResponse)
 		if err != nil {
 		if err != nil {
 			slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.Any("error", err))
 			slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.Any("error", err))
 			response.JSONUnauthorized(w, r)
 			response.JSONUnauthorized(w, r)
@@ -306,12 +311,6 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
-	sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), request.ClientIP(r))
-	if err != nil {
-		response.JSONServerError(w, r, err)
-		return
-	}
-
 	h.store.WebAuthnSaveLogin(matchingCredential.Handle)
 	h.store.WebAuthnSaveLogin(matchingCredential.Handle)
 
 
 	slog.Info("User authenticated successfully with webauthn",
 	slog.Info("User authenticated successfully with webauthn",
@@ -323,23 +322,16 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
 	)
 	)
 	h.store.SetLastLogin(user.ID)
 	h.store.SetLastLogin(user.ID)
 
 
-	sess := session.New(h.store, request.SessionID(r))
-	sess.SetLanguage(user.Language)
-	sess.SetTheme(user.Theme)
-
-	http.SetCookie(w, cookie.New(
-		cookie.CookieUserSessionID,
-		sessionToken,
-		config.Opts.HTTPS(),
-		config.Opts.BasePath(),
-	))
+	if err := authenticateWebSession(w, r, h.store, user); err != nil {
+		response.JSONServerError(w, r, err)
+		return
+	}
 
 
 	response.NoContent(w, r)
 	response.NoContent(w, r)
 }
 }
 
 
 func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) {
 func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) {
-	sess := session.New(h.store, request.SessionID(r))
-	view := view.New(h.tpl, r, sess)
+	view := view.New(h.tpl, r)
 
 
 	user, err := h.store.UserByID(request.UserID(r))
 	user, err := h.store.UserByID(request.UserID(r))
 	if err != nil {
 	if err != nil {