Files
opsx-vpn/backend/internal/api/client.go
T

721 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// client.go 面向桌面/移动客户端的终端用户 API(/api/client/*)。
//
// 与管理后台 API 完全隔离:独立的令牌表(只存哈希)、不携带管理角色、
// 只暴露当前拨号用户被授权的 WireGuard 线路与自己名下的设备。
// 设备私钥只在客户端生成与保管,服务端仅登记公钥(外部 Peer)。
package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"vpn-platform/backend/internal/ldaputil"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service/wireguard"
"vpn-platform/backend/internal/sessionmgr"
"vpn-platform/backend/internal/store"
)
const (
clientTokenTTL = 30 * 24 * time.Hour
clientTokenRenewBelow = 15 * 24 * time.Hour // 剩余不足一半时滑动续期
maxDevicesPerLine = 5 // 每用户每条线路的在线设备上限
)
var clientMFAMu sync.Mutex
// ---- 令牌 ----
func clientTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// 会话只授予本次真正完成密码和 MFA 校验的拨号记录;修改凭据后旧授权立即失效。
func credentialFingerprint(user *model.VPNUser) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%t", user.ID, user.Password, user.LDAPDN, user.MFASecret, user.MFAPin, user.MFAEnabled)))
return hex.EncodeToString(sum[:])
}
func grantedUsers(users []model.VPNUser) map[uint]string {
grants := make(map[uint]string, len(users))
for i := range users {
grants[users[i].ID] = credentialFingerprint(&users[i])
}
return grants
}
func currentGrantedUsers(sess *model.ClientSession) []model.VPNUser {
var grants map[uint]string
if json.Unmarshal([]byte(sess.Grants), &grants) != nil || len(grants) == 0 {
return nil
}
ids := make([]uint, 0, len(grants))
for id := range grants {
ids = append(ids, id)
}
var users []model.VPNUser
if store.DB.Where("id IN ? AND username = ? AND enabled = ?", ids, sess.Username, true).Find(&users).Error != nil {
return nil
}
valid := users[:0]
for i := range users {
if grants[users[i].ID] == credentialFingerprint(&users[i]) {
valid = append(valid, users[i])
}
}
return valid
}
func revokeClientSessionsForUser(user *model.VPNUser) error {
var sessions []model.ClientSession
if err := store.DB.Where("username = ?", user.Username).Find(&sessions).Error; err != nil {
return err
}
for _, session := range sessions {
var grants map[uint]string
if json.Unmarshal([]byte(session.Grants), &grants) != nil {
continue
}
if _, ok := grants[user.ID]; ok {
if err := store.DB.Delete(&session).Error; err != nil {
return err
}
}
}
return nil
}
func issueClientToken(username, deviceName, platform string, users []model.VPNUser) (string, time.Time, error) {
grants, err := json.Marshal(grantedUsers(users))
if err != nil {
return "", time.Time{}, err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
expire := time.Now().Add(clientTokenTTL)
sess := model.ClientSession{
TokenHash: clientTokenHash(token),
Username: username,
Grants: string(grants),
DeviceName: deviceName,
Platform: platform,
ExpireAt: expire,
}
if err := store.DB.Create(&sess).Error; err != nil {
return "", time.Time{}, err
}
return token, expire, nil
}
// clientAuth 校验客户端令牌;与管理后台 authMiddleware 互不相通。
func clientAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少客户端令牌"})
return
}
var sess model.ClientSession
if err := store.DB.Where("token_hash = ?", clientTokenHash(token)).First(&sess).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已注销"})
return
}
if time.Now().After(sess.ExpireAt) {
store.DB.Delete(&sess)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
users := currentGrantedUsers(&sess)
if len(users) == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "线路授权已失效,请重新登录"})
return
}
if time.Until(sess.ExpireAt) < clientTokenRenewBelow {
sess.ExpireAt = time.Now().Add(clientTokenTTL)
store.DB.Model(&sess).Update("expire_at", sess.ExpireAt)
}
c.Set("client_username", sess.Username)
c.Set("client_users", users)
c.Set("client_token_hash", sess.TokenHash)
c.Next()
}
}
// ---- 拨号用户认证 ----
// verifyVPNUserPassword 与 OpenVPN 拨号校验语义一致:本地用户走 bcrypt,
// LDAP 用户用其登记的 DN 直接 bind。
func verifyVPNUserPassword(user *model.VPNUser, password string) error {
switch user.Source {
case "local":
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return fmt.Errorf("密码错误")
}
return nil
case "ldap":
if user.LDAPDN == "" {
return fmt.Errorf("LDAP 用户缺少 DN")
}
cfg, err := ldapSettings()
if err != nil {
return err
}
dialURL, err := ldaputil.DialURL(cfg.URL)
if err != nil {
return err
}
conn, err := ldap.DialURL(dialURL)
if err != nil {
return err
}
defer conn.Close()
if err := conn.Bind(user.LDAPDN, password); err != nil {
return fmt.Errorf("密码错误")
}
return nil
}
return fmt.Errorf("未知认证来源")
}
// verifyVPNUserOTP 校验拨号用户的 TOTP 动态口令(MFASecret 加密保存)。
func verifyVPNUserOTP(user *model.VPNUser, otp string) bool {
if !user.MFAEnabled {
return true
}
if otp == "" {
return false
}
clientMFAMu.Lock()
defer clientMFAMu.Unlock()
if store.DB.First(user, user.ID).Error != nil || !user.MFAEnabled {
return false
}
now := time.Now()
if user.MFALockedUntil != nil && now.Before(*user.MFALockedUntil) {
return false
}
failure := func() bool {
failures := user.MFAFailCount + 1
updates := map[string]interface{}{"mfa_fail_count": failures}
if failures >= 5 {
until := now.Add(5 * time.Minute)
updates["mfa_locked_until"] = &until
}
store.DB.Model(user).Updates(updates)
return false
}
if user.MFAHasPin {
pin, err := store.DecryptSecret(user.MFAPin)
if err != nil || pin == "" || !strings.HasPrefix(otp, pin) {
return failure()
}
otp = strings.TrimPrefix(otp, pin)
}
secret, err := store.DecryptSecret(user.MFASecret)
if err != nil || secret == "" {
return failure()
}
counter := now.Unix() / 30
if !totp.Validate(strings.TrimSpace(otp), secret) || counter <= user.MFALastCounter {
return failure()
}
store.DB.Model(user).Updates(map[string]interface{}{"mfa_last_counter": counter, "mfa_last_used_at": now, "mfa_fail_count": 0, "mfa_locked_until": nil})
return true
}
// authorizedLines 返回该拨号用户密码验证通过(且 OTP 满足)的 WireGuard 线路。
func authorizedLines(username, password, otp string) ([]model.VPNUser, bool, error) {
var users []model.VPNUser
if err := store.DB.Where("username = ? AND enabled = ?", username, true).Find(&users).Error; err != nil {
return nil, false, err
}
var passed []model.VPNUser
mfaRequired := false
for i := range users {
u := &users[i]
if verifyVPNUserPassword(u, password) != nil {
continue
}
if u.MFAEnabled {
mfaRequired = true
if otp == "" || !verifyVPNUserOTP(u, otp) {
continue
}
}
if t, err := store.GetTunnel(u.TunnelID); err != nil || t.Protocol != "wireguard" {
continue
}
passed = append(passed, *u)
}
return passed, mfaRequired, nil
}
type clientLine struct {
TunnelID uint `json:"tunnel_id"`
Name string `json:"name"`
Remark string `json:"remark"`
State string `json:"state"`
Endpoint string `json:"endpoint"`
RoutingMode string `json:"routing_mode"`
ExpireAt *time.Time `json:"expire_at"`
}
// linesForUsers 把授权拨号记录映射为 WireGuard 线路列表。
func linesForUsers(users []model.VPNUser) []clientLine {
seen := map[uint]bool{}
lines := []clientLine{}
for _, u := range users {
if seen[u.TunnelID] {
continue
}
seen[u.TunnelID] = true
t, err := store.GetTunnel(u.TunnelID)
if err != nil || t.Protocol != "wireguard" || (t.ExpireAt != nil && time.Now().After(*t.ExpireAt)) {
continue
}
line := clientLine{TunnelID: t.ID, Name: t.Name, Remark: t.Remark, State: t.State, ExpireAt: t.ExpireAt, RoutingMode: "split"}
if cfg, err := store.TunnelConfig(t); err == nil {
line.Endpoint = wireguardEndpoint(cfg)
line.RoutingMode = routingModeForAllowedIPs(fmt.Sprint(cfg["allowed_ips"]))
}
lines = append(lines, line)
}
return lines
}
func routingModeForAllowedIPs(value string) string {
for _, entry := range strings.Split(value, ",") {
if strings.TrimSpace(entry) == "0.0.0.0/0" {
return "global_ipv4"
}
}
return "split"
}
func wireguardEndpoint(cfg map[string]interface{}) string {
if ep, ok := cfg["public_endpoint"].(string); ok && ep != "" {
return ep
}
port := 51820
if p, ok := cfg["listen_port"].(float64); ok && p > 0 {
port = int(p)
}
return fmt.Sprintf(":%d", port) // 未配置公网地址时仅回传端口,由客户端提示管理员补全
}
// ---- 处理器 ----
type clientLoginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
OTP string `json:"otp"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
}
func (s *Server) clientLogin(c *gin.Context) {
var req clientLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
req.Username = strings.TrimSpace(req.Username)
lockKey := "client|" + c.ClientIP() + "|" + req.Username
if remaining := loginLockRemaining(lockKey); remaining > 0 {
c.JSON(429, gin.H{"error": fmt.Sprintf("尝试过于频繁,请 %d 秒后重试", int(remaining.Seconds()))})
return
}
passed, mfaRequired, err := authorizedLines(req.Username, req.Password, req.OTP)
if err != nil {
c.JSON(500, gin.H{"error": "认证服务异常"})
return
}
if len(passed) == 0 {
recordLoginFail(lockKey)
sessionmgr.LogConnection(0, req.Username, "auth_fail", c.ClientIP(), "客户端登录失败")
if mfaRequired && req.OTP == "" {
c.JSON(401, gin.H{"error": "该账号已启用动态口令", "mfa_required": true})
return
}
c.JSON(401, gin.H{"error": "用户名或密码错误"})
return
}
resetLoginFail(lockKey)
lines := linesForUsers(passed)
if len(lines) == 0 {
c.JSON(403, gin.H{"error": "该账号未被授权任何 WireGuard 线路"})
return
}
token, expire, err := issueClientToken(req.Username, req.DeviceName, req.Platform, passed)
if err != nil {
c.JSON(500, gin.H{"error": "签发令牌失败"})
return
}
displayName := passed[0].DisplayName
c.JSON(200, gin.H{
"token": token, "expires_at": expire,
"username": req.Username, "display_name": displayName,
"lines": lines,
})
}
func (s *Server) clientLogout(c *gin.Context) {
store.DB.Where("token_hash = ?", c.GetString("client_token_hash")).Delete(&model.ClientSession{})
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) clientLines(c *gin.Context) {
users, _ := c.Get("client_users")
c.JSON(200, gin.H{"lines": linesForUsers(users.([]model.VPNUser))})
}
// clientAuthorizedTunnel 校验当前客户端用户对该 WireGuard 线路有拨号授权。
func clientAuthorizedTunnel(users []model.VPNUser, tunnelID uint) (*model.Tunnel, *model.VPNUser, error) {
var authorized *model.VPNUser
for i := range users {
if users[i].TunnelID == tunnelID {
authorized = &users[i]
break
}
}
if authorized == nil {
return nil, nil, fmt.Errorf("没有该线路的接入授权")
}
t, err := store.GetTunnel(tunnelID)
if err != nil {
return nil, nil, fmt.Errorf("线路不存在")
}
if t.Protocol != "wireguard" {
return nil, nil, fmt.Errorf("客户端目前仅支持 WireGuard 线路")
}
if t.ExpireAt != nil && time.Now().After(*t.ExpireAt) {
return nil, nil, fmt.Errorf("线路已过期")
}
return t, authorized, nil
}
// allocateClientAddress 在专线网段内为设备分配第一个空闲地址(/32,仅 IPv4)。
func allocateClientAddress(serverCIDR string, used map[string]bool) (string, error) {
ip, ipnet, err := net.ParseCIDR(serverCIDR)
if err != nil {
return "", fmt.Errorf("专线网段配置无效: %s", serverCIDR)
}
ip = ip.To4()
if ip == nil {
return "", fmt.Errorf("客户端自动分配目前仅支持 IPv4 网段")
}
ones, bits := ipnet.Mask.Size()
if bits-ones < 2 {
return "", fmt.Errorf("专线网段过小,无法分配地址")
}
base := ip.Mask(ipnet.Mask)
total := 1 << uint(bits-ones)
// 跳过网络地址(0)与广播地址(total-1)
for i := 1; i < total-1; i++ {
candidate := make(net.IP, 4)
copy(candidate, base)
for b, v := 3, i; b >= 0 && v > 0; b-- {
candidate[b] += byte(v & 0xff)
v >>= 8
}
if !used[candidate.String()] {
return candidate.String() + "/32", nil
}
}
return "", fmt.Errorf("专线网段地址已分配完")
}
type clientRegisterDeviceReq struct {
TunnelID uint `json:"tunnel_id" binding:"required"`
Name string `json:"name" binding:"required"`
Platform string `json:"platform"`
PublicKey string `json:"public_key" binding:"required"`
}
func (s *Server) clientRegisterDevice(c *gin.Context) {
var req clientRegisterDeviceReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
username := c.GetString("client_username")
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" || len(req.Name) > 64 || strings.ContainsAny(req.Name, "\r\n") {
c.JSON(400, gin.H{"error": "设备名不能为空、不超过 64 字符且不含换行"})
return
}
if !wireguard.IsWireGuardKey(req.PublicKey) {
c.JSON(400, gin.H{"error": "public_key 不是合法的 WireGuard 公钥"})
return
}
granted, _ := c.Get("client_users")
t, vpnUser, err := clientAuthorizedTunnel(granted.([]model.VPNUser), req.TunnelID)
if err != nil {
c.JSON(403, gin.H{"error": err.Error()})
return
}
mu := lockTunnel(t.ID)
defer mu.Unlock()
var active int64
store.DB.Model(&model.ClientDevice{}).
Where("vpn_user_id = ? AND revoked = ?", vpnUser.ID, false).
Count(&active)
if active >= maxDevicesPerLine {
c.JSON(400, gin.H{"error": fmt.Sprintf("每条线路最多登记 %d 台设备,请先在客户端或管理端撤销闲置设备", maxDevicesPerLine)})
return
}
var dup int64
store.DB.Model(&model.ClientDevice{}).Where("public_key = ?", req.PublicKey).Count(&dup)
if dup > 0 {
c.JSON(400, gin.H{"error": "该设备密钥已登记过;请在本机重新生成密钥后再注册"})
return
}
wg := wireguard.New()
used, err := wg.UsedClientAddresses(t)
if err != nil {
c.JSON(500, gin.H{"error": "读取地址占用失败: " + err.Error()})
return
}
cfg, err := store.TunnelConfig(t)
if err != nil {
c.JSON(500, gin.H{"error": "读取线路配置失败"})
return
}
endpoint, configured := cfg["public_endpoint"].(string)
if !configured || strings.TrimSpace(endpoint) == "" {
c.JSON(400, gin.H{"error": "请管理员先为 WireGuard 专线配置公网端点"})
return
}
addr, err := allocateClientAddress(fmt.Sprint(cfg["address"]), used)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
device := model.ClientDevice{
TunnelID: t.ID, VPNUserID: vpnUser.ID, Username: username, Name: req.Name,
Platform: req.Platform, PublicKey: req.PublicKey, ClientAddress: addr,
}
if err := store.DB.Create(&device).Error; err != nil {
c.JSON(500, gin.H{"error": "保存设备失败"})
return
}
device.PeerID = fmt.Sprintf("dev-%d", device.ID)
if _, err := wg.RegisterDevicePeer(t, device.PeerID, device.Name, device.PublicKey, device.ClientAddress); err != nil {
// 已回滚的登记直接删除数据库记录;若 Peer 仍在,保留记录供管理员处理。
if _, lookupErr := wg.DeviceConnectionParams(t, device.PeerID); lookupErr != nil {
store.DB.Delete(&device)
} else {
store.DB.Model(&device).Update("peer_id", device.PeerID)
store.Audit(username, "client_device_cleanup_failed", t.Name, err.Error())
}
c.JSON(500, gin.H{"error": "登记设备 Peer 失败: " + err.Error()})
return
}
store.DB.Model(&device).Update("peer_id", device.PeerID)
store.Audit(username, "client_register_device", t.Name, fmt.Sprintf("设备 %q 分配 %s", device.Name, device.ClientAddress))
sessionmgr.LogConnection(t.ID, username, "connect", c.ClientIP(), fmt.Sprintf("客户端设备 %q 登记", device.Name))
params, err := wg.DeviceConnectionParams(t, device.PeerID)
if err != nil {
c.JSON(500, gin.H{"error": "读取连接参数失败: " + err.Error()})
return
}
params["device_id"] = device.ID
params["device_name"] = device.Name
params["tunnel_id"] = t.ID
params["tunnel_name"] = t.Name
c.JSON(200, params)
}
func (s *Server) clientListDevices(c *gin.Context) {
var devices []model.ClientDevice
users, _ := c.Get("client_users")
ids := make([]uint, 0, len(users.([]model.VPNUser)))
for _, u := range users.([]model.VPNUser) {
ids = append(ids, u.ID)
}
store.DB.Where("vpn_user_id IN ?", ids).Order("id desc").Find(&devices)
c.JSON(200, gin.H{"devices": devices})
}
// clientDeviceConfig 重新拉取设备连接参数(端点/密钥轮换后客户端自愈)。
func (s *Server) clientDeviceConfig(c *gin.Context) {
device, err := s.mustClientDevice(c)
if err != nil {
return
}
t, err := store.GetTunnel(device.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "线路已删除"})
return
}
params, err := wireguard.New().DeviceConnectionParams(t, device.PeerID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
params["device_id"] = device.ID
params["device_name"] = device.Name
params["tunnel_id"] = t.ID
params["tunnel_name"] = t.Name
c.JSON(200, params)
}
// clientDeviceStatus 返回服务端观测到的该设备真实握手与流量。
func (s *Server) clientDeviceStatus(c *gin.Context) {
device, err := s.mustClientDevice(c)
if err != nil {
return
}
t, err := store.GetTunnel(device.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "线路已删除"})
return
}
proto, err := s.mgr.Get("wireguard")
if err != nil {
c.JSON(500, gin.H{"error": "协议引擎不可用"})
return
}
st, err := proto.Status(t)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
for _, p := range st.Peers {
if p.PublicKey != device.PublicKey {
continue
}
updates := map[string]interface{}{"rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes}
if p.LatestHandshakeAt > 0 {
seen := time.Unix(p.LatestHandshakeAt, 0)
updates["last_seen_at"] = &seen
}
store.DB.Model(device).Updates(updates)
c.JSON(200, gin.H{
"running": st.Running, "connected": p.Connected,
"latest_handshake_at": p.LatestHandshakeAt,
"rx_bytes": p.RxBytes, "tx_bytes": p.TxBytes,
})
return
}
c.JSON(200, gin.H{"running": st.Running, "connected": false, "detail": "网关尚未观测到该设备握手"})
}
func (s *Server) clientRevokeDevice(c *gin.Context) {
device, err := s.mustClientDevice(c)
if err != nil {
return
}
if err := s.revokeDevice(device, c.GetString("client_username")); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) mustClientDevice(c *gin.Context) (*model.ClientDevice, error) {
var device model.ClientDevice
if err := store.DB.First(&device, c.Param("id")).Error; err != nil {
c.JSON(404, gin.H{"error": "设备不存在"})
return nil, err
}
users, _ := c.Get("client_users")
allowed := false
for _, u := range users.([]model.VPNUser) {
if u.ID == device.VPNUserID && u.TunnelID == device.TunnelID {
allowed = true
break
}
}
if !allowed || device.Username != c.GetString("client_username") {
c.JSON(403, gin.H{"error": "只能操作本人设备"})
return nil, fmt.Errorf("forbidden")
}
if device.Revoked {
c.JSON(400, gin.H{"error": "设备已撤销"})
return nil, fmt.Errorf("revoked")
}
if _, err := store.GetTunnel(device.TunnelID); err != nil {
c.JSON(404, gin.H{"error": "线路不存在"})
return nil, err
}
return &device, nil
}
// revokeDevice 撤销设备:先从网关移除 Peer(配置 + 运行时),再标记撤销。
func (s *Server) revokeDevice(device *model.ClientDevice, operator string) error {
t, err := store.GetTunnel(device.TunnelID)
if err == nil && t.Protocol == "wireguard" && device.PeerID != "" {
mu := lockTunnel(t.ID)
defer mu.Unlock()
if err := wireguard.New().RemoveDevicePeer(t, device.PeerID); err != nil {
return err
}
}
if err := store.DB.Model(device).Update("revoked", true).Error; err != nil {
return err
}
store.Audit(operator, "client_revoke_device", fmt.Sprintf("线路 %d", device.TunnelID),
fmt.Sprintf("撤销用户 %s 的设备 %q(%s)", device.Username, device.Name, device.ClientAddress))
return nil
}
// ---- 管理端:客户端设备管理 ----
func (s *Server) adminListClientDevices(c *gin.Context) {
type row struct {
model.ClientDevice
TunnelName string `json:"tunnel_name"`
}
var devices []model.ClientDevice
store.DB.Order("id desc").Find(&devices)
rows := make([]row, 0, len(devices))
for _, d := range devices {
r := row{ClientDevice: d}
if t, err := store.GetTunnel(d.TunnelID); err == nil {
r.TunnelName = t.Name
}
rows = append(rows, r)
}
c.JSON(200, gin.H{"devices": rows})
}
func (s *Server) adminRevokeClientDevice(c *gin.Context) {
var device model.ClientDevice
if err := store.DB.First(&device, c.Param("id")).Error; err != nil {
c.JSON(404, gin.H{"error": "设备不存在"})
return
}
if device.Revoked {
c.JSON(400, gin.H{"error": "设备已撤销"})
return
}
if err := s.revokeDevice(&device, c.GetString("username")); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
}