fix(leonardo): 连续 3 次鉴权失败才判死,get-session 补齐浏览器头并保护 session_data
This commit is contained in:
@@ -30,6 +30,9 @@ const (
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.255.2"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
// sec-ch-ua must agree with userAgent's major version — a mismatch is itself a
|
||||
// bot signal.
|
||||
secChUA = `"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"`
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -137,6 +140,14 @@ func mergeCookies(cookie string, setCookies []string) string {
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// keepsSession reports whether a merged cookie still carries BOTH components
|
||||
// get-session needs: the session token and better-auth's session_data cache.
|
||||
// A merge that loses either one (a Set-Cookie clearing a cache chunk) must be
|
||||
// discarded — sending it would answer 200 null, i.e. look like a dead account.
|
||||
func keepsSession(cookie string) bool {
|
||||
return strings.Contains(cookie, "__Secure-better-auth.session_token") && HasSessionData(cookie)
|
||||
}
|
||||
|
||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
||||
// and the CDN image path) and the human-facing account fields.
|
||||
@@ -187,19 +198,30 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
// Header set/order copied from a real browser's get-session call (HAR): a
|
||||
// same-origin GET carries NO origin header and DOES carry the ua client hints
|
||||
// + priority — sending origin while omitting the hints is exactly the shape
|
||||
// Vercel's checkpoint 429s.
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cache-control": {"no-cache"},
|
||||
"cookie": {send},
|
||||
"origin": {appBase},
|
||||
"pragma": {"no-cache"},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cookie", "origin", "referer",
|
||||
"user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
"accept", "accept-language", "cache-control", "cookie", "pragma",
|
||||
"priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode",
|
||||
"sec-fetch-site", "user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
@@ -208,14 +230,20 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send {
|
||||
// Only a real app answer may rotate the stored cookie. The 403/429 人机校验 页
|
||||
// also sends Set-Cookie (often CLEARING better-auth cookies), and persisting
|
||||
// that would strip the session_data cache — after which get-session answers
|
||||
// 200 null and a perfectly healthy account looks dead.
|
||||
if resp.StatusCode == 200 {
|
||||
if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send && keepsSession(merged) {
|
||||
c.mu.Lock()
|
||||
c.rotated[cookie] = merged
|
||||
c.mu.Unlock()
|
||||
send = merged
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return nil, ErrAuth
|
||||
return nil, fmt.Errorf("%w: get-session http 401: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||
@@ -240,8 +268,10 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
||||
// No bearer despite 200 → the cookie no longer authenticates.
|
||||
return nil, ErrAuth
|
||||
// No bearer despite 200 → the cookie no longer authenticates. Carry the body
|
||||
// so the log says WHICH shape it was (null session vs a session without a
|
||||
// token) instead of a bare "auth failed".
|
||||
return nil, fmt.Errorf("%w: get-session 200 without accessToken: %s", ErrAuth, clip(body, 160))
|
||||
}
|
||||
uid := raw.Session.UserID
|
||||
if uid == "" {
|
||||
@@ -278,6 +308,14 @@ func (c *Client) session(ctx context.Context, cookie string, force bool) (*Sessi
|
||||
return c.GetSession(ctx, cookie)
|
||||
}
|
||||
|
||||
// ProbeSession force-mints a session from the cookie, bypassing the cached
|
||||
// bearer. Callers use it to double-check an auth failure before killing an
|
||||
// account: a rejected bearer (rotation race / expired token) still yields a
|
||||
// working cookie here, only a genuinely dead cookie returns ErrAuth.
|
||||
func (c *Client) ProbeSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
return c.session(ctx, cookie, true)
|
||||
}
|
||||
|
||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
||||
@@ -411,7 +449,11 @@ func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byt
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"origin": {appBase},
|
||||
"priority": {"u=1, i"},
|
||||
"referer": {appBase + "/"},
|
||||
"sec-ch-ua": {secChUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"user-agent": {userAgent},
|
||||
"authorization": {"Bearer " + accessToken},
|
||||
"x-leo-schema-version": {schemaVersion},
|
||||
@@ -419,7 +461,8 @@ func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byt
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
http.HeaderOrderKey: {
|
||||
"content-type", "accept", "accept-language", "origin", "referer",
|
||||
"content-type", "accept", "accept-language", "origin", "priority",
|
||||
"referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
|
||||
"user-agent", "authorization", "x-leo-schema-version",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
|
||||
@@ -1114,6 +1114,24 @@ func (s *TokenService) ImportCustomAccount(ctx context.Context, baseURL, apiKey,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// leonardoAuthStrikeLimit 是 leonardo 号被判死前允许的连续鉴权失败次数。上游偶发
|
||||
// 返回 200 null / 401(cookie 轮换竞态、人机校验)时一次就判死会误杀健康号,所以要
|
||||
// 连续失败到这个次数才判死;任何一次成功都会清零。
|
||||
const leonardoAuthStrikeLimit = 3
|
||||
|
||||
// leonardoAuthStrike 记一次鉴权失败:返回要写回的 meta、当前连续失败次数,以及是否
|
||||
// 该判死。
|
||||
func leonardoAuthStrike(item *model.TokenAccount, reason string) (datatypes.JSONMap, int, bool) {
|
||||
meta := cloneJSONMap(item.Meta)
|
||||
strikes := 1
|
||||
if n, ok := jsonMapInt(item.Meta, "auth_fails"); ok {
|
||||
strikes = n + 1
|
||||
}
|
||||
meta["auth_fails"] = strikes
|
||||
meta["last_auth_error"] = reason
|
||||
return meta, strikes, strikes >= leonardoAuthStrikeLimit
|
||||
}
|
||||
|
||||
// finishPending writes the terminal status/dead flag and clears the pending_check
|
||||
// marker (merging any cached quota) for a background import probe.
|
||||
func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) {
|
||||
@@ -1469,17 +1487,23 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
s.persistLeonardoCookie(ctx, item.ID, item.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, leonardo.ErrAuth) {
|
||||
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
"fails": gorm.Expr("fails + 1"),
|
||||
})
|
||||
meta, strikes, kill := leonardoAuthStrike(item, "quota refresh: "+err.Error())
|
||||
patch := map[string]any{"meta": meta, "fails": gorm.Expr("fails + 1")}
|
||||
if kill {
|
||||
patch["status"] = "disabled"
|
||||
patch["dead"] = true
|
||||
log.Printf("account leonardo/%s disabled after %d consecutive auth failures: %v", item.ID, strikes, err)
|
||||
} else {
|
||||
log.Printf("leonardo %s: auth failure %d/%d on quota refresh (%v) — kept active", item.ID, strikes, leonardoAuthStrikeLimit, err)
|
||||
}
|
||||
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, patch)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
patch := map[string]any{}
|
||||
meta := cloneJSONMap(item.Meta)
|
||||
meta["cached_quota_at"] = int(time.Now().Unix())
|
||||
meta["auth_fails"] = 0 // cookie 还能换 token,连续失败计数清零
|
||||
if remaining, ok := data["remaining"].(int); ok {
|
||||
meta["cached_quota_remaining"] = remaining
|
||||
// Below the per-generation floor → sink to "限额" so it stops being
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -3762,9 +3763,30 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
|
||||
// grok is intentionally excluded: a grok sso can momentarily 401 while
|
||||
// still valid (upstream blip / proxy / anti-bot), so an auth failure just
|
||||
// fails over for this request without permanently killing the account.
|
||||
if pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" {
|
||||
disable := pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine"
|
||||
if disable && pool == "leonardo" {
|
||||
// 两道保险:先重新 get-session 复核(单次失败常是 bearer 轮换竞态),复核
|
||||
// 也不过就只记一次连续失败,连续到上限才判死。
|
||||
if s.leonardoCookieAlive(ctx, token) {
|
||||
log.Printf("leonardo %s: auth failure on %s but cookie still authenticates — kept active", token.ID, kind)
|
||||
disable = false
|
||||
} else {
|
||||
meta, strikes, kill := leonardoAuthStrike(&token, "auth failure on "+kind)
|
||||
patch["meta"] = meta
|
||||
disable = kill
|
||||
if kill {
|
||||
log.Printf("account leonardo/%s disabled after %d consecutive auth failures: %s", token.ID, strikes, kind)
|
||||
} else {
|
||||
log.Printf("leonardo %s: auth failure %d/%d on %s — kept active", token.ID, strikes, leonardoAuthStrikeLimit, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
if disable {
|
||||
patch["status"] = "disabled"
|
||||
patch["dead"] = true
|
||||
if pool != "leonardo" {
|
||||
log.Printf("account %s/%s disabled: auth failure on %s", pool, token.ID, kind)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Neither pool is auto-disabled on generic (non-auth / non-quota) failures
|
||||
@@ -3775,6 +3797,24 @@ func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token mod
|
||||
_, _ = s.tokens.Update(ctx, pool, token.ID, patch)
|
||||
}
|
||||
|
||||
// leonardoCookieAlive re-checks a Leonardo cookie after an auth failure by
|
||||
// force-minting a session (bypassing the cached bearer). Only a cookie that
|
||||
// still fails to authenticate counts as dead; a temporary upstream answer
|
||||
// (403/429 人机校验) also keeps the account alive.
|
||||
func (s *V1Service) leonardoCookieAlive(ctx context.Context, token model.TokenAccount) bool {
|
||||
if s.leonardo == nil || strings.TrimSpace(token.Value) == "" {
|
||||
return false
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
sess, err := s.leonardo.ProbeSession(probeCtx, token.Value)
|
||||
if err == nil && sess != nil && strings.TrimSpace(sess.AccessToken) != "" {
|
||||
s.leonardoPersistCookie(probeCtx, token.ID, token.Value)
|
||||
return true
|
||||
}
|
||||
return !errors.Is(err, leonardo.ErrAuth)
|
||||
}
|
||||
|
||||
// markTokenDead disables an account and marks it dead on a fatal upstream error
|
||||
// (a non-overload temporary Adobe failure that ops policy treats as account death).
|
||||
func (s *V1Service) markTokenDead(ctx context.Context, pool string, token model.TokenAccount, kind string) {
|
||||
|
||||
Reference in New Issue
Block a user