Files
heicode-mananger/heicode/controller/device.go
T
chenchenandClaude Opus 4.8 b33bcf90bd 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>
2026-06-04 16:15:47 +08:00

495 lines
17 KiB
Go

package controller
import (
"encoding/base64"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/i18n"
"github.com/heicode/manager/model"
"github.com/heicode/manager/setting/operation_setting"
)
// Length caps for device-bound token columns. These match the GORM tags
// on model.Token (varchar(64)/varchar(16)/varchar(32)). varchar(N) means
// "N characters" on MySQL/PostgreSQL but is unlimited on SQLite — using
// rune counts here keeps semantics identical across all three engines
// (CLAUDE.md Rule 2) and prevents a byte-slice from corrupting a multi-
// byte UTF-8 sequence ("陈晨的 MacBook" is 18 bytes / 8 runes).
const (
deviceIdMaxRunes = 64
deviceNameMaxRunes = 64
platformMaxRunes = 16
appVersionMaxRunes = 32
)
// Public DTO sent to the web dashboard. Never includes Key (the raw sk-
// value) — that's only returned once at pair time. Subsequent listings
// show only metadata so a curious admin can't reseed someone's device
// just by reading the API response.
type deviceListItem struct {
Id int `json:"id"`
DeviceId string `json:"device_id"`
DeviceName string `json:"device_name"`
DevicePlatform string `json:"device_platform"`
DeviceAppVersion string `json:"device_app_version"`
DeviceBoundAt int64 `json:"device_bound_at"`
DeviceLastUsedAt int64 `json:"device_last_used_at"`
DeviceLastSeenIp string `json:"device_last_seen_ip"`
Status int `json:"status"`
RevokedAt int64 `json:"revoked_at"`
RevokedReason string `json:"revoked_reason"`
}
func toDeviceListItem(t *model.Token) deviceListItem {
deviceId := ""
if t.DeviceId != nil {
deviceId = *t.DeviceId
}
return deviceListItem{
Id: t.Id,
DeviceId: deviceId,
DeviceName: t.DeviceName,
DevicePlatform: t.DevicePlatform,
DeviceAppVersion: t.DeviceAppVersion,
DeviceBoundAt: t.DeviceBoundAt,
DeviceLastUsedAt: t.DeviceLastUsedAt,
DeviceLastSeenIp: t.DeviceLastSeenIp,
Status: t.Status,
RevokedAt: t.RevokedAt,
RevokedReason: t.RevokedReason,
}
}
// ListUserDevices serves GET /api/devices — the "Devices" tab on the
// user's Profile page. Each row maps 1:1 to a device-bound token row.
func ListUserDevices(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"})
return
}
tokens, err := model.GetUserDeviceBoundTokens(userId)
if err != nil {
common.SysLog("ListUserDevices: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
items := make([]deviceListItem, 0, len(tokens))
for _, t := range tokens {
items = append(items, toDeviceListItem(t))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
type pairDeviceRequest struct {
DeviceId string `json:"device_id"` // client-generated UUID v4
PublicKey string `json:"public_key"` // base64 32-byte Ed25519 pubkey
Fingerprint string `json:"fingerprint"` // sha256 hex of HWID + hostname
DeviceName string `json:"device_name"` // free-form, e.g. "Chen's MacBook"
Platform string `json:"platform"` // darwin / windows / linux
AppVersion string `json:"app_version"` // cc-haha version at pair time
}
// PairDevice serves POST /api/devices/pair — the very first thing a
// new cc-haha install does after the user logs into Heicode. The
// session cookie (set by /api/user/login) provides the user identity.
// We create a NEW hidden token row whose key the client must store in
// OS keychain — that's the bearer for subsequent calls, paired with a
// signature header.
func PairDevice(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "unauthorized"})
return
}
var req pairDeviceRequest
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
if !isValidPairRequest(req) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
// Idempotency: check for an existing (user_id, device_id) row
// BEFORE the cap. Otherwise a user already at the device cap can
// never re-confirm their existing device — bootstrap retries
// would hit "device limit reached" even though they're not adding
// anything new. Same (user_id, device_id, pubkey) is a no-op
// returning 200 so clients can call pair on every login as a
// liveness probe. Mismatched pubkey on a known device_id is a
// hard 409 — the client must clear + regenerate.
var existing model.Token
dup := model.DB.Where("user_id = ? AND device_id = ?", userId, req.DeviceId).
First(&existing).Error
if dup == nil {
samePubkey := existing.DevicePubkey != nil && *existing.DevicePubkey == req.PublicKey
if !samePubkey {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": "device already paired with different key",
})
return
}
// Same physical device coming back online. If the row is still
// active, return reused:true unchanged. If the row was revoked,
// we MUST reactivate it — otherwise the client thinks pair
// succeeded (200) but every subsequent V2 request fails with
// ErrDeviceRevoked (401) and the user is locked out of the app
// despite holding a valid JWT/sk for pair. Reactivation has to
// re-check the cap: while this device was revoked, the user
// may have paired N other devices, and counting it back in
// would exceed MaxDevicesPerUser.
revoked := existing.RevokedAt != 0
if revoked {
activeCount, err := model.CountUserDeviceBoundTokens(userId)
if err != nil {
common.SysLog("PairDevice reactivate count: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
if int(activeCount) >= operation_setting.GetMaxDevicesPerUser() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "device limit reached",
})
return
}
if err := model.ReactivateDevice(existing.Id); err != nil {
common.SysLog("PairDevice reactivate: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
common.SysLog("PairDevice: reactivated revoked device id=" +
strconv.Itoa(existing.Id) + " user_id=" + strconv.Itoa(userId))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": existing.Id,
"device_id": req.DeviceId,
"device_bound_at": existing.DeviceBoundAt,
"reused": true,
"reactivated": revoked,
},
})
return
}
// Per-user device cap. Only enforced for genuinely-new devices
// (the dup check above already short-circuited the re-pair path).
count, err := model.CountUserDeviceBoundTokens(userId)
if err != nil {
common.SysLog("PairDevice count: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
if int(count) >= operation_setting.GetMaxDevicesPerUser() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "device limit reached",
})
return
}
rawKey, err := common.GenerateKey()
if err != nil {
common.SysLog("PairDevice GenerateKey: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
now := common.GetTimestamp()
nowMs := now * 1000
deviceIdCopy := req.DeviceId
pubkeyCopy := req.PublicKey
tok := model.Token{
UserId: userId,
Name: deviceNameFromRequest(req),
Key: rawKey,
Status: common.TokenStatusEnabled,
CreatedTime: now,
AccessedTime: now,
ExpiredTime: -1, // never naturally; signature validity is the gate
// Device tokens are an AUTH mechanism, not a per-token billing
// boundary — they pin a device's Ed25519 pubkey to a user row.
// Quota / billing all belong on the User. UnlimitedQuota=true
// means the Manager skips token-level quota gating and consumes
// straight from User.Quota, which is what the legacy sk- bearer
// path also did. Without this, every V2 chat returned 403
// "token quota is not enough" because pair time defaulted
// RemainQuota to 0.
UnlimitedQuota: true,
HideFromUserUI: true, // device tokens only show on Devices page
DeviceId: &deviceIdCopy,
DevicePubkey: &pubkeyCopy,
DeviceFingerprint: req.Fingerprint,
DeviceName: req.DeviceName,
DevicePlatform: req.Platform,
DeviceAppVersion: req.AppVersion,
DeviceBoundAt: nowMs,
RequireDeviceBinding: true,
}
if err := tok.Insert(); err != nil {
common.SysLog("PairDevice Insert: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
// V2: do NOT return the raw key. With V2 device-signed auth the
// client identifies itself solely by device_id + signature; no
// bearer is needed on the wire. The token row's `Key` is still
// populated (DB NOT NULL + uniqueIndex on column) but stays
// server-only — it never leaves this controller and never reaches
// the response writer.
_ = rawKey // explicitly unused outside the row insert
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": tok.Id,
"device_id": deviceIdCopy,
"device_bound_at": nowMs,
},
})
}
// RevokeUserDevice serves DELETE /api/devices/:id — flips the device's
// token to disabled. Client side discards the keychain entry and
// prompts for re-pair.
func RevokeUserDevice(c *gin.Context) {
userId := c.GetInt("id")
tokenId, err := strconv.Atoi(c.Param("id"))
if err != nil || tokenId <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"})
return
}
// Look up + ownership check in one query to prevent users from
// guessing token ids belonging to other users.
var tok model.Token
q := model.DB.Where("id = ? AND user_id = ?", tokenId, userId).First(&tok)
if q.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
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, "user_initiated"); err != nil {
common.SysLog("RevokeUserDevice: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
// Audit trail. Symmetric with the "PairDevice: reactivated revoked
// device" log line so an admin can grep both transitions on the
// same device_id when investigating a lockout or abuse report.
// IP is the user's *current* IP (the one that POSTed revoke), not
// the device's last-seen IP — we want to know who clicked revoke,
// not where the device last ran.
deviceIdStr := ""
if tok.DeviceId != nil {
deviceIdStr = *tok.DeviceId
}
common.SysLog("RevokeUserDevice: user_id=" + strconv.Itoa(userId) +
" token_id=" + strconv.Itoa(tok.Id) +
" device_id=" + deviceIdStr +
" device_name=" + tok.DeviceName +
" ip=" + c.ClientIP() +
" reason=user_initiated")
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"`
}
// RenameUserDevice serves PATCH /api/devices/:id — lets the user update
// the friendly label without re-pairing. Trims to 64 chars to match the
// DB column.
func RenameUserDevice(c *gin.Context) {
userId := c.GetInt("id")
tokenId, err := strconv.Atoi(c.Param("id"))
if err != nil || tokenId <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "bad id"})
return
}
var req renameDeviceRequest
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
name := strings.TrimSpace(req.DeviceName)
// Reject empty rename — wiping device_name to '' makes the row show
// as "Unnamed device" in the Devices list, which is almost never
// what the user wants. The desktop UI already validates client-side
// but a direct API caller could still hit this path.
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgInvalidParams),
})
return
}
// Truncate by rune, not by byte. `name[:64]` slices on bytes and can
// leave a mangled UTF-8 sequence at the boundary; also `varchar(64)`
// in MySQL/PostgreSQL means 64 *characters* but unlimited in SQLite
// — rune-counting makes behaviour identical across all three.
if utf8.RuneCountInString(name) > deviceNameMaxRunes {
runes := []rune(name)
name = string(runes[:deviceNameMaxRunes])
}
res := model.DB.Model(&model.Token{}).
Where("id = ? AND user_id = ? AND device_pubkey IS NOT NULL", tokenId, userId).
Update("device_name", name)
if res.Error != nil {
common.SysLog("RenameUserDevice: " + res.Error.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// --- validation helpers ---
func isValidPairRequest(r pairDeviceRequest) bool {
if r.DeviceId == "" || r.PublicKey == "" || r.Fingerprint == "" {
return false
}
// device_id is a client-generated UUID v4 (36 ASCII chars) — byte
// length == rune length, so a byte check is fine. The cap is a defence-
// in-depth against a client sending garbage that would later be embedded
// in canonical strings / log lines.
if len(r.DeviceId) > deviceIdMaxRunes {
return false
}
if len(r.Fingerprint) != 64 { // sha256 hex
return false
}
// Ed25519 pubkey must be exactly 32 bytes once base64-decoded.
pk, err := base64.StdEncoding.DecodeString(r.PublicKey)
if err != nil || len(pk) != 32 {
return false
}
// device_name / platform / app_version are free-form text. Count runes
// so Chinese/Japanese hostnames don't silently overflow varchar(N).
if utf8.RuneCountInString(r.DeviceName) > deviceNameMaxRunes ||
utf8.RuneCountInString(r.Platform) > platformMaxRunes ||
utf8.RuneCountInString(r.AppVersion) > appVersionMaxRunes {
return false
}
return true
}
func deviceNameFromRequest(r pairDeviceRequest) string {
// `Token.Name` is what shows up in legacy admin tools and consume
// logs. Make it visually obvious these rows are devices, not
// user-created API keys, so admins can tell them apart at a glance.
label := strings.TrimSpace(r.DeviceName)
if label == "" {
label = "device"
}
return "device:" + label
}