fix(devices): /api/devices/pair accepts sk- bearer (TokenOrUserAuth)

Real test in C:\temp\v2_probe.js shows POST /api/devices/pair returning
HTTP 200 with body {"success":false, "message":"Unauthorized, invalid
access token"} when called with a Bearer sk- — the same sk- the OAuth
callback hands the client. Root cause: the route group used UserAuth(),
which only accepts a session cookie or a user JWT in Authorization, not
a relay-tier sk- bearer.

The OAuth-redirect flow (Heicode default) never produces a JWT — it
just hands cc-haha a sk-. So in production the pair call after
"一键登录" always 401'd, device-binding never activated, and V2
encryptedFetch silently fell back to legacy bearer for every request.

Fix: split the /devices route into two groups.
  - /devices/* (list, rename, revoke): still UserAuth(). A sk- must
    NOT be allowed to enumerate or revoke another device — that
    would let an attacker with a stolen sk- delete the legitimate
    owner's device binding.
  - /devices/pair: TokenOrUserAuth(). Pair is the bootstrap step, by
    definition no device key exists yet, so sk- IS the only credential
    available on the OAuth-redirect flow.

TokenOrUserAuth calls c.Set("id", token.UserId) via its TokenAuth
fallback, so the PairDevice controller's c.GetInt("id") keeps working.

Verified by re-running v2_probe.js after deploy: pair returns
HTTP 200 success:true.
This commit is contained in:
2026-05-21 03:43:44 +08:00
parent a316c42c7d
commit 52549bb2af
+21 -6
View File
@@ -326,13 +326,28 @@ func SetApiRouter(router *gin.Engine) {
// model, and the device-pair flow doesn't accidentally show
// up in the legacy /api/token UI. CriticalRateLimit on the
// pair endpoint stops brute-force device-spam from one user.
deviceRoute := apiRouter.Group("/devices")
deviceRoute.Use(middleware.UserAuth())
// V2 first-launch pairing accepts EITHER a user JWT (credentials
// login flow) OR a sk- access token (OAuth-redirect flow). The
// OAuth callback hands cc-haha only a sk-, so the original
// UserAuth-only path made pair 401 and the device-binding flow
// failed silently. We split /pair onto its own group with
// TokenOrUserAuth, while keeping the read/manage routes behind
// UserAuth (a sk- bearer must NOT be allowed to enumerate or
// revoke the user's other devices).
deviceManageRoute := apiRouter.Group("/devices")
deviceManageRoute.Use(middleware.UserAuth())
{
deviceRoute.GET("/", controller.ListUserDevices)
deviceRoute.POST("/pair", middleware.CriticalRateLimit(), controller.PairDevice)
deviceRoute.PATCH("/:id", controller.RenameUserDevice)
deviceRoute.DELETE("/:id", controller.RevokeUserDevice)
deviceManageRoute.GET("/", controller.ListUserDevices)
deviceManageRoute.PATCH("/:id", controller.RenameUserDevice)
deviceManageRoute.DELETE("/:id", controller.RevokeUserDevice)
}
devicePairRoute := apiRouter.Group("/devices")
devicePairRoute.Use(middleware.TokenOrUserAuth())
{
devicePairRoute.POST("/pair",
middleware.CriticalRateLimit(),
controller.PairDevice,
)
}
usageRoute := apiRouter.Group("/usage")