Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/api/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type DeviceAuthorizeRequest struct {
Fingerprint string `json:"fingerprint" binding:"required,min=8,max=128"`
Platform string `json:"platform" binding:"max=64"`
Version string `json:"version"`
// Name 是设备自报的显示名(通常是主机名)。可空 —— 不带它的老客户端照常授权,
// 设备名回退到指纹缩写。设备流没有第二条途径拿到这个名字:不在这里带上,
// 设备列表里每台机器就都只能叫指纹缩写。
Name string `json:"name" binding:"max=128"`
}
type DeviceAuthorizeResponse struct {
DeviceCode string `json:"device_code"`
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/device_ctr/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func (d *Device) PublicKey(c *gin.Context, _ *api.PublicKeyRequest) {
func (d *Device) Authorize(ctx context.Context, req *api.DeviceAuthorizeRequest) (*api.DeviceAuthorizeResponse, error) {
out, err := device_svc.Default().Authorize(ctx, device_svc.AuthorizeInput{
DeviceKind: req.DeviceKind, Fingerprint: req.Fingerprint,
Platform: req.Platform, Version: req.Version,
Platform: req.Platform, Version: req.Version, Name: req.Name,
})
if err != nil {
return nil, i18n.NewInternalError(ctx, code.ServerError)
Expand Down
28 changes: 28 additions & 0 deletions internal/controller/device_ctr/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,34 @@ func TestAuthorize_IgnoresLegacyCapabilitiesField(t *testing.T) {
}, stub.authorizeInputs[0])
}

// 设备自报的显示名(通常是主机名)必须一路传到 service:设备流没有别的途径拿到
// 它,缺了这一段,设备列表里每台机器都只能叫指纹缩写。
func TestAuthorize_PassesReportedName(t *testing.T) {
stub := &stubDeviceSvc{}
server, _ := newDeviceTestServer(t, stub)

body := `{"device_kind":"agentred","fingerprint":"fp-named-client","platform":"linux/amd64",` +
`"version":"v0.5.0","name":"coding"}`
resp := doRequest(t, http.MethodPost, server.URL+"/v1/oauth/device/authorize", "", "", body)
require.Equal(t, http.StatusOK, resp.StatusCode)

require.Len(t, stub.authorizeInputs, 1)
assert.Equal(t, "coding", stub.authorizeInputs[0].Name)
}

// 老客户端不带 name,授权照常成立(名字回退到指纹缩写,由 service 决定)。
func TestAuthorize_NameIsOptional(t *testing.T) {
stub := &stubDeviceSvc{}
server, _ := newDeviceTestServer(t, stub)

body := `{"device_kind":"agentred","fingerprint":"fp-unnamed-client","platform":"linux/amd64","version":"v0.5.0"}`
resp := doRequest(t, http.MethodPost, server.URL+"/v1/oauth/device/authorize", "", "", body)
require.Equal(t, http.StatusOK, resp.StatusCode)

require.Len(t, stub.authorizeInputs, 1)
assert.Empty(t, stub.authorizeInputs[0].Name)
}

// nightly 构建会在语义版本后附带提交与构建元数据,长度可能超过 32 个字符。
// version 只是展示信息,且存储列可容纳 64 个字符,授权入口不能提前拒绝它。
func TestAuthorize_AcceptsLongNightlyVersion(t *testing.T) {
Expand Down
32 changes: 31 additions & 1 deletion internal/model/entity/device_entity/device.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Package device_entity 维护设备实体。
package device_entity

import "github.com/cago-frame/cago/pkg/consts"
import (
"strings"

"github.com/cago-frame/cago/pkg/consts"
)

const (
KindDesktop = "desktop"
Expand All @@ -27,3 +31,29 @@ type Device struct {
func (*Device) TableName() string { return "devices" }

func (d *Device) IsActive() bool { return d != nil && d.Status == consts.ACTIVE }

// fingerprintPrefix 是 daemon 侧规范指纹的算法前缀(sha256:<64 位 hex>)。
const fingerprintPrefix = "sha256:"

// displayNameFallbackRunes 是回退名取的指纹符文数。
const displayNameFallbackRunes = 8

// DisplayName 返回设备列表里显示的名字:客户端自报的名字优先,缺省时回退到指纹缩写。
//
// 回退**先剥掉 sha256: 前缀再截**:daemon 与桌面端的规范指纹都是 sha256:<64 位 hex>,
// 直接截前 8 个字符拿到的是 "sha256:" 加一个十六进制字符 —— 整个账号下的机器最多只有
// 16 种名字,等于没有名字。
//
// 按符文而不是按字节截:指纹由客户端自己生成,端点只按 binding `min=8` 收,而 validator
// 的 min 数的正是符文 —— 八个多字节符文的指纹过得了校验,按字节切却会切在符文中间,
// 落库的是一段非法 UTF-8,数据库会拒掉整条 INSERT。
func DisplayName(reported, fingerprint string) string {
if name := strings.TrimSpace(reported); name != "" {
return name
}
runes := []rune(strings.TrimPrefix(fingerprint, fingerprintPrefix))
if len(runes) <= displayNameFallbackRunes {
return string(runes)
}
return string(runes[:displayNameFallbackRunes])
}
28 changes: 28 additions & 0 deletions internal/model/entity/device_entity/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,31 @@ func TestDevice_IsActive(t *testing.T) {
assert.False(t, (&Device{Status: consts.DELETE}).IsActive())
assert.False(t, (*Device)(nil).IsActive())
}

func TestDisplayName(t *testing.T) {
// daemon 侧的规范指纹形态:sha256:<64 位 hex>(rpc.DaemonFingerprint)。
const daemonFP = "sha256:475776c61078781c9fda7b3345d232e32d5f176a7220ce2d129c5e39ac2db3de"

t.Run("自报了名字就用它", func(t *testing.T) {
assert.Equal(t, "coding", DisplayName("coding", daemonFP))
})
t.Run("自报名字只有空白视同没报", func(t *testing.T) {
assert.Equal(t, "475776c6", DisplayName(" ", daemonFP))
})
t.Run("没自报时回退到指纹缩写,且不能把 sha256: 前缀算进去", func(t *testing.T) {
// 直接截前 8 个字符会得到 "sha256:4"——每台机器都长一样,等于没有名字。
assert.Equal(t, "475776c6", DisplayName("", daemonFP))
})
t.Run("浏览器那种无前缀指纹按原样取前 8 位", func(t *testing.T) {
assert.Equal(t, "b363ed8b", DisplayName("", "b363ed8b7fdd0175e6d08ea8"))
})
t.Run("指纹本身不足 8 位就整串返回", func(t *testing.T) {
assert.Equal(t, "ab12", DisplayName("", "ab12"))
assert.Equal(t, "", DisplayName("", ""))
})
t.Run("按符文截,不切碎多字节指纹", func(t *testing.T) {
// 端点只按 binding `min=8` 收,而 validator 数的是符文:八个多字节符文过得了
// 校验,按字节切却会切在符文中间,落库时 MySQL 直接拒掉整条 INSERT。
assert.Equal(t, "一二三四五六七八", DisplayName("", "一二三四五六七八九十"))
})
}
23 changes: 13 additions & 10 deletions internal/model/entity/device_flow_entity/device_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ type DeviceFlowCode struct {
UserCode string `gorm:"column:user_code;type:text;not null"`
DeviceKind string `gorm:"column:device_kind;type:text;not null"`
ClientFingerprint string `gorm:"column:client_fingerprint;type:text;not null"`
Platform string `gorm:"column:platform;type:text;not null;default:''"`
Version string `gorm:"column:version;type:text;not null;default:''"`
AuthorizedUserID int64 `gorm:"column:authorized_user_id;type:bigint;not null;default:0"`
ApprovedAt int64 `gorm:"column:approved_at;type:bigint;not null;default:0"`
ConsumedAt int64 `gorm:"column:consumed_at;type:bigint;not null;default:0"`
DeniedAt int64 `gorm:"column:denied_at;type:bigint;not null;default:0"`
IntervalSeconds int `gorm:"column:interval_seconds;type:smallint;not null;default:5"`
LastPolledAt int64 `gorm:"column:last_polled_at;type:bigint;not null;default:0"`
ExpiresAt int64 `gorm:"column:expires_at;type:bigint;not null;default:0"`
Createtime int64 `gorm:"column:createtime;type:bigint;not null;default:0"`
// ClientName 是客户端自报的显示名(通常是主机名),可空;换取 token 时决定
// devices.name,缺省则回退到指纹缩写。
ClientName string `gorm:"column:client_name;type:text;not null;default:''"`
Platform string `gorm:"column:platform;type:text;not null;default:''"`
Version string `gorm:"column:version;type:text;not null;default:''"`
AuthorizedUserID int64 `gorm:"column:authorized_user_id;type:bigint;not null;default:0"`
ApprovedAt int64 `gorm:"column:approved_at;type:bigint;not null;default:0"`
ConsumedAt int64 `gorm:"column:consumed_at;type:bigint;not null;default:0"`
DeniedAt int64 `gorm:"column:denied_at;type:bigint;not null;default:0"`
IntervalSeconds int `gorm:"column:interval_seconds;type:smallint;not null;default:5"`
LastPolledAt int64 `gorm:"column:last_polled_at;type:bigint;not null;default:0"`
ExpiresAt int64 `gorm:"column:expires_at;type:bigint;not null;default:0"`
Createtime int64 `gorm:"column:createtime;type:bigint;not null;default:0"`
}

func (*DeviceFlowCode) TableName() string { return "device_flow_codes" }
Expand Down
3 changes: 2 additions & 1 deletion internal/service/device_svc/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func (s *deviceSvc) Authorize(ctx context.Context, in AuthorizeInput) (*Authoriz
UserCode: uc,
DeviceKind: in.DeviceKind,
ClientFingerprint: in.Fingerprint,
ClientName: in.Name,
Platform: in.Platform,
Version: in.Version,
IntervalSeconds: int(s.cfg.PollInterval / time.Second),
Expand Down Expand Up @@ -197,7 +198,7 @@ func (s *deviceSvc) ExchangeToken(ctx context.Context, dc string) (*TokenOutput,

d := &device_entity.Device{
UserID: flow.AuthorizedUserID,
Name: flow.ClientFingerprint[:8],
Name: device_entity.DisplayName(flow.ClientName, flow.ClientFingerprint),
Kind: flow.DeviceKind,
Platform: flow.Platform,
Version: flow.Version,
Expand Down
42 changes: 42 additions & 0 deletions internal/service/device_svc/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ func TestAuthorize_ReturnsUserCode(t *testing.T) {
mF.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, code *device_flow_entity.DeviceFlowCode) error {
assert.Equal(t, "agentred", code.DeviceKind)
// 自报名字必须落进 flow 行:换取 token 时 devices.name 只认它。
assert.Equal(t, "coding", code.ClientName)
return nil
},
)

out, err := svc.Authorize(ctx, AuthorizeInput{
DeviceKind: "agentred", Fingerprint: "fp-aaaaaaaa", Platform: "linux/amd64", Version: "0.5.0",
Name: "coding",
})
assert.NoError(t, err)
assert.NotEmpty(t, out.DeviceCode)
Expand Down Expand Up @@ -158,6 +161,45 @@ func TestExchangeToken(t *testing.T) {
assert.Equal(t, int64(7), out.DeviceID)
assert.Equal(t, out.JTI, capturedJTI)
})
// 设备流的显示名:客户端自报优先,缺省回退到指纹缩写。回退**必须**剥掉
// sha256: 前缀 —— 直接截前 8 个字符得到的是 "sha256:" 加一个十六进制字符,
// 整个账号下的机器最多只有 16 种名字。
exchangeNamed := func(t *testing.T, reported string) string {
ctx, mD, mT, mF, svc, mock := setupDeviceTest(t)
mF.EXPECT().FindByDeviceCode(gomock.Any(), "dc-x").Return(
&device_flow_entity.DeviceFlowCode{
DeviceCode: "dc-x", IntervalSeconds: 5,
ExpiresAt: time.Now().Add(time.Hour).UnixMilli(),
AuthorizedUserID: 42, ApprovedAt: time.Now().UnixMilli(),
DeviceKind: "agentred",
ClientFingerprint: "sha256:475776c61078781c9fda7b3345d232e32d5f176a7220ce2d129c5e39ac2db3de",
ClientName: reported,
}, nil,
)
mF.EXPECT().UpdateLastPolled(gomock.Any(), "dc-x", gomock.Any()).Return(nil)
var name string
mD.EXPECT().Upsert(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, d *device_entity.Device) error {
name = d.Name
d.ID = 7
return nil
},
)
mT.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
mF.EXPECT().MarkConsumed(gomock.Any(), "dc-x", gomock.Any()).Return(int64(1), nil)
mock.ExpectBegin()
mock.ExpectCommit()

_, err := svc.ExchangeToken(ctx, "dc-x")
assert.NoError(t, err)
return name
}
convey.Convey("设备名取客户端自报的主机名", func() {
assert.Equal(t, "coding", exchangeNamed(t, "coding"))
})
convey.Convey("客户端没自报名字时回退到指纹缩写", func() {
assert.Equal(t, "475776c6", exchangeNamed(t, ""))
})
convey.Convey("并发竞败(MarkConsumed 命中 0 行)→ invalid_grant,且在写 device 之前就出局", func() {
ctx, mD, mT, mF, svc, mock := setupDeviceTest(t)
mF.EXPECT().FindByDeviceCode(gomock.Any(), "dc-x").Return(
Expand Down
2 changes: 2 additions & 0 deletions internal/service/device_svc/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type AuthorizeInput struct {
Fingerprint string
Platform string
Version string
// Name 是客户端自报的显示名(通常是主机名),可空 —— 缺省时设备名回退到指纹缩写。
Name string
}

type AuthorizeOutput struct {
Expand Down
34 changes: 34 additions & 0 deletions migrations/202608150002_device_flow_client_name.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package migrations

import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

// migration202608150002 给 device_flow_codes 加上客户端自报的显示名。
//
// 设备流此前没有任何途径接收设备名:授权请求只收 device_kind / fingerprint /
// platform / version,换取 token 时只能把指纹截前 8 个字符当名字。而 daemon 与桌面端
// 的规范指纹是 sha256:<64 位 hex>,截出来的是 "sha256:" 加一个十六进制字符——整个
// 账号下的机器最多只有 16 种名字。名字要有意义,就必须由客户端在授权时报上来,
// 而授权与换取 token 是两次独立的请求,中间只有这一行 flow 记录能承载它。
//
//
// NOT NULL DEFAULT ”:老客户端不带 name,既有的未消费 flow 行也没有这一列,
// 空串表示「没自报」,由 device_entity.DisplayName 回退到指纹缩写。
//
// varchar(128) 与授权端点的 binding `max=128` 对齐,也与同表 platform / version 那批
// 有界 varchar 的写法一致——这张表里没有 text 列。
func migration202608150002() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "202608150002",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(
"ALTER TABLE device_flow_codes ADD COLUMN client_name varchar(128) NOT NULL DEFAULT ''",
).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("ALTER TABLE device_flow_codes DROP COLUMN client_name").Error
},
}
}
1 change: 1 addition & 0 deletions migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,6 @@ func migrationList() []*gormigrate.Migration {
migration202608100001(),
migration202608140001(),
migration202608150001(),
migration202608150002(),
}
}
Loading