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>
495 lines
17 KiB
Go
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
|
|
}
|