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:
@@ -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**,非设备签名)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user