feat(device): desktop client self-logout endpoint

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 16:15:47 +08:00
co-authored by Claude Opus 4.8
parent 557fb2b829
commit b33bcf90bd
3 changed files with 74 additions and 0 deletions
@@ -66,6 +66,26 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
> 设备如何拿到 `device_id` + 设备密钥:走与模型调用相同的**设备配对/登录流程**(`cc-haha/src/services/device`、`/api/heicode-auth/*`),本文不重复。 > 设备如何拿到 `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**,非设备签名) ## 2. 账户与余额 🟢(可选;用**用户会话/JWT**,非设备签名)
+51
View File
@@ -340,6 +340,57 @@ func RevokeUserDevice(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true}) 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 { type renameDeviceRequest struct {
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
} }
+3
View File
@@ -360,6 +360,9 @@ func SetApiRouter(router *gin.Engine) {
controller.PairDevice, 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 // Heicode desktop balance/usage endpoint. The standard
// /api/user/self lives behind UserAuth (session cookie or JWT) // /api/user/self lives behind UserAuth (session cookie or JWT)