Commit 219c9e06 by Orrin Committed by GitHub

优化匿名冷启动与公开内容接口的重复回源请求 (#7166)

* fix: reduce public bootstrap requests and revalidate content

* fix(controller): use a weak ETag for revalidated public JSON

/api is gzip-compressed by middleware that runs after the handler returns,
and the validator is computed over the uncompressed body. The compressed and
identity forms of one payload therefore share a validator, which a strong ETag
must not do -- it asserts byte-for-byte equality across representations
(RFC 9110 8.8.1). Serve W/ instead.

Weak comparison ignores W/ on both operands, so etagMatches now strips it from
the served validator as well as from each candidate. Stripping only the
candidate would make a weak served validator match nothing and silently
disable every 304.

Vary: Accept-Encoding stays. Weakening the validator makes revalidation
correct, but it does not separate the two encodings in a shared cache.

* fix(test): align response cookie helper name

* fix(auth): revalidate stale route sessions

* Update web/src/features/about/api.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: remove newly added PR tests

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent 057f71c2
...@@ -176,42 +176,40 @@ func GetStatus(c *gin.Context) { ...@@ -176,42 +176,40 @@ func GetStatus(c *gin.Context) {
func GetNotice(c *gin.Context) { func GetNotice(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock() notice := common.OptionMap["Notice"]
c.JSON(http.StatusOK, gin.H{ common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": common.OptionMap["Notice"], "data": notice,
}) })
return
} }
func GetAbout(c *gin.Context) { func GetAbout(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock() about := common.OptionMap["About"]
c.JSON(http.StatusOK, gin.H{ common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": common.OptionMap["About"], "data": about,
}) })
return
} }
func GetUserAgreement(c *gin.Context) { func GetUserAgreement(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ serveRevalidatedJSON(c, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": system_setting.GetLegalSettings().UserAgreement, "data": system_setting.GetLegalSettings().UserAgreement,
}) })
return
} }
func GetPrivacyPolicy(c *gin.Context) { func GetPrivacyPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ serveRevalidatedJSON(c, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy, "data": system_setting.GetLegalSettings().PrivacyPolicy,
}) })
return
} }
func GetMidjourney(c *gin.Context) { func GetMidjourney(c *gin.Context) {
...@@ -227,13 +225,13 @@ func GetMidjourney(c *gin.Context) { ...@@ -227,13 +225,13 @@ func GetMidjourney(c *gin.Context) {
func GetHomePageContent(c *gin.Context) { func GetHomePageContent(c *gin.Context) {
common.OptionMapRWMutex.RLock() common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock() homePageContent := common.OptionMap["HomePageContent"]
c.JSON(http.StatusOK, gin.H{ common.OptionMapRWMutex.RUnlock()
serveRevalidatedJSON(c, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": common.OptionMap["HomePageContent"], "data": homePageContent,
}) })
return
} }
func SendEmailVerification(c *gin.Context) { func SendEmailVerification(c *gin.Context) {
......
package controller
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// serveRevalidatedJSON writes payload as JSON with a weak content-derived ETag
// and answers conditional requests with 304 Not Modified.
//
// Intended for small, public, admin-editable payloads (notice, home page
// content) that every anonymous visitor fetches on page load. The goal is to
// make those fetches cheap without ever serving stale content:
//
// - The ETag is a hash of the response body, so it is identical across
// replicas. Deriving it from a timestamp would not be, and the Option table
// has no updated_at column to derive one from anyway.
// - The validator is weak (W/ prefixed) because /api is gzip-compressed by
// middleware that runs after this handler returns. The hash is computed over
// the uncompressed body, so the compressed and identity forms of one payload
// share a validator, and a strong ETag asserts byte-for-byte equality that
// does not hold across encodings (RFC 9110 §8.8.1). Weakening it costs
// nothing here: conditional GET compares weakly anyway, and these payloads
// are a few hundred bytes of JSON that no client Range-requests.
// - Cache-Control is "no-cache", which means "may be stored, but must be
// revalidated before reuse" (RFC 9111 §5.2.2.4). Browsers and CDNs both
// revalidate on every request, so an admin edit takes effect immediately.
// max-age/s-maxage are deliberately not set: upstream cannot assume how
// long any given deployment tolerates a stale notice.
// - Vary: Accept-Encoding is still required. Weakening the validator makes
// revalidation correct, but it does not separate the two encodings in a
// shared cache. Without Vary, a cache holding the gzip copy would hand those
// bytes to a client that never sent Accept-Encoding: gzip.
func serveRevalidatedJSON(c *gin.Context, payload any) {
body, err := common.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": err.Error(),
})
return
}
digest := sha256.Sum256(body)
etag := `W/"` + hex.EncodeToString(digest[:]) + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "no-cache")
c.Header("Vary", "Accept-Encoding")
if etagMatches(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// etagMatches reports whether an If-None-Match header field matches etag,
// using the weak comparison required for conditional GET (RFC 9110 §13.1.2).
// The field is a comma-separated list of entity-tags or the wildcard "*".
//
// Weak comparison ignores the W/ prefix on both operands, so it must be
// stripped from the served etag as well as from each candidate. Stripping only
// the candidate would make a weak served validator match nothing, silently
// disabling 304 responses.
func etagMatches(ifNoneMatch string, etag string) bool {
ifNoneMatch = strings.TrimSpace(ifNoneMatch)
if ifNoneMatch == "" {
return false
}
if ifNoneMatch == "*" {
return true
}
etag = strings.TrimPrefix(etag, "W/")
for _, candidate := range strings.Split(ifNoneMatch, ",") {
if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
return true
}
}
return false
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
- Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer <token>` 发送。 - Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer <token>` 发送。
- Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly``SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。 - Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly``SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。
- `new_api_has_session` 是 Refresh Cookie 的会话提示,值恒为 `1``Path=/`、非 `HttpOnly`,与 Refresh Cookie 同时写入、同时清除、同一过期时间。它只声明"曾签发过 Refresh Cookie",不含任何凭据,也不参与任何鉴权判定;伪造它唯一的效果是自费一次注定失败的 refresh。它存在的原因是 Refresh Cookie 被 `HttpOnly``Path=/api/user/auth` 双重限制,`/` 上的页面无法判断自己是否匿名,否则每次冷启动都要发一次注定 401 的 refresh,而该请求还会占用按 IP 计数的 `CriticalRateLimit` 配额。
- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。数据库中的 Session 状态是最终权威;撤销传播速度取决于下文所述的 Redis 拓扑。 - `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。数据库中的 Session 状态是最终权威;撤销传播速度取决于下文所述的 Redis 拓扑。
- 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。 - 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。
- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;Session 快照使用跟随 `SYNC_FREQUENCY` 的短 TTL,缓存未命中或未启用 Redis 时回退到数据库校验。 - Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;Session 快照使用跟随 `SYNC_FREQUENCY` 的短 TTL,缓存未命中或未启用 Redis 时回退到数据库校验。
...@@ -70,6 +71,8 @@ ...@@ -70,6 +71,8 @@
前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。 前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。
公开页面的冷启动会先读 `new_api_has_session`:提示不存在且内存中没有任何身份时跳过 refresh,直接按匿名渲染,且**不**把这次跳过记为已完成的匿名判定——跳过只是延后,不是服务端结论。会依据鉴权结果做跳转的位置(受保护路由与登录页)不看提示,内存为空时一律回源。因此提示缺失但 Refresh Cookie 有效的用户(该 Cookie 上线前建立的会话,或只清理了 `/` 站点数据的浏览器)会在公开页显示为匿名,并在进入上述任一位置时自动恢复登录态,不需要重新输入密码。提示因服务端撤销而过期时,那次 refresh 返回 401 并在同一响应里清除提示,浪费的请求只发生一次。
## Session 签发限额与保留策略 ## Session 签发限额与保留策略
服务端在所有登录方式的统一 Session 签发出口执行两级账户限制: 服务端在所有登录方式的统一 Session 签发出口执行两级账户限制:
......
...@@ -15,6 +15,13 @@ import ( ...@@ -15,6 +15,13 @@ import (
const RefreshCookieName = "new_api_refresh" const RefreshCookieName = "new_api_refresh"
// SessionHintCookieName is the script-readable companion to RefreshCookieName.
// See writeSessionHintCookie for why it exists and what it is not.
const SessionHintCookieName = "new_api_has_session"
// SessionHintCookieValue is the only value the hint ever carries.
const SessionHintCookieValue = "1"
var ( var (
ErrLoginSessionInvalid = errors.New("login session is invalid") ErrLoginSessionInvalid = errors.New("login session is invalid")
ErrLoginSessionRevoked = errors.New("login session is revoked") ErrLoginSessionRevoked = errors.New("login session is revoked")
...@@ -310,6 +317,7 @@ func WriteRefreshCookie(c *gin.Context, rawToken string) { ...@@ -310,6 +317,7 @@ func WriteRefreshCookie(c *gin.Context, rawToken string) {
Secure: common.SessionCookieSecure, Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
writeSessionHintCookie(c, maxAge, expiresAt)
} }
func ClearRefreshCookie(c *gin.Context) { func ClearRefreshCookie(c *gin.Context) {
...@@ -323,6 +331,50 @@ func ClearRefreshCookie(c *gin.Context) { ...@@ -323,6 +331,50 @@ func ClearRefreshCookie(c *gin.Context) {
Secure: common.SessionCookieSecure, Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
clearSessionHintCookie(c)
}
// writeSessionHintCookie mirrors the Refresh Cookie's lifetime with a
// script-readable marker. The Refresh Cookie itself is HttpOnly and scoped to
// /api/user/auth, so a page at / cannot tell whether a login session exists;
// without this hint the frontend has to POST /api/user/auth/refresh on every
// cold boot just to learn that an anonymous visitor is anonymous. That request
// is guaranteed to 401 and still consumes a slot of the IP-keyed
// CriticalRateLimit budget shared by everyone behind the same address.
//
// The value is the constant "1" and carries no credential: it states that a
// Refresh Cookie was issued, never who for. Authorization still derives solely
// from the Refresh Cookie and the Access Token, so forging this hint only costs
// the forger the round trip it was meant to avoid.
//
// It must be written and cleared in lockstep with the Refresh Cookie, which is
// why it lives inside these two helpers rather than at their call sites: both
// cookies then ride the same response with the same expiry, and no login path
// can set one without the other.
func writeSessionHintCookie(c *gin.Context, maxAge int, expiresAt time.Time) {
http.SetCookie(c.Writer, &http.Cookie{
Name: SessionHintCookieName,
Value: SessionHintCookieValue,
Path: "/",
MaxAge: maxAge,
Expires: expiresAt,
HttpOnly: false,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionHintCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: SessionHintCookieName,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(1, 0),
HttpOnly: false,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
} }
func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) { func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) {
......
...@@ -20,7 +20,12 @@ import { api } from '@/lib/api' ...@@ -20,7 +20,12 @@ import { api } from '@/lib/api'
import type { AboutResponse } from './types' import type { AboutResponse } from './types'
export async function getAboutContent() { export async function getAboutContent(): Promise<AboutResponse> {
const res = await api.get<AboutResponse>('/api/about') // See getNotice in @/lib/api: the global `Cache-Control: no-store` is dropped
// so the browser can hold an ETag and revalidate, letting the server answer
// 304. Server-side `no-cache` keeps admin edits immediate.
const res = await api.get<AboutResponse>('/api/about', {
headers: { 'Cache-Control': null },
})
return res.data return res.data
} }
...@@ -29,6 +29,11 @@ import type { HomePageContentResponse } from './types' ...@@ -29,6 +29,11 @@ import type { HomePageContentResponse } from './types'
* Returns Markdown/HTML content or iframe URL * Returns Markdown/HTML content or iframe URL
*/ */
export async function getHomePageContent(): Promise<HomePageContentResponse> { export async function getHomePageContent(): Promise<HomePageContentResponse> {
const res = await api.get('/api/home_page_content') // See getNotice in @/lib/api: the global `Cache-Control: no-store` is dropped
// so the browser can hold an ETag and revalidate, letting the server answer
// 304. Server-side `no-cache` keeps admin edits immediate.
const res = await api.get('/api/home_page_content', {
headers: { 'Cache-Control': null },
})
return res.data return res.data
} }
...@@ -20,12 +20,21 @@ import { api } from '@/lib/api' ...@@ -20,12 +20,21 @@ import { api } from '@/lib/api'
import type { LegalDocumentResponse } from './types' import type { LegalDocumentResponse } from './types'
export async function getUserAgreement() { // Both documents drop the client's global `Cache-Control: no-store` for the
const res = await api.get<LegalDocumentResponse>('/api/user-agreement') // same reason as getNotice in @/lib/api: `no-store` stops the browser from
// keeping a copy, so it would never hold an ETag to revalidate with and the
// server could never answer 304. These are the largest payloads in this family
// and are re-fetched on every sign-up, so the saving is the most visible here.
export async function getUserAgreement(): Promise<LegalDocumentResponse> {
const res = await api.get<LegalDocumentResponse>('/api/user-agreement', {
headers: { 'Cache-Control': null },
})
return res.data return res.data
} }
export async function getPrivacyPolicy() { export async function getPrivacyPolicy(): Promise<LegalDocumentResponse> {
const res = await api.get<LegalDocumentResponse>('/api/privacy-policy') const res = await api.get<LegalDocumentResponse>('/api/privacy-policy', {
headers: { 'Cache-Control': null },
})
return res.data return res.data
} }
...@@ -28,6 +28,7 @@ export { ...@@ -28,6 +28,7 @@ export {
getFreshAuthHeaders, getFreshAuthHeaders,
isAuthBundle, isAuthBundle,
refreshAuthentication, refreshAuthentication,
resolveAuthentication,
AuthRotationError, AuthRotationError,
} from '@/lib/auth-session' } from '@/lib/auth-session'
export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session' export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session'
...@@ -77,7 +78,14 @@ export async function getNotice(): Promise<{ ...@@ -77,7 +78,14 @@ export async function getNotice(): Promise<{
message?: string message?: string
data?: string data?: string
}> { }> {
const res = await api.get('/api/notice') // Drop the client's global `Cache-Control: no-store` for this public,
// non-user-specific payload. `no-store` forbids the browser from keeping a
// copy at all, so it would never hold an ETag to revalidate with and the
// server could never answer 304. The server sends `no-cache`, so the browser
// still revalidates on every request and an admin edit shows up immediately.
const res = await api.get('/api/notice', {
headers: { 'Cache-Control': null },
})
return res.data return res.data
} }
......
...@@ -21,6 +21,7 @@ import axios from 'axios' ...@@ -21,6 +21,7 @@ import axios from 'axios'
import { t } from 'i18next' import { t } from 'i18next'
import { publishAuthSessionEvent } from '@/lib/auth-session-sync' import { publishAuthSessionEvent } from '@/lib/auth-session-sync'
import { hasSessionHint } from '@/lib/session-hint'
import { import {
useAuthStore, useAuthStore,
type AuthBootstrapState, type AuthBootstrapState,
...@@ -360,7 +361,15 @@ function currentValidAuthBundle(): AuthBundle | null { ...@@ -360,7 +361,15 @@ function currentValidAuthBundle(): AuthBundle | null {
} }
} }
export async function bootstrapAuthentication(): Promise<RefreshOutcome> { /**
* Resolve authentication from memory, or from the server when memory is empty.
*
* Use this wherever the answer decides what the user sees: route guards that
* redirect on the result, and the sign-in page. It contacts the server on a
* cold cache even when no session hint is present, so a usable Refresh Cookie
* is always honoured.
*/
export async function resolveAuthentication(): Promise<RefreshOutcome> {
const bundle = currentValidAuthBundle() const bundle = currentValidAuthBundle()
if (bundle) { if (bundle) {
useAuthStore.getState().auth.setBootstrapState('complete') useAuthStore.getState().auth.setBootstrapState('complete')
...@@ -377,6 +386,26 @@ export async function bootstrapAuthentication(): Promise<RefreshOutcome> { ...@@ -377,6 +386,26 @@ export async function bootstrapAuthentication(): Promise<RefreshOutcome> {
return refreshAuthentication() return refreshAuthentication()
} }
/**
* Resolve authentication on the public boot path, skipping a refresh that the
* server's session hint says would fail.
*
* The skip leaves `bootstrapState` at `idle` rather than `complete`: a missing
* hint is not a server verdict, so it must not be recorded as a finished
* anonymous check. `resolveAuthentication` therefore still reaches the network
* later, which is what lets a hintless visitor holding a valid Refresh Cookie
* recover the moment authentication actually matters.
*/
export async function bootstrapAuthentication(): Promise<RefreshOutcome> {
if (!currentValidAuthBundle() && !hasSessionHint()) {
const auth = useAuthStore.getState().auth
if (!auth.user && !auth.session) {
return { kind: 'anonymous' }
}
}
return resolveAuthentication()
}
export function getCommonHeaders(): Record<string, string> { export function getCommonHeaders(): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* Detection of the server's login-session hint cookie.
*
* The Refresh Cookie is `HttpOnly` and scoped to `/api/user/auth`, so a page at
* `/` cannot read it and cannot tell an anonymous visitor from a returning one.
* The server therefore writes `new_api_has_session=1` alongside it — same
* expiry, `Path=/`, not `HttpOnly` — purely so the frontend can skip a refresh
* that is certain to fail.
*
* This is an optimization, never an authorization signal. A present hint means
* "a Refresh Cookie was issued at some point"; it can be stale after a
* server-side revocation, and it can be absent while a usable Refresh Cookie
* still exists (a visitor who cleared site data for `/` only, or any session
* created before this cookie shipped). Callers must treat a missing hint as
* "not worth a request right now", not as "signed out", and must still be able
* to reach the server when authentication actually matters.
*/
export const SESSION_HINT_COOKIE_NAME = 'new_api_has_session'
/** Read a cookie value out of a `document.cookie`-shaped string. */
export function readCookie(cookieHeader: string, name: string): string | null {
for (const part of cookieHeader.split(';')) {
const separator = part.indexOf('=')
if (separator < 0) continue
if (part.slice(0, separator).trim() !== name) continue
return part.slice(separator + 1).trim()
}
return null
}
/**
* Whether the server currently claims a login session exists.
*
* Returns `true` when the hint cannot be read at all (no `document`, as in SSR
* or a non-DOM test environment). An unreadable hint is not evidence of
* absence, and the safe direction is to let the refresh proceed.
*/
export function hasSessionHint(): boolean {
if (typeof document === 'undefined') return true
return (
readCookie(document.cookie, SESSION_HINT_COOKIE_NAME) !== null
)
}
...@@ -21,6 +21,7 @@ import { z } from 'zod' ...@@ -21,6 +21,7 @@ import { z } from 'zod'
import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect' import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect'
import { SignIn } from '@/features/auth/sign-in' import { SignIn } from '@/features/auth/sign-in'
import { resolveAuthentication } from '@/lib/auth-session'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
const searchSchema = z.object({ const searchSchema = z.object({
...@@ -31,6 +32,10 @@ export const Route = createFileRoute('/(auth)/sign-in')({ ...@@ -31,6 +32,10 @@ export const Route = createFileRoute('/(auth)/sign-in')({
component: SignIn, component: SignIn,
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
// 根 guard 可能因为没有会话提示而跳过了 refresh。此处必须回源确认,
// 否则持有有效 Refresh Cookie 的用户会被要求重新输入密码。
await resolveAuthentication()
const { auth } = useAuthStore.getState() const { auth } = useAuthStore.getState()
// 如果已经有用户信息,说明已登录 // 如果已经有用户信息,说明已登录
......
...@@ -19,10 +19,17 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,10 +19,17 @@ For commercial licensing, please contact support@quantumnous.com
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { AuthenticatedLayout } from '@/components/layout' import { AuthenticatedLayout } from '@/components/layout'
import { resolveAuthentication } from '@/lib/auth-session'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ location }) => { beforeLoad: async ({ location }) => {
// The root guard may have skipped its refresh because no session hint was
// present. That skip is an optimization for public pages and must not
// decide a protected route, so resolve against the server before
// redirecting. An in-memory session returns without a request.
await resolveAuthentication()
const { auth } = useAuthStore.getState() const { auth } = useAuthStore.getState()
if (!auth.user || !auth.accessToken) { if (!auth.user || !auth.accessToken) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment