From b33bcf90bda50488f7089157ed8660ae4f9a65c8 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 4 Jun 2026 16:15:47 +0800 Subject: [PATCH] feat(device): desktop client self-logout endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/devices/logout (UserOrV2DeviceAuth): a device-signed client revokes its OWN bound token via the signed X-Heicode-Device-Id (cannot touch other devices); a session/JWT caller may pass {device_id}. Idempotent. The existing DELETE /api/devices/:id revoke is session-only, so device clients had no self-logout — this closes that gap. Documented in the client API doc §1.1. Co-Authored-By: Claude Opus 4.8 --- .../integration/heicode-desktop-client-api.md | 20 ++++++++ heicode/controller/device.go | 51 +++++++++++++++++++ heicode/router/api-router.go | 3 ++ 3 files changed, 74 insertions(+) diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index 91cd279d..3b1900f0 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -66,6 +66,26 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) > 设备如何拿到 `device_id` + 设备密钥:走与模型调用相同的**设备配对/登录流程**(`cc-haha/src/services/device`、`/api/heicode-auth/*`),本文不重复。 +### 1.1 注销登录(logout)🟢 + +桌面客户端用**设备绑定 token** 登录,注销 = **吊销当前设备的 token**(不是清会话 cookie): + +| 方法 | 路径 | 鉴权 | 说明 | +|---|---|---|---| +| POST | `/api/devices/logout` | V2 设备签名 / 会话 | 吊销**当前设备**的 token | + +- **设备客户端**:用 V2 设备签名调用即可(空 body 也行)——服务端按签名头 `X-Heicode-Device-Id` 找到**本设备**的 token 并吊销。**只能注销自己,动不了用户的其它设备。** +- **会话/JWT 调用方**:可在 body 传 `{"device_id":"..."}` 注销该用户名下某台设备。 +- **幂等**:设备已不存在也返回 `{"success":true}`(注销已达成)。 +- 调用成功后客户端应**同时删除本地设备密钥**;要再用需**重新配对**(`POST /api/devices/pair`)。 + +```json +// 成功 +{ "success": true } +``` + +> 其它设备管理(会话/JWT):`GET /api/devices/`(列我的设备)、`PATCH /api/devices/:id`(改名)、`DELETE /api/devices/:id`(按 id 吊销某设备)。这些走 `UserAuth`,网页台「设备」页用;客户端自助注销用上面的 `/api/devices/logout`。 + --- ## 2. 账户与余额 🟢(可选;用**用户会话/JWT**,非设备签名) diff --git a/heicode/controller/device.go b/heicode/controller/device.go index c15e0d5a..1d105653 100644 --- a/heicode/controller/device.go +++ b/heicode/controller/device.go @@ -340,6 +340,57 @@ func RevokeUserDevice(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } +// LogoutDevice is the desktop client's "注销登录": it revokes the CURRENT device's +// bound token. Auth is UserOrV2DeviceAuth, so a device-signed client can log +// ITSELF out by its own X-Heicode-Device-Id (it cannot touch other devices). A +// session/JWT caller may pass {"device_id":"..."} to log out a specific own +// device. Idempotent: an already-gone device still returns success. +// +// After calling this the client should also discard its local device key. To +// resume it must pair again (POST /api/devices/pair). +func LogoutDevice(c *gin.Context) { + userId := c.GetInt("id") + if userId <= 0 { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "authentication required"}) + return + } + + // The signed device id is authoritative for a device client (it can only + // log itself out). Fall back to a body device_id for session callers. + deviceId := strings.TrimSpace(c.GetHeader("X-Heicode-Device-Id")) + if deviceId == "" { + var body struct { + DeviceId string `json:"device_id"` + } + _ = common.UnmarshalBodyReusable(c, &body) + deviceId = strings.TrimSpace(body.DeviceId) + } + if deviceId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "device_id required (or call with a V2 device signature)"}) + return + } + + var tok model.Token + if err := model.DB.Where("user_id = ? AND device_id = ?", userId, deviceId).First(&tok).Error; err != nil { + // Already gone -> logout already achieved. + c.JSON(http.StatusOK, gin.H{"success": true, "message": "already logged out"}) + return + } + if tok.DevicePubkey == nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "not a device-bound token"}) + return + } + if err := model.RevokeDevice(tok.Id, "client_logout"); err != nil { + common.SysLog("LogoutDevice: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": common.TranslateMessage(c, i18n.MsgDatabaseError)}) + return + } + common.SysLog("LogoutDevice: user_id=" + strconv.Itoa(userId) + + " token_id=" + strconv.Itoa(tok.Id) + " device_id=" + deviceId + + " ip=" + c.ClientIP() + " reason=client_logout") + c.JSON(http.StatusOK, gin.H{"success": true}) +} + type renameDeviceRequest struct { DeviceName string `json:"device_name"` } diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index fea43c64..c4d3e133 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -360,6 +360,9 @@ func SetApiRouter(router *gin.Engine) { controller.PairDevice, ) } + // Desktop client logout: a device-signed client revokes its OWN device + // token (or a session caller passes device_id). V2-device or session auth. + apiRouter.POST("/devices/logout", middleware.UserOrV2DeviceAuth(), controller.LogoutDevice) // Heicode desktop balance/usage endpoint. The standard // /api/user/self lives behind UserAuth (session cookie or JWT)