feat: 1、update device rregister logic

This commit is contained in:
zhr 2026-09-23 15:42:52 +08:00
parent b909cfbac0
commit bec98be1da
56 changed files with 1631 additions and 272 deletions

View File

@ -76,7 +76,7 @@ Do not convert these to integers.
| OpenAPI schema | Go | JSON | Values |
|----------------|----|------|--------|
| `DeviceType` | login DTO `string` + `oneof` | `device_type` / `platform` | `ios android harmony windows macos web` |
| `DeviceType` | login DTO `uint8` `min=1,max=6` | `device_type` / `platform` | meta `device_type` 1–6 |
| `Language` | `i18n.Lang` | `language`, `Accept-Language`, `X-Language` | `zh-CN` `en` `zh-TW` (default `zh-CN`) |
| `UploadPurpose` | `upload/entity.Purpose*` consts + `LookupSpec` | `purpose` | `avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file moment_image moment_video moment_cover` |
| `MomentKind` | `moment/entity.Kind` | `kind` | 1纯文 2图 3视频 |

View File

@ -107,7 +107,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
5. 在线发消息、收推送、增量同步走 **TCP**。`POST /v1/messages` 只是 HTTP 调试通道,不要当 App 主路径。
测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码,登录任意 `verify_code`。
测试号 `test01@spark.com`~`test100@spark.com` 可跳过发码,登录任意 `verify_code`。
`uid` 就是 Snowflake `users.id`。对外展示用可变的 `spark_id`,不要把 uid 写进对象存储路径。

View File

@ -56,6 +56,7 @@ jwt:
refresh_ttl: 720h
denylist_prefix: "denylist:"
refresh_prefix: "refresh:"
pair_key_mode: "triple"
# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login).
admin:

118
docs/devices-client.md Normal file
View File

@ -0,0 +1,118 @@
# 设备与登录 — 客户端对接说明
面向 iOS / Android / Web 等客户端
## 变更摘要(Breaking)
| 项 | 旧行为 | 新行为 |
| --- | --- | --- |
| 登录 Body `device_id` | 客户端生成并必填 | **删除**;由服务端根据物理指纹生成 |
| 登录 Body | 无 | **`app_id` 必填**;**`device_type` 为整数 1–6**(见 meta) |
| 物理标识 | 无 | **`idfv`(iOS/macOS)或 `android_id`(Android/Harmony)** 按平台必填 |
| JWT | `sub` + `did` | 增加 **`aid`(app_id)**;access / refresh 均含 |
| 登录响应 `data` | `device_id` | 仍为服务端 **`device_id`**,并增加 **`app_id`** |
| 同一邮箱 | 同一用户 | 不变 |
| 同机多 App | 各 App 各自 `device_id` | **同机共用一个 `device_id`**;会话按 **`app_id` / JWT `aid`** 区分 |
## 登录 `POST /v1/auth/login`
### 必填字段
- `email`、`verify_code`
- `app_id`:例如 Spark IM 为 `1`(以 meta 枚举为准)
- `device_type`:**整数枚举**(见 `GET /v1/meta/enums` → `device_type`);`1`=ios,`2`=android,`3`=harmony,`4`=windows,`5`=macos,`6`=web。可选 **`platform`** 同枚举,仅当未传 `device_type` 时作别名。
### 物理设备字段(生成 `device_id`)
| `device_type` | 必填 Body 字段 |
| --- | --- |
| `1`(ios)、`5`(macos) | `idfv` |
| `2`(android)、`3`(harmony) | `android_id` |
| `4`(windows)、`6`(web) | `idfv` 或 `android_id` 至少一个(建议 Web 用稳定 local id 映射到其一) |
可选:`device_name`(展示名)。
**不要**再传 `device_id`。指纹算法(与客户端实现一致即可):
```text
payload = "{device_type}:{idfv|android_id}"
fingerprint_hash = HEX(SHA256(payload))
device_id = uint64(SHA256(fingerprint_hash)[0:8]),非 0
```
`device_type` 与 meta / DB / 指纹 **同一套数值**(ios=1, android=2, …)。
### 响应
保存:
- `access_token`、`refresh_token`
- **`device_id`**(写入本地,与 JWT `did` 相同)
- **`app_id`**(与 JWT `aid` 相同)
- `user_id`、`spark_id`
### 示例
```http
POST /v1/auth/login HTTP/1.1
Content-Type: application/json
X-Language: zh-CN
X-App-Version: 1.0.0
{
"email": "user@example.com",
"verify_code": "123456",
"app_id": 1,
"device_type": 1,
"idfv": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"device_name": "iPhone 15"
}
```
## JWT
解码 access / refresh token(HS256,生产由服务端签发):
| claim | 含义 |
| --- | --- |
| `sub` | `user_id` |
| `did` | `device_id`(服务端生成) |
| **`aid`** | **`app_id`(当次登录 App)** |
| `use` | `access` 或 `refresh` |
刷新 token 后 **`aid` 不变**(与首次登录 App 绑定)。
## 登录后的 HTTP
- **`Authorization: Bearer <access_token>`**(必填)
- **`X-Device-ID`**:十进制 `device_id`,须等于 JWT **`did`**(与登录响应一致)
- 可选 **`X-App-ID`**:若传,须与 JWT **`aid`** 一致
- **`X-Language` / `Accept-Language`**、`X-App-Version` 仍走 Header
业务 JSON **不要**再塞 `device_id` / `app_id`(登录、刷新除外)。
## 刷新与注销
- **`POST /v1/auth/token/refresh`**:Body 仅 `refresh_token`;新 token 对仍带相同 `did` / `aid`。
- **`POST /v1/auth/logout`**:吊销当前 **`(user, device, app)`** 会话(默认服务端配置下各 App 独立;若运维切到 `pair` 模式,同设备会互踢 refresh,见下)。
## TCP AUTH
- Body:`token`(access)、可选 **`device_id`**(须等于 JWT `did`)、`app_version`、`language`
- **不必**在 AUTH Body 传 `app_id`;以 token 内 **`aid`** 为准
- 连接仍按 **`(user_id, device_id)`** 绑定;同机多 App 各连各的 token 即可
## 同机多 App
- **同一物理机 → 同一 `device_id`**(指纹不含 `app_id`)
- **不同 App → 不同 JWT `aid`**,各自 login 拿各自 token
- 默认(`triple`):App A 与 App B **可同时** refresh,互不踢
- 若服务端改为 `pair` 模式:后登录 App 会 **踢掉同设备上其它 App 的 refresh**(需产品公告)
## 客户端迁移清单
1. 登录 Body:加 **`app_id`**、**`idfv` / `android_id`**;去掉自研 **`device_id`**
2. 首次登录成功后 **持久化响应里的 `device_id`**,后续 Header / TCP 使用该值
3. 解析 JWT 时读取 **`aid`**,多 App 共用一套 `device_id` 存储键时应用 **`(device_id, app_id)`** 区分 token
4. 拉取 **`GET /v1/meta/enums`** 获取 `app_id`、`device_type` 等

209
docs/devices.md Normal file
View File

@ -0,0 +1,209 @@
# 设备与用户设备关系(方案定稿)
不改代码前的设计说明。与 JWT、`user_devices`、`user_login_logs`、PairStore 对齐。
## 目标
| 项 | 规则 |
| --- | --- |
| 物理设备 | 同一指纹 → **一个 `device_id`**(指纹 **不含 `app_id`**),多 App 同机共用 |
| **用户** | **同一邮箱 = 同一 `user_id`**,与从哪个 App 登录无关 |
| **App 会话** | Login Body **`app_id` → JWT `aid`**;**`user_login_logs.app_id`** 记当次 App |
| **JWT** | **`did` + `aid`(app_id)** |
| **PairStore** | 默认 **`(user_id, device_id, app_id)`**;配置可切 **`(user_id, device_id)`**(见 §PairStore) |
| **`users`** | **不增加 `app_id` 列**;同一邮箱同一用户 |
| **`user_devices`** | 仅 **`(user_id, device_id)`** + 登录/活跃时间,**不存 `app_id`** |
| **审计** | **`user_login_logs.app_id`**:当次从哪个 App 登录 |
| API | **`GET /v1/users/me/devices` 首版不提供** |
在线 / 活跃:更新 **`user_devices(user_id, device_id)`**;**当前 App** 看 JWT **`aid`** / 登录日志,不看 `user_devices`。
---
## 枚举
### `device_type`
| value | name |
| ---: | --- |
| 0 | unknown |
| 1 | ios |
| 2 | android |
| 3 | harmony |
| 4 | windows |
| 5 | macos |
| 6 | web |
### `app_id`
| value | name |
| ---: | --- |
| 1 | spark_im |
| … | … |
---
## 指纹与 `device_id`
```text
payload = "{device_type}:{idfv|android_id}"
fingerprint_hash = HEX(SHA256(payload))
device_id = uint64(SHA256(fingerprint_hash)[0:8]),非 0
```
- **`devices`**:`UNIQUE(fingerprint_hash)`,**无 `app_id` 列**。
- Android 多 App 共 **`device_id`**:客户端集团统一上报策略(见前版说明),服务端不合并不同 ANDROID_ID。
---
## 表:`users`
**不新增 `app_id`**。登录仍 **仅按 email** 查/建用户(`user_emails` 不变,同一邮箱 = 同一 `user_id`)。
---
## 表:`devices`
```sql
CREATE TABLE devices (
device_id BIGINT UNSIGNED NOT NULL COMMENT 'JWT did;多 App 同机共用',
fingerprint_hash CHAR(64) NOT NULL COMMENT '物理指纹,UNIQUE,不含 app_id',
device_type TINYINT UNSIGNED NOT NULL COMMENT 'device_type 枚举',
device_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近 login 上报设备名',
idfv VARCHAR(36) NOT NULL DEFAULT '',
android_id VARCHAR(20) NOT NULL DEFAULT '',
last_client_ip VARCHAR(30) NOT NULL DEFAULT '' COMMENT '最近 RealIP',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (device_id),
UNIQUE KEY uk_fingerprint (fingerprint_hash)
) COMMENT='物理设备;与用户、App 无关';
```
---
## 表:`user_devices`(无 `app_id`)
```sql
CREATE TABLE user_devices (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增',
user_id BIGINT NOT NULL COMMENT '用户 uid',
device_id BIGINT UNSIGNED NOT NULL COMMENT 'devices.device_id',
last_login_at DATETIME(3) NOT NULL COMMENT '该用户在此设备最近登录成功',
last_active_at DATETIME(3) NULL COMMENT '该用户在此设备最近活跃',
created_at DATETIME(3) NOT NULL COMMENT '该用户首次在此设备绑定',
PRIMARY KEY (id),
UNIQUE KEY uk_user_device (user_id, device_id),
KEY idx_user_id (user_id),
KEY idx_device_id (device_id)
) COMMENT='用户与设备关系;当前 App 见 JWT aid';
```
| 场景 | 说明 |
| --- | --- |
| 同机用户 A、B | `(A,did)`、`(B,did)`,**同一 `devices` 行** |
| **同邮箱、App1 与 App2 登录** | **同一 `user_id`**;**同一 `device_id`** → **一行 `user_devices`**;JWT **`aid` 不同**(各次 login) |
| B 在用、查 A 设备活跃 | 只更新 **A 的 `(A,did)`**,与 B 无关 |
---
## 表:`user_login_logs`
```sql
ALTER TABLE user_login_logs
ADD COLUMN app_id TINYINT UNSIGNED NOT NULL DEFAULT 0
COMMENT '当次登录 App,见 meta app_id' AFTER device_id;
```
当次 **`app_id`** = Login Body(当次从哪个 App 登录)。
---
## JWT
### Claims(access / refresh 均含)
| claim | JSON | 说明 |
| --- | --- | --- |
| `sub` | uid | 不变 |
| **`did`** | device_id | 服务端指纹生成 |
| **`aid`** | app_id | **当次登录 App**(Body `app_id`),refresh 链上固定 |
| `use` | access / refresh | 不变 |
| `jti` / `exp` / … | | 不变 |
### PairStore(refresh 键策略)
配置项(示例 **`auth.pair_key_mode`**):**`triple`** | **`pair`**,**上线前可改**,实现须 **两种都支持**。
| 模式 | Redis / 内存键 | **App2 登录同 `(user, device)` 时** |
| --- | --- | --- |
| **`triple`(默认)** | `(user_id, device_id, app_id)` | **不踢** App1 的 refresh;App1 / App2 **各一套** access+refresh |
| **`pair`** | `(user_id, device_id)` | **踢掉**该用户在该设备上 **所有 App** 的旧 refresh(及对应 access denylist),只保留 **最新一次 Issue** |
**JWT 始终带 `aid`**,与模式无关;Authenticate / refresh 仍校验 token 内 **`aid`**。
**`triple` → Issue`**:只 **replace** 当前 **`(uid, did, aid)`** 这一对 jti。
**`pair` → Issue`**:`replacePair` 前 **Delete 该 `(uid, did)` 下全部 pair 键**(或 scan 前缀 `auth:pair:{uid}:{did}:*`),再写入新 pair;可选 **LogoutAllAppsOnDevice** 钩子踢 TCP 仅 **`(uid, did)`**(不按 aid 拆连接,与现 TCP 模型一致)。
**Refresh**:键解析与 Issue 同模式;refresh token 内 **`aid` 必须与存储键一致**。
**Logout(当前设备)**:
- `triple`:只删 **`(uid, did, aid)`**(JWT 当前 `aid`)。
- `pair`:删 **`(uid, did)`** 唯一 pair(等价于该设备上所有 App session 一并注销)。
**迁移建议**:先 **`triple`** 上线;切 **`pair`** 前公告「同设备换 App 登录将踢其它 App 登录态」;Redis 键规范预留 **`auth:pair:{uid}:{did}`** 与 **`auth:pair:{uid}:{did}:{aid}`** 两种 layout,或统一 hash field 由 mode 决定 field 名。
### 签发与校验(其它)
- **`Issue(userID, deviceID, appID)`**。
- **Authenticate**:**`did` + `aid`** → Principal / reqctx。
- 可选 Header **`X-App-ID`** 与 JWT **`aid`** 一致。
### Login 响应
仍返回 **`device_id`**、**`user_id`**;可增 **`app_id`** 与 JWT 一致(可选,避免客户端只解 JWT)。
---
## 登录流程(`POST /v1/auth/login`)
1. 校验 Body **`app_id`**、OTP 等。
2. **email** 查/建用户。
3. 指纹 → **`device_id`** → UPSERT **`devices`**。
4. UPSERT **`user_devices(user_id, device_id)`**。
5. INSERT **`user_login_logs`**(含当次 **`app_id`**)。
6. **`Issue(user_id, device_id, app_id)`**(Body `app_id` → JWT `aid`;PairStore 按 **`pair_key_mode`**)。
---
## TCP AUTH
- 验 access token → **`user_id` + `device_id` + `app_id`**。
- 连接绑定 **`(user_id, device_id)`**(与现网一致);**`app_id` 进 conn 上下文** 供日志/限流扩展。
- Body **`device_id`** 可选,须与 JWT **`did`** 一致;**不要求 Body 再传 `app_id`**(以 token **`aid`** 为准)。
---
## 关系小结
```text
JWT aid ← 当次登录 App
JWT did ← devices.device_id
user_devices (user_id, device_id)
user_login_logs.app_id
PairStore ← triple 或 pair(配置)
```
---
## 迁移与检查清单
- [ ] **`users` 不加 `app_id`**;`user_emails` 不变
- [ ] `devices` / `user_devices` / `user_login_logs.app_id`
- [ ] JWT **`aid`** + Principal / reqctx
- [ ] **`auth.pair_key_mode`**:`triple`(App2 **不踢** App1 refresh)/ `pair`(**踢**全 App 同设备 refresh)
- [ ] PairStore 实现 **双模式** + Redis 键 layout 可切换
- [ ] login OpenAPI:`app_id` 必填;去掉客户端 **`device_id` 必填**
- [ ] meta **`app_id`** 枚举
首版不做:`GET /v1/users/me/devices`、attestation。

View File

@ -80,7 +80,7 @@ OpenAPI:`GET /openapi.yaml` → `/v1/sms/codes`。错误码目录:`/docs/err
| 类型 | 规则 |
| --- | --- |
| 邮箱 | `test01@spark.com`~`test10@spark.com`:`Send` **不**发邮件、**不**写 Redis 冷却;登录 / 注销校验 **任意** `verify_code` |
| 邮箱 | `test01@spark.com`~`test100@spark.com`:`Send` **不**发邮件、**不**写 Redis 冷却;登录 / 注销校验 **任意** `verify_code` |
| 手机 | `+8613800138000`~`+8613800138002`:不发短信;校验任意码(与测试邮箱类似) |
---

View File

@ -27,6 +27,8 @@ import (
convmodel "spark-server/internal/module/conversation/model"
deliverlogic "spark-server/internal/module/deliver/logic"
delivermodel "spark-server/internal/module/deliver/model"
devicelogic "spark-server/internal/module/device/logic"
devicemodel "spark-server/internal/module/device/model"
favlogic "spark-server/internal/module/favorite/logic"
favmodel "spark-server/internal/module/favorite/model"
friendlogic "spark-server/internal/module/friend/logic"
@ -117,12 +119,13 @@ func New(cfg *Config) (*App, error) {
func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (*App, error) {
jwtSvc, err := authlogic.NewJWT(&authlogic.Options{
Secret: cfg.JWT.Secret,
Issuer: cfg.JWT.Issuer,
TTL: cfg.JWT.TTL,
RefreshTTL: cfg.JWT.RefreshTTL,
Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix),
Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix),
Secret: cfg.JWT.Secret,
Issuer: cfg.JWT.Issuer,
TTL: cfg.JWT.TTL,
RefreshTTL: cfg.JWT.RefreshTTL,
Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix),
Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix, authlogic.PairKeyMode(cfg.JWT.PairKeyMode)),
PairKeyMode: authlogic.PairKeyMode(cfg.JWT.PairKeyMode),
})
if err != nil {
_ = rdb.Close()
@ -166,7 +169,9 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
userRepo := wireUserRepo(db)
smsSvc := wireSMS(cfg, rdb, userRepo)
users := userlogic.NewService(userRepo, smsSvc, idgen, wireUserObjects(objects))
devices := wireDevices(db)
login := authlogic.NewLoginService(jwtSvc, users)
login.Devices = devices
login.Sessions = sessions
var deliverQueue deliverlogic.Queue
inbox, mailbox := wireInbox(idgen, db, rdb)
@ -461,11 +466,18 @@ func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
return authlogic.NewMemoryDenylist()
}
func jwtPairs(rdb *redisx.Client, prefix string) authlogic.PairStore {
func jwtPairs(rdb *redisx.Client, prefix string, mode authlogic.PairKeyMode) authlogic.PairStore {
if rdb != nil && rdb.Ready() {
return authlogic.NewRedisPairs(rdb.RDB(), prefix)
return authlogic.NewRedisPairsMode(rdb.RDB(), prefix, mode)
}
return authlogic.NewMemoryPairs()
return authlogic.NewMemoryPairsMode(mode)
}
func wireDevices(db *mysql.DB) *devicelogic.Service {
if db != nil && db.Ready() {
return devicelogic.NewService(devicemodel.NewRegistryRepo(db.Gorm()))
}
return devicelogic.NewService(devicemodel.NewMemoryRegistry())
}
func (a *App) Run() error {

View File

@ -179,6 +179,7 @@ type JWTConfig struct {
RefreshTTL time.Duration `yaml:"refresh_ttl"`
DenylistPrefix string `yaml:"denylist_prefix"`
RefreshPrefix string `yaml:"refresh_prefix"`
PairKeyMode string `yaml:"pair_key_mode"`
}
func Load(path string) (*Config, error) {
@ -247,6 +248,9 @@ func applyDefaults(cfg *Config) {
if cfg.JWT.RefreshPrefix == "" {
cfg.JWT.RefreshPrefix = "refresh:"
}
if strings.TrimSpace(cfg.JWT.PairKeyMode) == "" {
cfg.JWT.PairKeyMode = "triple"
}
if cfg.Redis.DeliverQueueKey == "" {
cfg.Redis.DeliverQueueKey = "deliver:queue"
}

View File

@ -7,6 +7,7 @@ import "time"
type Principal struct {
UserID int64
DeviceID uint64
AppID uint32
JTI string
ExpiresAt time.Time
}
@ -19,5 +20,6 @@ type IssuedToken struct {
RefreshExpiresIn int64 `json:"refresh_expires_in"`
UserID int64 `json:"user_id"`
DeviceID uint64 `json:"device_id"`
AppID uint32 `json:"app_id"`
SparkID string `json:"spark_id"`
}

View File

@ -21,9 +21,9 @@ type Service interface {
// Issuer mints, refreshes, and revokes access tokens.
type Issuer interface {
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
Issue(ctx context.Context, userID int64, deviceID uint64, appID uint32) (*authentity.IssuedToken, error)
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
Logout(ctx context.Context, userID int64, deviceID uint64, appID uint32, accessJTI string, accessExp time.Time) error
LogoutAll(ctx context.Context, userID int64) error
Revoke(ctx context.Context, token string) error
}
@ -42,17 +42,19 @@ const (
// Options configure HS256 JWT issuance and validation.
type Options struct {
Secret string
Issuer string
TTL time.Duration
RefreshTTL time.Duration
Denylist Denylist
Pairs PairStore
Clock func() time.Time
Secret string
Issuer string
TTL time.Duration
RefreshTTL time.Duration
Denylist Denylist
Pairs PairStore
PairKeyMode PairKeyMode
Clock func() time.Time
}
type claims struct {
DeviceID uint64 `json:"did"`
AppID uint32 `json:"aid"`
Use string `json:"use,omitempty"`
jwt.RegisteredClaims
}
@ -65,6 +67,7 @@ type JWT struct {
refreshTTL time.Duration
denylist Denylist
pairs PairStore
pairMode PairKeyMode
clock func() time.Time
}
@ -103,26 +106,27 @@ func NewJWT(opt *Options) (*JWT, error) {
refreshTTL: refreshTTL,
denylist: dl,
pairs: pairs,
pairMode: normalizePairKeyMode(opt.PairKeyMode),
clock: clock,
}, nil
}
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) {
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64, appID uint32) (*authentity.IssuedToken, error) {
if err := ctx.Err(); err != nil {
return nil, errs.FromRPCError("jwt", err)
}
if userID <= 0 || deviceID == 0 {
if userID <= 0 || deviceID == 0 || appID == 0 {
return nil, errs.ErrInvalidArgument
}
access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl)
access, err := j.sign(userID, deviceID, appID, tokenUseAccess, j.ttl)
if err != nil {
return nil, err
}
refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL)
refresh, err := j.sign(userID, deviceID, appID, tokenUseRefresh, j.refreshTTL)
if err != nil {
return nil, err
}
if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil {
if err := j.replacePair(ctx, userID, deviceID, appID, access.jti, refresh.jti); err != nil {
return nil, err
}
return &authentity.IssuedToken{
@ -132,6 +136,7 @@ func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authen
RefreshExpiresIn: int64(j.refreshTTL / time.Second),
UserID: userID,
DeviceID: deviceID,
AppID: appID,
}, nil
}
@ -140,11 +145,12 @@ type signedToken struct {
jti string
}
func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) {
func (j *JWT) sign(userID int64, deviceID uint64, appID uint32, use string, ttl time.Duration) (signedToken, error) {
now := j.clock()
jti := reqctx.NewID()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
DeviceID: deviceID,
AppID: appID,
Use: use,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatInt(userID, 10),
@ -161,20 +167,38 @@ func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration)
return signedToken{raw: raw, jti: jti}, nil
}
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error {
old, err := j.pairs.Get(ctx, userID, deviceID)
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, appID uint32, accessJTI, refreshJTI string) error {
var old TokenPair
var err error
if j.pairMode == PairKeyPair {
old, err = j.pairs.Get(ctx, userID, deviceID, 0)
} else {
old, err = j.pairs.Get(ctx, userID, deviceID, appID)
}
if err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
if err := j.revokePairJTIs(ctx, old); err != nil {
return err
}
if j.pairMode == PairKeyPair {
if err := j.pairs.DeleteAllOnDevice(ctx, userID, deviceID); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
}
if err := j.pairs.Put(ctx, userID, deviceID, appID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
return nil
}
func (j *JWT) revokePairJTIs(ctx context.Context, old TokenPair) error {
if old.Access != "" {
_ = j.denylist.Add(ctx, old.Access, j.ttl)
}
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
return nil
}
@ -194,20 +218,27 @@ func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.Iss
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
if err != nil || uid <= 0 || c.DeviceID == 0 || c.AppID == 0 {
return nil, errs.ErrUnauthorized
}
stored, err := j.pairs.Get(ctx, uid, c.DeviceID)
stored, err := j.pairs.Get(ctx, uid, c.DeviceID, j.storeAppID(c.AppID))
if err != nil {
return nil, errs.FromDependencyError("jwt-refresh", err)
}
if stored.Refresh == "" || stored.Refresh != c.ID {
return nil, errs.ErrUnauthorized
}
return j.Issue(ctx, uid, c.DeviceID)
return j.Issue(ctx, uid, c.DeviceID, c.AppID)
}
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error {
func (j *JWT) storeAppID(appID uint32) uint32 {
if j.pairMode == PairKeyPair {
return 0
}
return appID
}
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, appID uint32, accessJTI string, accessExp time.Time) error {
if accessJTI != "" {
remain := accessExp.Sub(j.clock())
if remain > 0 {
@ -216,7 +247,13 @@ func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJ
}
}
}
old, err := j.pairs.Get(ctx, userID, deviceID)
var old TokenPair
var err error
if j.pairMode == PairKeyPair {
old, err = j.pairs.Get(ctx, userID, deviceID, 0)
} else {
old, err = j.pairs.Get(ctx, userID, deviceID, appID)
}
if err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
@ -226,7 +263,13 @@ func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJ
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Delete(ctx, userID, deviceID); err != nil {
if j.pairMode == PairKeyPair {
if err := j.pairs.DeleteAllOnDevice(ctx, userID, deviceID); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
return nil
}
if err := j.pairs.Delete(ctx, userID, deviceID, appID); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
return nil
@ -240,15 +283,23 @@ func (j *JWT) LogoutAll(ctx context.Context, userID int64) error {
if err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
seen := make(map[uint64]struct{})
for i := range rows {
row := rows[i]
if row.Pair.Access != "" {
_ = j.denylist.Add(ctx, row.Pair.Access, j.ttl)
if err := j.revokePairJTIs(ctx, row.Pair); err != nil {
return err
}
if row.Pair.Refresh != "" {
_ = j.denylist.Add(ctx, row.Pair.Refresh, j.refreshTTL)
if j.pairMode == PairKeyPair {
if _, ok := seen[row.DeviceID]; ok {
continue
}
seen[row.DeviceID] = struct{}{}
if err := j.pairs.DeleteAllOnDevice(ctx, userID, row.DeviceID); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
continue
}
if err := j.pairs.Delete(ctx, userID, row.DeviceID); err != nil {
if err := j.pairs.Delete(ctx, userID, row.DeviceID, row.AppID); err != nil {
return errs.FromDependencyError("jwt-refresh", err)
}
}
@ -271,14 +322,14 @@ func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Princ
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
if err != nil || uid <= 0 || c.DeviceID == 0 || c.AppID == 0 {
return nil, errs.ErrUnauthorized
}
exp := time.Time{}
if c.ExpiresAt != nil {
exp = c.ExpiresAt.Time
}
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, AppID: c.AppID, JTI: c.ID, ExpiresAt: exp}, nil
}
func (j *JWT) Revoke(ctx context.Context, token string) error {

View File

@ -25,10 +25,12 @@ func testJWT(t *testing.T, clock func() time.Time) *JWT {
return j
}
const jwtTestAppID uint32 = 1
func TestJWTIssueAuthenticate(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 42, 7)
tok, err := j.Issue(ctx, 42, 7, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -39,7 +41,7 @@ func TestJWTIssueAuthenticate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if p.UserID != 42 || p.DeviceID != 7 || p.JTI == "" {
if p.UserID != 42 || p.DeviceID != 7 || p.AppID != jwtTestAppID || p.JTI == "" {
t.Fatalf("%+v", p)
}
}
@ -57,7 +59,7 @@ func TestJWTRejectsBadToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tok, err := other.Issue(ctx, 1, 1)
tok, err := other.Issue(ctx, 1, 1, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -71,7 +73,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
now := time.Now()
clock := func() time.Time { return now }
j := testJWT(t, clock)
tok, err := j.Issue(ctx, 1, 2)
tok, err := j.Issue(ctx, 1, 2, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -84,7 +86,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
issTok, err := wrongIss.Issue(ctx, 1, 2)
issTok, err := wrongIss.Issue(ctx, 1, 2, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -96,7 +98,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 5, 8)
tok, err := j.Issue(ctx, 5, 8, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -124,7 +126,7 @@ func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
func TestJWTLogoutRevokesPair(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 4, 2)
tok, err := j.Issue(ctx, 4, 2, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -132,7 +134,7 @@ func TestJWTLogoutRevokesPair(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := j.Logout(ctx, p.UserID, p.DeviceID, p.JTI, p.ExpiresAt); err != nil {
if err := j.Logout(ctx, p.UserID, p.DeviceID, p.AppID, p.JTI, p.ExpiresAt); err != nil {
t.Fatal(err)
}
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
@ -146,11 +148,11 @@ func TestJWTLogoutRevokesPair(t *testing.T) {
func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
first, err := j.Issue(ctx, 8, 1)
first, err := j.Issue(ctx, 8, 1, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
second, err := j.Issue(ctx, 8, 2)
second, err := j.Issue(ctx, 8, 2, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -171,7 +173,7 @@ func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) {
func TestJWTDenylistRevoke(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 9, 3)
tok, err := j.Issue(ctx, 9, 3, jwtTestAppID)
if err != nil {
t.Fatal(err)
}
@ -185,12 +187,15 @@ func TestJWTDenylistRevoke(t *testing.T) {
func TestJWTIssueRejectsBadIDs(t *testing.T) {
j := testJWT(t, nil)
if _, err := j.Issue(context.Background(), 0, 1); !errors.Is(err, errs.ErrInvalidArgument) {
if _, err := j.Issue(context.Background(), 0, 1, jwtTestAppID); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("user: %v", err)
}
if _, err := j.Issue(context.Background(), 1, 0); !errors.Is(err, errs.ErrInvalidArgument) {
if _, err := j.Issue(context.Background(), 1, 0, jwtTestAppID); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("device: %v", err)
}
if _, err := j.Issue(context.Background(), 1, 1, 0); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("app: %v", err)
}
}
func TestJWTRejectsNoneAlg(t *testing.T) {
@ -214,6 +219,51 @@ func TestJWTRejectsNoneAlg(t *testing.T) {
}
}
func TestJWTPairModeSingleSessionPerDevice(t *testing.T) {
ctx := context.Background()
pairs := NewMemoryPairsMode(PairKeyPair)
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Pairs: pairs, PairKeyMode: PairKeyPair})
if err != nil {
t.Fatal(err)
}
first, err := j.Issue(ctx, 3, 9, 1)
if err != nil {
t.Fatal(err)
}
second, err := j.Issue(ctx, 3, 9, 2)
if err != nil {
t.Fatal(err)
}
if _, err := j.Refresh(ctx, first.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("old app refresh should fail: %v", err)
}
if _, err := j.Refresh(ctx, second.RefreshToken); err != nil {
t.Fatal(err)
}
}
func TestMemoryPairsListByUser(t *testing.T) {
ctx := context.Background()
p := NewMemoryPairsMode(PairKeyTriple)
if err := p.Put(ctx, 2, 5, 1, TokenPair{Access: "a1", Refresh: "r1"}, time.Minute); err != nil {
t.Fatal(err)
}
if err := p.Put(ctx, 2, 5, 2, TokenPair{Access: "a2", Refresh: "r2"}, time.Minute); err != nil {
t.Fatal(err)
}
rows, err := p.ListByUser(ctx, 2)
if err != nil || len(rows) != 2 {
t.Fatalf("list %+v %v", rows, err)
}
if err := p.DeleteAllOnDevice(ctx, 2, 5); err != nil {
t.Fatal(err)
}
rows, err = p.ListByUser(ctx, 2)
if err != nil || len(rows) != 0 {
t.Fatalf("after delete %+v %v", rows, err)
}
}
func TestNewJWTRequiresSecret(t *testing.T) {
if _, err := NewJWT(&Options{}); err == nil {
t.Fatal("expected secret error")
@ -243,13 +293,13 @@ func TestRedisStoresRequireClient(t *testing.T) {
t.Fatal("denylist add")
}
p := NewRedisPairs(nil, "")
if err := p.Put(ctx, 1, 1, TokenPair{Access: "a"}, time.Second); err == nil {
if err := p.Put(ctx, 1, 1, jwtTestAppID, TokenPair{Access: "a"}, time.Second); err == nil {
t.Fatal("pairs put")
}
if _, err := p.Get(ctx, 1, 1); err == nil {
if _, err := p.Get(ctx, 1, 1, jwtTestAppID); err == nil {
t.Fatal("pairs get")
}
if err := p.Delete(ctx, 1, 1); err == nil {
if err := p.Delete(ctx, 1, 1, jwtTestAppID); err == nil {
t.Fatal("pairs del")
}
if _, err := p.ListByUser(ctx, 1); err == nil {

View File

@ -4,6 +4,8 @@ import (
"context"
authentity "spark-server/internal/module/auth/entity"
deviceentity "spark-server/internal/module/device/entity"
devicelogic "spark-server/internal/module/device/logic"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
@ -16,14 +18,21 @@ type Verifier interface {
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error)
}
// LoginInput is the HTTP/TCP-shared login body. Validation tags drive client-visible messages.
// DeviceRegistrar resolves physical device_id from idfv / android_id.
type DeviceRegistrar interface {
Register(ctx context.Context, in devicelogic.RegisterInput) (uint64, error)
}
// LoginInput is the HTTP login body.
type LoginInput struct {
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"`
DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"`
DeviceType string `json:"device_type" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
Platform string `json:"platform" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
AppID uint8 `json:"app_id" validate:"required,min=1" label:"field.app_id" errmsg:"required:validate.login.app_id_required"`
DeviceType uint8 `json:"device_type" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"`
Platform uint8 `json:"platform" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"`
DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"`
IDFV string `json:"idfv" validate:"omitempty,max=36" label:"field.idfv"`
AndroidID string `json:"android_id" validate:"omitempty,max=20" label:"field.android_id"`
}
type RefreshInput struct {
@ -35,10 +44,11 @@ type SessionKicker interface {
KickDevice(userID int64, deviceID uint64)
}
// LoginService is the application login use-case. Handlers only bind and call this.
// LoginService is the application login use-case.
type LoginService struct {
Issuer Issuer
Users Verifier
Devices DeviceRegistrar
Sessions SessionKicker
}
@ -47,23 +57,38 @@ func NewLoginService(issuer Issuer, users Verifier) *LoginService {
}
func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.IssuedToken, error) {
if s == nil || s.Issuer == nil || s.Users == nil || in == nil {
if s == nil || s.Issuer == nil || s.Users == nil || s.Devices == nil || in == nil {
return nil, errs.ErrUnauthorized
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if !userentity.ValidDeviceType(loginDeviceType(in)) {
if !deviceentity.AppID(in.AppID).Valid() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil))
}
dtype := loginResolvedDeviceType(in)
if !dtype.Valid() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
}
u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, loginDevice(ctx, in))
deviceID, err := s.Devices.Register(ctx, devicelogic.RegisterInput{
DeviceType: dtype,
DeviceName: in.DeviceName,
IDFV: in.IDFV,
AndroidID: in.AndroidID,
ClientIP: reqctx.RealIP(ctx),
})
if err != nil {
return nil, err
}
ld := loginDevice(ctx, in, deviceID)
u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, ld)
if err != nil {
return nil, err
}
if u == nil || u.ID <= 0 {
return nil, errs.ErrUnauthorized
}
tok, err := s.Issuer.Issue(ctx, u.ID, in.DeviceID)
tok, err := s.Issuer.Issue(ctx, u.ID, deviceID, uint32(in.AppID))
if err != nil {
return nil, err
}
@ -86,10 +111,10 @@ func (s *LoginService) Logout(ctx context.Context) error {
return errs.ErrUnauthorized
}
rc := reqctx.From(ctx)
if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 {
if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 || rc.AppID == 0 {
return errs.ErrUnauthorized
}
if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.JTI, rc.TokenExpires); err != nil {
if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.AppID, rc.JTI, rc.TokenExpires); err != nil {
return err
}
if s.Sessions != nil {
@ -98,25 +123,31 @@ func (s *LoginService) Logout(ctx context.Context) error {
return nil
}
func loginDeviceType(in *LoginInput) string {
func loginResolvedDeviceType(in *LoginInput) deviceentity.DeviceType {
if in == nil {
return ""
return deviceentity.TypeUnknown
}
if t := userentity.NormalizeDeviceType(in.DeviceType); userentity.ValidDeviceType(t) {
return t
if in.DeviceType >= 1 {
return deviceentity.DeviceTypeFromWire(in.DeviceType)
}
return userentity.NormalizeDeviceType(in.Platform)
if in.Platform >= 1 {
return deviceentity.DeviceTypeFromWire(in.Platform)
}
return deviceentity.TypeUnknown
}
func loginDevice(ctx context.Context, in *LoginInput) *userentity.LoginDevice {
func loginDevice(ctx context.Context, in *LoginInput, deviceID uint64) *userentity.LoginDevice {
if in == nil {
return &userentity.LoginDevice{}
return &userentity.LoginDevice{DeviceID: deviceID}
}
dtype := loginResolvedDeviceType(in)
d := &userentity.LoginDevice{
DeviceID: in.DeviceID,
DeviceType: loginDeviceType(in),
Platform: in.Platform,
DeviceID: deviceID,
AppID: in.AppID,
DeviceType: dtype.PlatformName(),
DeviceName: in.DeviceName,
IDFV: in.IDFV,
AndroidID: in.AndroidID,
RealIP: reqctx.RealIP(ctx),
Language: reqctx.Language(ctx),
}

View File

@ -1,6 +1,7 @@
package logic
import (
"context"
"errors"
"strings"
"testing"
@ -19,7 +20,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
if err != nil {
t.Fatal(err)
}
svc := NewLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"})
svc := testLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"})
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
@ -35,11 +36,15 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
t.Fatalf("en validate %+v", err)
}
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
tok, err := svc.Login(ctx, &LoginInput{
Email: "test01@spark.com", VerifyCode: "any", AppID: testAppID,
DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007",
})
if err != nil {
t.Fatal(err)
}
if tok.UserID != 11 || tok.DeviceID != 7 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" {
if tok.UserID != 11 || tok.DeviceID != testIOSDeviceID || tok.AppID != uint32(testAppID) ||
tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" {
t.Fatalf("%+v", tok)
}
}
@ -51,10 +56,13 @@ func TestLoginServiceRefreshLogout(t *testing.T) {
}
users := StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"}
kicker := &kickRec{}
svc := NewLoginService(j, users)
svc := testLoginService(j, users)
svc.Sessions = kicker
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
tok, err := svc.Login(ctx, &LoginInput{
Email: "test01@spark.com", VerifyCode: "any", AppID: testAppID,
DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007",
})
if err != nil {
t.Fatal(err)
}
@ -66,7 +74,9 @@ func TestLoginServiceRefreshLogout(t *testing.T) {
if err != nil {
t.Fatal(err)
}
outCtx := reqctx.With(ctx, &reqctx.RequestContext{UserID: p.UserID, DeviceID: p.DeviceID, JTI: p.JTI, TokenExpires: p.ExpiresAt})
outCtx := reqctx.With(ctx, &reqctx.RequestContext{
UserID: p.UserID, DeviceID: p.DeviceID, AppID: p.AppID, JTI: p.JTI, TokenExpires: p.ExpiresAt,
})
if err := svc.Logout(outCtx); err != nil {
t.Fatal(err)
}
@ -81,6 +91,80 @@ func TestLoginServiceRefreshLogout(t *testing.T) {
}
}
func TestLoginServiceUnauthorized(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret"})
if err != nil {
t.Fatal(err)
}
in := LoginInput{
Email: "ada@example.com", VerifyCode: "000000", AppID: testAppID,
DeviceType: 6, IDFV: "web-test-idfv",
}
svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11})
svc.Devices = fixedDeviceRegistrar{id: 1}
if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("bad code: %v", err)
}
in.VerifyCode = "123456"
svc.Devices = nil
if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("nil devices: %v", err)
}
}
type kickRec struct{ n int }
func (k *kickRec) KickDevice(int64, uint64) { k.n++ }
func TestLoginRejectsInvalidAppID(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret"})
if err != nil {
t.Fatal(err)
}
svc := testLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 1})
ctx := t.Context()
_, err = svc.Login(ctx, &LoginInput{
Email: "test01@spark.com", VerifyCode: "x", AppID: 0,
DeviceType: 1, IDFV: "00000000-0000-0000-0000-000000000007",
})
if err == nil {
t.Fatal("expected missing app_id")
}
_, err = svc.Login(ctx, &LoginInput{
Email: "test01@spark.com", VerifyCode: "x", AppID: testAppID,
DeviceType: 1,
})
if err == nil {
t.Fatal("expected missing idfv")
}
}
func TestJWTTripleModeTwoApps(t *testing.T) {
ctx := context.Background()
pairs := NewMemoryPairsMode(PairKeyTriple)
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Pairs: pairs, PairKeyMode: PairKeyTriple})
if err != nil {
t.Fatal(err)
}
a, err := j.Issue(ctx, 1, testIOSDeviceID, 1)
if err != nil {
t.Fatal(err)
}
b, err := j.Issue(ctx, 1, testIOSDeviceID, 2)
if err != nil {
t.Fatal(err)
}
if a.RefreshToken == b.RefreshToken {
t.Fatal("different apps should have different refresh pairs")
}
if _, err := j.Refresh(ctx, a.RefreshToken); err != nil {
t.Fatal(err)
}
if _, err := j.Refresh(ctx, b.RefreshToken); err != nil {
t.Fatal(err)
}
}
func TestStaticAuthenticate(t *testing.T) {
s := Static{Token: "abc", UserID: 1, DeviceID: 2}
p, err := s.Authenticate(t.Context(), "abc")
@ -92,26 +176,6 @@ func TestStaticAuthenticate(t *testing.T) {
}
}
type kickRec struct{ n int }
func (k *kickRec) KickDevice(int64, uint64) { k.n++ }
func TestLoginServiceUnauthorized(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret"})
if err != nil {
t.Fatal(err)
}
in := LoginInput{Email: "ada@example.com", VerifyCode: "000000", DeviceID: 1, DeviceType: "web"}
svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11})
if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("bad code: %v", err)
}
in.VerifyCode = "123456"
if _, err = NewLoginService(j, nil).Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
t.Fatalf("nil verifier: %v", err)
}
}
func TestLoginServiceRecordsContextDevice(t *testing.T) {
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
if err != nil {
@ -125,21 +189,26 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
store := smsmodel.NewMemoryStore()
smsSvc := smslogic.NewService(store, nil, nil, nil)
users := userlogic.NewService(repo, smsSvc, idgen, nil)
svc := NewLoginService(j, users)
svc := testLoginService(j, users)
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
})
tok, err := svc.Login(ctx, &LoginInput{
Email: "test07@spark.com", VerifyCode: "x", DeviceID: 9, Platform: "android", DeviceName: "Pixel",
Email: "test07@spark.com", VerifyCode: "x", AppID: testAppID,
DeviceType: 2, DeviceName: "Pixel", AndroidID: "spark-test-android-9",
})
if err != nil {
t.Fatal(err)
}
dev, err := repo.GetDevice(ctx, tok.UserID, 9)
dev, err := repo.GetDevice(ctx, tok.UserID, testAndroidDeviceID)
if err != nil {
t.Fatal(err)
}
if dev.DeviceType != "android" || dev.DeviceName != "Pixel" || dev.LastIP != "198.51.100.9" || dev.AppVersion != "2.1.0" || dev.Language != "en" {
if dev.DeviceID != testAndroidDeviceID || dev.LastLoginAt.IsZero() {
t.Fatalf("%+v", dev)
}
logs := repo.LoginLogs()
if len(logs) == 0 || logs[len(logs)-1].DeviceType != "android" || logs[len(logs)-1].AppVersion != "2.1.0" || logs[len(logs)-1].Language != "en" {
t.Fatalf("logs %+v", logs)
}
}

View File

@ -0,0 +1,40 @@
package logic
import (
"context"
deviceentity "spark-server/internal/module/device/entity"
devicelogic "spark-server/internal/module/device/logic"
devicemodel "spark-server/internal/module/device/model"
)
var (
testIOSDeviceID = mustTestDeviceID(deviceentity.TypeIOS, "00000000-0000-0000-0000-000000000007", "")
testAndroidDeviceID = mustTestDeviceID(deviceentity.TypeAndroid, "", "spark-test-android-9")
)
const testAppID uint8 = 1
func mustTestDeviceID(dtype deviceentity.DeviceType, idfv, androidID string) uint64 {
id, _, err := devicelogic.DeviceIDFromFingerprint(devicelogic.FingerprintInput{
DeviceType: dtype, IDFV: idfv, AndroidID: androidID,
})
if err != nil || id == 0 {
panic(err)
}
return id
}
func testLoginService(j *JWT, users Verifier) *LoginService {
reg := devicemodel.NewMemoryRegistry()
devices := devicelogic.NewService(reg)
svc := NewLoginService(j, users)
svc.Devices = devices
return svc
}
type fixedDeviceRegistrar struct{ id uint64 }
func (f fixedDeviceRegistrar) Register(_ context.Context, _ devicelogic.RegisterInput) (uint64, error) {
return f.id, nil
}

View File

@ -0,0 +1,39 @@
package logic
import (
"strconv"
)
// PairKeyMode controls refresh token storage key shape.
type PairKeyMode string
const (
PairKeyTriple PairKeyMode = "triple" // (user_id, device_id, app_id)
PairKeyPair PairKeyMode = "pair" // (user_id, device_id)
)
func (m PairKeyMode) Valid() bool {
return m == PairKeyTriple || m == PairKeyPair
}
func normalizePairKeyMode(m PairKeyMode) PairKeyMode {
if m.Valid() {
return m
}
return PairKeyTriple
}
func storageKey(mode PairKeyMode, userID int64, deviceID uint64, appID uint32) string {
if normalizePairKeyMode(mode) == PairKeyPair {
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
}
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10) + ":" + strconv.FormatUint(uint64(appID), 10)
}
func userKeyPrefix(userID int64) string {
return strconv.FormatInt(userID, 10) + ":"
}
func deviceKeyPrefix(userID int64, deviceID uint64) string {
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
}

View File

@ -26,20 +26,23 @@ type TokenPair struct {
// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both.
type PairStore interface {
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
Delete(ctx context.Context, userID int64, deviceID uint64) error
Put(ctx context.Context, userID int64, deviceID uint64, appID uint32, pair TokenPair, ttl time.Duration) error
Get(ctx context.Context, userID int64, deviceID uint64, appID uint32) (TokenPair, error)
Delete(ctx context.Context, userID int64, deviceID uint64, appID uint32) error
ListByUser(ctx context.Context, userID int64) ([]DevicePair, error)
DeleteAllOnDevice(ctx context.Context, userID int64, deviceID uint64) error
}
// DevicePair is the live token pair for one device of a user.
type DevicePair struct {
DeviceID uint64
AppID uint32
Pair TokenPair
}
type MemoryPairs struct {
mu sync.Mutex
mode PairKeyMode
items map[string]storedPair
}
@ -49,49 +52,67 @@ type storedPair struct {
}
func NewMemoryPairs() *MemoryPairs {
return &MemoryPairs{items: make(map[string]storedPair)}
return NewMemoryPairsMode(PairKeyTriple)
}
func pairKey(userID int64, deviceID uint64) string {
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
func NewMemoryPairsMode(mode PairKeyMode) *MemoryPairs {
return &MemoryPairs{mode: normalizePairKeyMode(mode), items: make(map[string]storedPair)}
}
func (s *MemoryPairs) Put(_ context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
func (s *MemoryPairs) Put(_ context.Context, userID int64, deviceID uint64, appID uint32, pair TokenPair, ttl time.Duration) error {
if userID <= 0 || deviceID == 0 || ttl <= 0 {
return nil
}
key := storageKey(s.mode, userID, deviceID, appID)
s.mu.Lock()
s.items[pairKey(userID, deviceID)] = storedPair{pair: pair, exp: time.Now().Add(ttl).UnixNano()}
s.items[key] = storedPair{pair: pair, exp: time.Now().Add(ttl).UnixNano()}
s.mu.Unlock()
return nil
}
func (s *MemoryPairs) Get(_ context.Context, userID int64, deviceID uint64) (TokenPair, error) {
func (s *MemoryPairs) Get(_ context.Context, userID int64, deviceID uint64, appID uint32) (TokenPair, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, ok := s.items[pairKey(userID, deviceID)]
key := storageKey(s.mode, userID, deviceID, appID)
row, ok := s.items[key]
if !ok {
return TokenPair{}, nil
}
if time.Now().UnixNano() >= row.exp {
delete(s.items, pairKey(userID, deviceID))
delete(s.items, key)
return TokenPair{}, nil
}
return row.pair, nil
}
func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) error {
func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64, appID uint32) error {
s.mu.Lock()
delete(s.items, pairKey(userID, deviceID))
delete(s.items, storageKey(s.mode, userID, deviceID, appID))
s.mu.Unlock()
return nil
}
func (s *MemoryPairs) DeleteAllOnDevice(_ context.Context, userID int64, deviceID uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.mode == PairKeyPair {
delete(s.items, storageKey(s.mode, userID, deviceID, 0))
return nil
}
prefix := deviceKeyPrefix(userID, deviceID) + ":"
for key := range s.items {
if strings.HasPrefix(key, prefix) {
delete(s.items, key)
}
}
return nil
}
func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair, error) {
if userID <= 0 {
return nil, nil
}
prefix := strconv.FormatInt(userID, 10) + ":"
prefix := userKeyPrefix(userID)
now := time.Now().UnixNano()
s.mu.Lock()
defer s.mu.Unlock()
@ -105,32 +126,58 @@ func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair,
delete(s.items, key)
continue
}
did, err := strconv.ParseUint(key[len(prefix):], 10, 64)
if err != nil || did == 0 {
did, aid, ok := parsePairKey(key, prefix)
if !ok || did == 0 {
continue
}
out = append(out, DevicePair{DeviceID: did, Pair: row.pair})
_ = aid
out = append(out, DevicePair{DeviceID: did, AppID: aid, Pair: row.pair})
}
return out, nil
}
func parsePairKey(key, userPrefix string) (deviceID uint64, appID uint32, ok bool) {
rest := strings.TrimPrefix(key, userPrefix)
parts := strings.Split(rest, ":")
if len(parts) < 2 {
return 0, 0, false
}
did, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil || did == 0 {
return 0, 0, false
}
if len(parts) == 2 {
return did, 0, true
}
aid64, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return did, 0, true
}
return did, uint32(aid64), true
}
type RedisPairs struct {
rdb *redis.Client
prefix string
mode PairKeyMode
}
func NewRedisPairs(rdb *redis.Client, prefix string) *RedisPairs {
return NewRedisPairsMode(rdb, prefix, PairKeyTriple)
}
func NewRedisPairsMode(rdb *redis.Client, prefix string, mode PairKeyMode) *RedisPairs {
if prefix == "" {
prefix = "refresh:"
}
return &RedisPairs{rdb: rdb, prefix: prefix}
return &RedisPairs{rdb: rdb, prefix: prefix, mode: normalizePairKeyMode(mode)}
}
func (s *RedisPairs) key(userID int64, deviceID uint64) string {
return s.prefix + pairKey(userID, deviceID)
func (s *RedisPairs) key(userID int64, deviceID uint64, appID uint32) string {
return s.prefix + storageKey(s.mode, userID, deviceID, appID)
}
func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, appID uint32, pair TokenPair, ttl time.Duration) error {
if s == nil || s.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
@ -141,14 +188,14 @@ func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, pai
if err != nil {
return err
}
return s.rdb.Set(ctx, s.key(userID, deviceID), raw, ttl).Err()
return s.rdb.Set(ctx, s.key(userID, deviceID, appID), raw, ttl).Err()
}
func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) {
func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64, appID uint32) (TokenPair, error) {
if s == nil || s.rdb == nil {
return TokenPair{}, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
raw, err := s.rdb.Get(ctx, s.key(userID, deviceID)).Bytes()
raw, err := s.rdb.Get(ctx, s.key(userID, deviceID, appID)).Bytes()
if err == redis.Nil {
return TokenPair{}, nil
}
@ -162,11 +209,38 @@ func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (To
return pair, nil
}
func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64) error {
func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64, appID uint32) error {
if s == nil || s.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
return s.rdb.Del(ctx, s.key(userID, deviceID)).Err()
return s.rdb.Del(ctx, s.key(userID, deviceID, appID)).Err()
}
func (s *RedisPairs) DeleteAllOnDevice(ctx context.Context, userID int64, deviceID uint64) error {
if s == nil || s.rdb == nil {
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
}
if s.mode == PairKeyPair {
return s.rdb.Del(ctx, s.key(userID, deviceID, 0)).Err()
}
pattern := s.prefix + deviceKeyPrefix(userID, deviceID) + ":*"
var cursor uint64
for {
keys, next, err := s.rdb.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return err
}
if len(keys) > 0 {
if err := s.rdb.Del(ctx, keys...).Err(); err != nil {
return err
}
}
cursor = next
if cursor == 0 {
break
}
}
return nil
}
func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair, error) {
@ -176,7 +250,7 @@ func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair
if userID <= 0 {
return nil, nil
}
prefix := s.prefix + strconv.FormatInt(userID, 10) + ":"
prefix := s.prefix + userKeyPrefix(userID)
var cursor uint64
out := make([]DevicePair, 0)
for {
@ -185,7 +259,12 @@ func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair
return nil, err
}
for _, key := range keys {
did, err := strconv.ParseUint(strings.TrimPrefix(key, prefix), 10, 64)
rest := strings.TrimPrefix(key, prefix)
parts := strings.Split(rest, ":")
if len(parts) < 1 {
continue
}
did, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil || did == 0 {
continue
}
@ -196,11 +275,18 @@ func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair
if err != nil {
return nil, err
}
var aid uint32
if len(parts) >= 2 {
aid64, err := strconv.ParseUint(parts[1], 10, 32)
if err == nil {
aid = uint32(aid64)
}
}
var pair TokenPair
if err := json.Unmarshal(raw, &pair); err != nil {
continue
}
out = append(out, DevicePair{DeviceID: did, Pair: pair})
out = append(out, DevicePair{DeviceID: did, AppID: aid, Pair: pair})
}
cursor = next
if cursor == 0 {

View File

@ -0,0 +1,82 @@
// Package logictest wires login + in-memory device registry for HTTP/integration tests.
package logictest
import (
"encoding/json"
authlogic "spark-server/internal/module/auth/logic"
deviceentity "spark-server/internal/module/device/entity"
devicelogic "spark-server/internal/module/device/logic"
devicemodel "spark-server/internal/module/device/model"
)
const (
TestAppID = uint8(1)
TestIOSIDFV = "00000000-0000-0000-0000-000000000007"
TestAndroidID = "spark-test-android-9"
)
var (
TestIOSDeviceID = mustDeviceID(deviceentity.TypeIOS, TestIOSIDFV, "")
TestAndroidDeviceID = mustDeviceID(deviceentity.TypeAndroid, "", TestAndroidID)
)
func mustDeviceID(dtype deviceentity.DeviceType, idfv, androidID string) uint64 {
id, _, err := devicelogic.DeviceIDFromFingerprint(devicelogic.FingerprintInput{
DeviceType: dtype,
IDFV: idfv,
AndroidID: androidID,
})
if err != nil || id == 0 {
panic(err)
}
return id
}
// LoginService returns LoginService with memory device registry.
func LoginService(j authlogic.Issuer, users authlogic.Verifier) *authlogic.LoginService {
reg := devicemodel.NewMemoryRegistry()
devices := devicelogic.NewService(reg)
svc := authlogic.NewLoginService(j, users)
svc.Devices = devices
return svc
}
// LoginBodyJSON is a minimal iOS login body for tests (stable device_id via TestIOSIDFV).
func LoginBodyJSON(email, verifyCode string) string {
return LoginBodyJSONNamed(email, verifyCode, "")
}
// LoginBodyJSONNamed adds optional device_name.
func LoginBodyJSONNamed(email, verifyCode, deviceName string) string {
m := map[string]any{
"email": email,
"verify_code": verifyCode,
"app_id": TestAppID,
"device_type": int(deviceentity.TypeIOS),
"idfv": TestIOSIDFV,
}
if deviceName != "" {
m["device_name"] = deviceName
}
b, _ := json.Marshal(m)
return string(b)
}
// LoginBodyJSONWithDevice builds login JSON with explicit identity fields.
func LoginBodyJSONWithDevice(email, verifyCode string, deviceType uint8, idfv, androidID string) string {
b, _ := json.Marshal(map[string]any{
"email": email,
"verify_code": verifyCode,
"app_id": TestAppID,
"device_type": deviceType,
"idfv": idfv,
"android_id": androidID,
})
return string(b)
}
// LoginBodyJSONAndroid is login with android_id (stable TestAndroidDeviceID).
func LoginBodyJSONAndroid(email, verifyCode string) string {
return LoginBodyJSONWithDevice(email, verifyCode, uint8(deviceentity.TypeAndroid), "", TestAndroidID)
}

View File

@ -0,0 +1,13 @@
package entity
// AppID identifies a client application (independent package/signing).
type AppID uint8
const (
AppUnknown AppID = 0
AppSparkIM AppID = 1
)
func (a AppID) Valid() bool {
return a > 0
}

View File

@ -0,0 +1,68 @@
package entity
import userentity "spark-server/internal/module/user/entity"
// DeviceType is stored as TINYINT in devices.device_type.
type DeviceType uint8
const (
TypeUnknown DeviceType = 0
TypeIOS DeviceType = 1
TypeAndroid DeviceType = 2
TypeHarmony DeviceType = 3
TypeWindows DeviceType = 4
TypeMacOS DeviceType = 5
TypeWeb DeviceType = 6
)
func DeviceTypeFromPlatform(platform string) DeviceType {
switch userentity.NormalizeDeviceType(platform) {
case userentity.DeviceIOS:
return TypeIOS
case userentity.DeviceAndroid:
return TypeAndroid
case userentity.DeviceHarmony:
return TypeHarmony
case userentity.DeviceWindows:
return TypeWindows
case userentity.DeviceMacOS:
return TypeMacOS
case userentity.DeviceWeb:
return TypeWeb
default:
return TypeUnknown
}
}
func (t DeviceType) Valid() bool {
return t >= TypeIOS && t <= TypeWeb
}
// DeviceTypeFromWire maps login/meta wire value to DeviceType.
func DeviceTypeFromWire(v uint8) DeviceType {
t := DeviceType(v)
if t.Valid() {
return t
}
return TypeUnknown
}
// PlatformName is the audit/log string for user_login_logs.device_type.
func (t DeviceType) PlatformName() string {
switch t {
case TypeIOS:
return userentity.DeviceIOS
case TypeAndroid:
return userentity.DeviceAndroid
case TypeHarmony:
return userentity.DeviceHarmony
case TypeWindows:
return userentity.DeviceWindows
case TypeMacOS:
return userentity.DeviceMacOS
case TypeWeb:
return userentity.DeviceWeb
default:
return ""
}
}

View File

@ -0,0 +1,68 @@
package logic
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
deviceentity "spark-server/internal/module/device/entity"
)
// FingerprintInput is the physical identity for one login/register.
type FingerprintInput struct {
DeviceType deviceentity.DeviceType
IDFV string
AndroidID string
}
func fingerprintHash(in FingerprintInput) (string, error) {
if !in.DeviceType.Valid() {
return "", fmt.Errorf("device_type")
}
idfv := strings.TrimSpace(in.IDFV)
androidID := strings.TrimSpace(in.AndroidID)
var tail string
switch in.DeviceType {
case deviceentity.TypeIOS, deviceentity.TypeMacOS:
if idfv == "" {
return "", fmt.Errorf("idfv")
}
tail = idfv
case deviceentity.TypeAndroid, deviceentity.TypeHarmony:
if androidID == "" {
return "", fmt.Errorf("android_id")
}
tail = androidID
default:
switch {
case idfv != "":
tail = idfv
case androidID != "":
tail = androidID
default:
return "", fmt.Errorf("device_id")
}
}
payload := fmt.Sprintf("%d:%s", in.DeviceType, tail)
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:]), nil
}
// DeviceIDFromFingerprint derives the stable business device id.
func DeviceIDFromFingerprint(in FingerprintInput) (uint64, string, error) {
hash, err := fingerprintHash(in)
if err != nil {
return 0, "", err
}
sum := sha256.Sum256([]byte(hash))
id := binary.BigEndian.Uint64(sum[:8])
if id == 0 {
id = binary.BigEndian.Uint64(sum[1:9])
}
if id == 0 {
id = 1
}
return id, hash, nil
}

View File

@ -0,0 +1,33 @@
package logic
import (
"testing"
deviceentity "spark-server/internal/module/device/entity"
)
func TestDeviceIDFromFingerprintIOS(t *testing.T) {
id, hash, err := DeviceIDFromFingerprint(FingerprintInput{
DeviceType: deviceentity.TypeIOS,
IDFV: "00000000-0000-0000-0000-000000000007",
})
if err != nil || hash == "" || id == 0 {
t.Fatalf("id=%d hash=%q err=%v", id, hash, err)
}
id2, _, err := DeviceIDFromFingerprint(FingerprintInput{
DeviceType: deviceentity.TypeIOS,
IDFV: "00000000-0000-0000-0000-000000000007",
})
if err != nil || id2 != id {
t.Fatalf("stable %d %d", id, id2)
}
}
func TestDeviceIDFromFingerprintRequiresFields(t *testing.T) {
if _, _, err := DeviceIDFromFingerprint(FingerprintInput{DeviceType: deviceentity.TypeIOS}); err == nil {
t.Fatal("ios without idfv")
}
if _, _, err := DeviceIDFromFingerprint(FingerprintInput{DeviceType: deviceentity.TypeAndroid}); err == nil {
t.Fatal("android without id")
}
}

View File

@ -0,0 +1,78 @@
package logic
import (
"context"
"strings"
"time"
deviceentity "spark-server/internal/module/device/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
)
// RegisterInput is supplied at login before JWT issue.
type RegisterInput struct {
DeviceType deviceentity.DeviceType
DeviceName string
IDFV string
AndroidID string
ClientIP string
}
// RegistryRow is the persisted physical device.
type RegistryRow struct {
DeviceID uint64
FingerprintHash string
DeviceType deviceentity.DeviceType
DeviceName string
IDFV string
AndroidID string
LastClientIP string
UpdatedAt time.Time
}
type Registry interface {
Upsert(ctx context.Context, row *RegistryRow) error
}
type Service struct {
reg Registry
}
func NewService(reg Registry) *Service {
return &Service{reg: reg}
}
func (s *Service) Register(ctx context.Context, in RegisterInput) (uint64, error) {
if s == nil || s.reg == nil {
return 0, errs.ErrAvailable
}
deviceID, hash, err := DeviceIDFromFingerprint(FingerprintInput{
DeviceType: in.DeviceType,
IDFV: in.IDFV,
AndroidID: in.AndroidID,
})
if err != nil {
return 0, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyDeviceIdentityInvalid, nil))
}
now := time.Now()
ip := strings.TrimSpace(in.ClientIP)
if ip == "" {
ip = reqctx.RealIP(ctx)
}
row := &RegistryRow{
DeviceID: deviceID,
FingerprintHash: hash,
DeviceType: in.DeviceType,
DeviceName: strings.TrimSpace(in.DeviceName),
IDFV: strings.TrimSpace(in.IDFV),
AndroidID: strings.TrimSpace(in.AndroidID),
LastClientIP: ip,
UpdatedAt: now,
}
if err := s.reg.Upsert(ctx, row); err != nil {
return 0, err
}
return deviceID, nil
}

View File

@ -0,0 +1,54 @@
package logic
import (
"context"
"sync"
"testing"
"time"
deviceentity "spark-server/internal/module/device/entity"
)
type memReg struct {
mu sync.Mutex
m map[string]*RegistryRow
}
func (m *memReg) Upsert(_ context.Context, row *RegistryRow) error {
if row == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.m == nil {
m.m = make(map[string]*RegistryRow)
}
cp := *row
if cp.UpdatedAt.IsZero() {
cp.UpdatedAt = time.Now()
}
m.m[row.FingerprintHash] = &cp
return nil
}
func TestRegisterUpsertsDevice(t *testing.T) {
svc := NewService(&memReg{})
ctx := context.Background()
id1, err := svc.Register(ctx, RegisterInput{
DeviceType: deviceentity.TypeIOS,
IDFV: "00000000-0000-0000-0000-000000000007",
DeviceName: "Phone",
ClientIP: "203.0.113.1",
})
if err != nil || id1 == 0 {
t.Fatalf("register %d %v", id1, err)
}
id2, err := svc.Register(ctx, RegisterInput{
DeviceType: deviceentity.TypeIOS,
IDFV: "00000000-0000-0000-0000-000000000007",
DeviceName: "Phone 2",
})
if err != nil || id2 != id1 {
t.Fatalf("same device %d %d %v", id1, id2, err)
}
}

View File

@ -0,0 +1,48 @@
package model
import (
"context"
"sync"
"time"
devicelogic "spark-server/internal/module/device/logic"
)
type MemoryRegistry struct {
mu sync.Mutex
byFP map[string]*devicelogic.RegistryRow
byDID map[uint64]*devicelogic.RegistryRow
}
func NewMemoryRegistry() *MemoryRegistry {
return &MemoryRegistry{
byFP: make(map[string]*devicelogic.RegistryRow),
byDID: make(map[uint64]*devicelogic.RegistryRow),
}
}
func (m *MemoryRegistry) Upsert(_ context.Context, row *devicelogic.RegistryRow) error {
if row == nil {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.byFP[row.FingerprintHash]; ok {
cp := *old
cp.DeviceName = row.DeviceName
cp.IDFV = row.IDFV
cp.AndroidID = row.AndroidID
cp.LastClientIP = row.LastClientIP
cp.UpdatedAt = time.Now()
m.byFP[row.FingerprintHash] = &cp
m.byDID[cp.DeviceID] = &cp
return nil
}
cp := *row
if cp.UpdatedAt.IsZero() {
cp.UpdatedAt = time.Now()
}
m.byFP[row.FingerprintHash] = &cp
m.byDID[row.DeviceID] = &cp
return nil
}

View File

@ -0,0 +1,78 @@
package model
import (
"context"
"errors"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
devicelogic "spark-server/internal/module/device/logic"
"spark-server/internal/pkg/errs"
)
type DeviceRow struct {
DeviceID uint64 `gorm:"column:device_id;primaryKey;comment:JWT did"`
FingerprintHash string `gorm:"column:fingerprint_hash;uniqueIndex:uk_fingerprint;comment:物理指纹"`
DeviceType uint8 `gorm:"column:device_type;comment:device_type 枚举"`
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
IDFV string `gorm:"column:idfv;comment:iOS IDFV"`
AndroidID string `gorm:"column:android_id;comment:Android ID"`
LastClientIP string `gorm:"column:last_client_ip;comment:最近 RealIP"`
CreatedAt time.Time `gorm:"column:created_at;comment:首次注册"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近 touch"`
}
func (DeviceRow) TableName() string { return "devices" }
type RegistryRepo struct {
gdb *gorm.DB
}
func NewRegistryRepo(gdb *gorm.DB) *RegistryRepo {
return &RegistryRepo{gdb: gdb}
}
func (r *RegistryRepo) Upsert(ctx context.Context, row *devicelogic.RegistryRow) error {
if r == nil || r.gdb == nil || row == nil {
return errs.ErrAvailable
}
now := time.Now()
if row.UpdatedAt.IsZero() {
row.UpdatedAt = now
}
dr := DeviceRow{
DeviceID: row.DeviceID,
FingerprintHash: row.FingerprintHash,
DeviceType: uint8(row.DeviceType),
DeviceName: row.DeviceName,
IDFV: row.IDFV,
AndroidID: row.AndroidID,
LastClientIP: row.LastClientIP,
CreatedAt: now,
UpdatedAt: row.UpdatedAt,
}
err := r.gdb.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "fingerprint_hash"}},
DoUpdates: clause.AssignmentColumns([]string{
"device_name", "idfv", "android_id", "last_client_ip", "updated_at",
}),
}).Create(&dr).Error
return mapDBErr(err)
}
func mapDBErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrNotFound
}
var me *mysqldriver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return errs.ErrDuplicate
}
return errs.ErrAvailable.WithError("device-db", err)
}

View File

@ -26,6 +26,7 @@ type Catalog struct {
TCPCommand []Item `json:"tcp_command"`
EnvelopeCode []Item `json:"envelope_code"`
DeviceType []Item `json:"device_type"`
AppID []Item `json:"app_id"`
Language []Item `json:"language"`
UploadPurpose []Item `json:"upload_purpose"`
SystemKind []Item `json:"system_kind"`

View File

@ -27,6 +27,7 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
TCPCommand: tcpCommand(lang),
EnvelopeCode: envelopeCode(lang),
DeviceType: deviceType(lang),
AppID: appID(lang),
Language: language(lang),
UploadPurpose: uploadPurpose(lang),
SystemKind: systemKind(lang),

View File

@ -0,0 +1,13 @@
package logic
import (
deviceentity "spark-server/internal/module/device/entity"
"spark-server/internal/module/meta/entity"
"spark-server/internal/pkg/i18n"
)
func appID(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "app_id", "spark_im", int(deviceentity.AppSparkIM)),
}
}

View File

@ -0,0 +1,18 @@
package logic
import (
deviceentity "spark-server/internal/module/device/entity"
"spark-server/internal/module/meta/entity"
"spark-server/internal/pkg/i18n"
)
func deviceType(lang i18n.Lang) []entity.Item {
return []entity.Item{
item(lang, "device_type", "ios", int(deviceentity.TypeIOS)),
item(lang, "device_type", "android", int(deviceentity.TypeAndroid)),
item(lang, "device_type", "harmony", int(deviceentity.TypeHarmony)),
item(lang, "device_type", "windows", int(deviceentity.TypeWindows)),
item(lang, "device_type", "macos", int(deviceentity.TypeMacOS)),
item(lang, "device_type", "web", int(deviceentity.TypeWeb)),
}
}

View File

@ -32,6 +32,9 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
}
if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" {
t.Fatalf("device_type %+v", c.DeviceType)
}
en := Catalog(i18n.En)
if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" {
t.Fatalf("en labels %+v %+v", en.Gender, en.MessageType)
@ -41,6 +44,7 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
c.AppID,
} {
if len(items) == 0 {
t.Fatal("empty set")

View File

@ -60,10 +60,6 @@ func code(lang i18n.Lang, name string, err *errs.AppError) entity.Item {
return entity.Item{Value: err.Code, Name: name, Label: i18n.ErrorMsg(lang, err.Code)}
}
func deviceType(lang i18n.Lang) []entity.Item {
return strSet(lang, "device_type", "ios", "android", "harmony", "windows", "macos", "web")
}
func language(lang i18n.Lang) []entity.Item {
return strSet(lang, "language", string(i18n.ZhCN), string(i18n.En), string(i18n.ZhTW))
}

View File

@ -23,29 +23,28 @@ var deviceTypes = map[string]struct{}{
DeviceWeb: {},
}
// Device is a login device row for one user.
type Device struct {
ID int64 // table AUTO_INCREMENT; business key is (UserID, DeviceID)
UserID int64
DeviceID uint64
DeviceType string
DeviceName string
AppVersion string
Language string
LastIP string
LastLoginAt time.Time
CreatedAt time.Time
// UserDeviceLink is one user bound to one physical device (no app_id on this row).
type UserDeviceLink struct {
ID int64
UserID int64
DeviceID uint64
LastLoginAt time.Time
LastActiveAt *time.Time
CreatedAt time.Time
}
// LoginDevice is the device snapshot supplied at login (not a table row).
// LoginDevice is the device snapshot supplied at login.
type LoginDevice struct {
DeviceID uint64
AppID uint8
DeviceType string
Platform string // alias of DeviceType; HTTP still accepts `platform`
Platform string
DeviceName string
AppVersion string
Language string
RealIP string
IDFV string
AndroidID string
}
func NormalizeDeviceType(s string) string {

View File

@ -6,7 +6,7 @@ import (
"time"
)
var testEmailRe = regexp.MustCompile(`^test(0[1-9]|10)@spark\.com$`)
var testEmailRe = regexp.MustCompile(`^test(0[1-9]|[1-9][0-9]|100)@spark\.com$`)
// EmailBinding maps one normalized email to one user.
type EmailBinding struct {

View File

@ -3,13 +3,19 @@ package entity
import "testing"
func TestIsTestEmail(t *testing.T) {
ok := []string{"test01@spark.com", "test10@spark.com", "TEST05@SPARK.COM"}
ok := []string{
"test01@spark.com", "test09@spark.com", "test10@spark.com", "test99@spark.com",
"test100@spark.com", "TEST05@SPARK.COM",
}
for _, e := range ok {
if !IsTestEmail(e) {
t.Fatalf("want test %s", e)
}
}
bad := []string{"test00@spark.com", "test11@spark.com", "test1@spark.com", "other@spark.com", "test01@example.com"}
bad := []string{
"test00@spark.com", "test101@spark.com", "test1@spark.com",
"other@spark.com", "test01@example.com",
}
for _, e := range bad {
if IsTestEmail(e) {
t.Fatalf("not test %s", e)

View File

@ -7,6 +7,7 @@ type LoginLog struct {
ID int64
UserID int64
DeviceID uint64
AppID uint8
DeviceType string
DeviceName string
AppVersion string

View File

@ -13,7 +13,7 @@ import (
"spark-server/internal/pkg/reqctx"
)
// AuthenticateEmail verifies OTP (test01–test10@spark.com accept any code),
// AuthenticateEmail verifies OTP (test01–test100@spark.com accept any code),
// creates the user on first login, and upserts the login device.
func (s *Service) AuthenticateEmail(ctx context.Context, email, verifyCode string, device *entity.LoginDevice) (*entity.User, error) {
if s == nil || s.repo == nil || s.ids == nil {
@ -128,24 +128,13 @@ func (s *Service) touchDevice(ctx context.Context, userID int64, device *entity.
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
}
now := time.Now()
ip := strings.TrimSpace(device.RealIP)
if ip == "" {
ip = reqctx.RealIP(ctx)
}
lang := strings.TrimSpace(device.Language)
if lang == "" {
lang = reqctx.Language(ctx)
}
return s.repo.UpsertDevice(ctx, &entity.Device{
UserID: userID,
DeviceID: device.DeviceID,
DeviceType: typ,
DeviceName: device.DeviceName,
AppVersion: device.AppVersion,
Language: lang,
LastIP: ip,
LastLoginAt: now,
CreatedAt: now,
active := now
return s.repo.UpsertDevice(ctx, &entity.UserDeviceLink{
UserID: userID,
DeviceID: device.DeviceID,
LastLoginAt: now,
LastActiveAt: &active,
CreatedAt: now,
})
}
@ -164,6 +153,7 @@ func (s *Service) recordLogin(ctx context.Context, userID int64, device *entity.
return s.repo.InsertLoginLog(ctx, &entity.LoginLog{
UserID: userID,
DeviceID: device.DeviceID,
AppID: device.AppID,
DeviceType: entity.DeviceTypeOf(device),
DeviceName: device.DeviceName,
AppVersion: device.AppVersion,

View File

@ -68,7 +68,7 @@ func TestAuthenticateTestEmailAnyCode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if dev.DeviceType != "ios" || dev.LastIP != "203.0.113.10" || dev.AppVersion != "" {
if dev.DeviceID != 7 || dev.LastLoginAt.IsZero() {
t.Fatalf("%+v", dev)
}
device.AppVersion = "1.0.0"
@ -77,17 +77,13 @@ func TestAuthenticateTestEmailAnyCode(t *testing.T) {
if _, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "x", device); err != nil {
t.Fatal(err)
}
dev, err = repo.GetDevice(ctx, u1.ID, 7)
if err != nil {
t.Fatal(err)
}
if dev.AppVersion != "1.0.0" || dev.Language != "zh-CN" {
t.Fatalf("%+v", dev)
}
logs := repo.LoginLogs()
if len(logs) < 2 || logs[0].DeviceType != entity.DeviceIOS || logs[0].DeviceID != 7 {
if len(logs) < 2 || logs[len(logs)-1].DeviceType != entity.DeviceIOS || logs[len(logs)-1].DeviceID != 7 {
t.Fatalf("logs %+v", logs)
}
if logs[len(logs)-1].AppVersion != "1.0.0" || logs[len(logs)-1].Language != "zh-CN" {
t.Fatalf("log context %+v", logs[len(logs)-1])
}
}
func TestStaleUIDQRCodeIsReplaced(t *testing.T) {

View File

@ -20,8 +20,9 @@ type Repository interface {
UpdateQRCode(ctx context.Context, userID int64, qrcode string) error
UpdateProfile(ctx context.Context, u *entity.User) error
TouchLogin(ctx context.Context, userID int64, at time.Time) error
UpsertDevice(ctx context.Context, d *entity.Device) error
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error)
UpsertDevice(ctx context.Context, d *entity.UserDeviceLink) error
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.UserDeviceLink, error)
TouchDeviceActive(ctx context.Context, userID int64, deviceID uint64, at time.Time) error
InsertLoginLog(ctx context.Context, log *entity.LoginLog) error
GetEmail(ctx context.Context, userID int64) (string, error)
GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error)

View File

@ -14,7 +14,7 @@ type MemoryRepo struct {
byID map[int64]*entity.User
byEmail map[string]int64
bySpark map[string]int64
devices map[deviceKey]*entity.Device
devices map[deviceKey]*entity.UserDeviceLink
logs []*entity.LoginLog
deleted map[int64]*entity.Deleted
deviceSeq int64
@ -31,7 +31,7 @@ func NewMemoryRepo() *MemoryRepo {
byID: make(map[int64]*entity.User),
byEmail: make(map[string]int64),
bySpark: make(map[string]int64),
devices: make(map[deviceKey]*entity.Device),
devices: make(map[deviceKey]*entity.UserDeviceLink),
logs: make([]*entity.LoginLog, 0),
deleted: make(map[int64]*entity.Deleted),
}
@ -133,7 +133,7 @@ func (m *MemoryRepo) TouchLogin(_ context.Context, userID int64, at time.Time) e
return nil
}
func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.Device) error {
func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.UserDeviceLink) error {
m.mu.Lock()
defer m.mu.Unlock()
key := deviceKey{UserID: d.UserID, DeviceID: d.DeviceID}
@ -149,7 +149,7 @@ func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.Device) error {
return nil
}
func (m *MemoryRepo) GetDevice(_ context.Context, userID int64, deviceID uint64) (*entity.Device, error) {
func (m *MemoryRepo) GetDevice(_ context.Context, userID int64, deviceID uint64) (*entity.UserDeviceLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.devices[deviceKey{UserID: userID, DeviceID: deviceID}]
@ -160,6 +160,18 @@ func (m *MemoryRepo) GetDevice(_ context.Context, userID int64, deviceID uint64)
return &cp, nil
}
func (m *MemoryRepo) TouchDeviceActive(_ context.Context, userID int64, deviceID uint64, at time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.devices[deviceKey{UserID: userID, DeviceID: deviceID}]
if !ok {
return errs.ErrNotFound
}
t := at
d.LastActiveAt = &t
return nil
}
func (m *MemoryRepo) UpdateQRCode(_ context.Context, userID int64, qrcode string) error {
m.mu.Lock()
defer m.mu.Unlock()

View File

@ -9,66 +9,56 @@ import (
"spark-server/internal/module/user/entity"
)
// UserDevice maps table user_devices: devices that have logged in.
type UserDevice struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
UserID int64 `gorm:"column:user_id;comment:所属用户"`
DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id,写入 JWT did"`
DeviceType string `gorm:"column:platform;comment:设备类型:ios/android/harmony/windows/macos/web"`
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
AppVersion string `gorm:"column:app_version;comment:最近一次登录的客户端版本"`
Language string `gorm:"column:language;comment:最近一次登录语言,如 zh-CN"`
LastIP string `gorm:"column:last_ip;comment:最近一次登录的 PROXY RealIP,禁止用环回地址"`
LastLoginAt time.Time `gorm:"column:last_login_at;comment:最近一次登录时间"`
CreatedAt time.Time `gorm:"column:created_at;comment:该设备首次登录时间"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
UserID int64 `gorm:"column:user_id;comment:所属用户"`
DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"`
LastLoginAt time.Time `gorm:"column:last_login_at;comment:该用户在此设备最近登录成功"`
LastActiveAt *time.Time `gorm:"column:last_active_at;comment:该用户在此设备最近活跃"`
CreatedAt time.Time `gorm:"column:created_at;comment:该用户首次在此设备绑定"`
}
func (UserDevice) TableName() string { return "user_devices" }
func (row *UserDevice) entity() *entity.Device {
func (row *UserDevice) entity() *entity.UserDeviceLink {
if row == nil {
return nil
}
return &entity.Device{
ID: row.ID,
UserID: row.UserID,
DeviceID: row.DeviceID,
DeviceType: row.DeviceType,
DeviceName: row.DeviceName,
AppVersion: row.AppVersion,
Language: row.Language,
LastIP: row.LastIP,
LastLoginAt: row.LastLoginAt,
CreatedAt: row.CreatedAt,
return &entity.UserDeviceLink{
ID: row.ID,
UserID: row.UserID,
DeviceID: row.DeviceID,
LastLoginAt: row.LastLoginAt,
LastActiveAt: row.LastActiveAt,
CreatedAt: row.CreatedAt,
}
}
func (m *Repo) UpsertDevice(ctx context.Context, d *entity.Device) error {
func (m *Repo) UpsertDevice(ctx context.Context, d *entity.UserDeviceLink) error {
if d == nil {
return nil
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := UserDevice{
UserID: d.UserID,
DeviceID: d.DeviceID,
DeviceType: d.DeviceType,
DeviceName: d.DeviceName,
AppVersion: d.AppVersion,
Language: d.Language,
LastIP: d.LastIP,
LastLoginAt: d.LastLoginAt,
CreatedAt: d.CreatedAt,
UserID: d.UserID,
DeviceID: d.DeviceID,
LastLoginAt: d.LastLoginAt,
LastActiveAt: d.LastActiveAt,
CreatedAt: d.CreatedAt,
}
err = db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"platform", "device_name", "app_version", "language", "last_ip", "last_login_at",
"last_login_at", "last_active_at",
}),
}).Create(&row).Error
return mapDBErr(err)
}
func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) {
func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.UserDeviceLink, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
@ -79,3 +69,17 @@ func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*e
}
return row.entity(), nil
}
func (m *Repo) TouchDeviceActive(ctx context.Context, userID int64, deviceID uint64, at time.Time) error {
db, err := m.session(ctx)
if err != nil {
return err
}
res := db.Model(&UserDevice{}).
Where("user_id = ? AND device_id = ?", userID, deviceID).
Update("last_active_at", at)
if res.Error != nil {
return mapDBErr(res.Error)
}
return nil
}

View File

@ -11,8 +11,9 @@ import (
type UserLoginLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
UserID int64 `gorm:"column:user_id;comment:登录用户"`
DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id"`
DeviceType string `gorm:"column:device_type;comment:设备类型:ios/android/harmony/windows/macos/web"`
DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id"`
AppID uint8 `gorm:"column:app_id;comment:当次登录 app_id"`
DeviceType string `gorm:"column:device_type;comment:设备类型"`
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
AppVersion string `gorm:"column:app_version;comment:客户端版本"`
Language string `gorm:"column:language;comment:登录语言"`
@ -33,6 +34,7 @@ func (m *Repo) InsertLoginLog(ctx context.Context, log *entity.LoginLog) error {
row := UserLoginLog{
UserID: log.UserID,
DeviceID: log.DeviceID,
AppID: log.AppID,
DeviceType: log.DeviceType,
DeviceName: log.DeviceName,
AppVersion: log.AppVersion,

View File

@ -23,6 +23,7 @@ func init() {
"enum.device_type.windows": {ZhCN: "Windows", En: "Windows", ZhTW: "Windows"},
"enum.device_type.macos": {ZhCN: "macOS", En: "macOS", ZhTW: "macOS"},
"enum.device_type.web": {ZhCN: "Web", En: "Web", ZhTW: "Web"},
"enum.app_id.spark_im": {ZhCN: "Spark IM", En: "Spark IM", ZhTW: "Spark IM"},
"enum.language.zh_CN": {ZhCN: "简体中文", En: "Simplified Chinese", ZhTW: "簡體中文"},
"enum.language.en": {ZhCN: "English", En: "English", ZhTW: "English"},
"enum.language.zh_TW": {ZhCN: "繁体中文", En: "Traditional Chinese", ZhTW: "繁體中文"},

View File

@ -41,5 +41,23 @@ func init() {
KeyLoginRefreshRequired: {
ZhCN: "请填写刷新令牌", En: "Please enter the refresh token", ZhTW: "請填寫重新整理令牌",
},
KeyLoginAppIDRequired: {
ZhCN: "请选择应用", En: "Please choose an app", ZhTW: "請選擇應用",
},
KeyLoginAppIDInvalid: {
ZhCN: "应用标识无效", En: "Invalid app", ZhTW: "應用標識無效",
},
KeyDeviceIdentityInvalid: {
ZhCN: "无法识别本机设备,请检查 idfv 或 android_id", En: "Device identity is invalid; check idfv or android_id", ZhTW: "無法識別本機裝置,請檢查 idfv 或 android_id",
},
KeyFieldAppID: {
ZhCN: "应用", En: "App", ZhTW: "應用",
},
KeyFieldIDFV: {
ZhCN: "IDFV", En: "IDFV", ZhTW: "IDFV",
},
KeyFieldAndroidID: {
ZhCN: "Android ID", En: "Android ID", ZhTW: "Android ID",
},
})
}

View File

@ -27,6 +27,9 @@ const (
KeyFieldPlatform = "field.platform"
KeyFieldDeviceName = "field.device_name"
KeyFieldDeviceType = "field.device_type"
KeyFieldAppID = "field.app_id"
KeyFieldIDFV = "field.idfv"
KeyFieldAndroidID = "field.android_id"
KeyFieldRefreshToken = "field.refresh_token"
KeyFieldNickname = "field.nickname"
KeyFieldAvatar = "field.avatar"
@ -67,6 +70,9 @@ const (
KeyLoginDeviceTypeRequired = "validate.login.device_type_required"
KeyLoginDeviceTypeInvalid = "validate.login.device_type_invalid"
KeyLoginRefreshRequired = "validate.login.refresh_required"
KeyLoginAppIDRequired = "validate.login.app_id_required"
KeyLoginAppIDInvalid = "validate.login.app_id_invalid"
KeyDeviceIdentityInvalid = "validate.device.identity_invalid"
KeyFriendTargetRequired = "validate.friend.target_required"
KeyFriendQueryRequired = "validate.friend.query_required"

View File

@ -26,6 +26,7 @@ type RequestContext struct {
RealIP string
JTI string
TokenExpires time.Time
AppID uint32
}
func With(ctx context.Context, rc *RequestContext) context.Context {

View File

@ -52,6 +52,7 @@ func Auth(svc authlogic.Service) gin.HandlerFunc {
}
rc.UserID = principal.UserID
rc.DeviceID = principal.DeviceID
rc.AppID = principal.AppID
rc.JTI = principal.JTI
rc.TokenExpires = principal.ExpiresAt
c.Next()

View File

@ -93,7 +93,7 @@ info:
### 调用顺序
1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。封控见 [docs/sms.md](../../../docs/sms.md)(IP 桶 + 同 target 60s 冷却,均 `42902`)。测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码。
1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。封控见 [docs/sms.md](../../../docs/sms.md)(IP 桶 + 同 target 60s 冷却,均 `42902`)。测试号 `test01@spark.com`~`test100@spark.com` 可跳过发码。
2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
@ -108,7 +108,7 @@ info:
X-Language: zh-CN
X-App-Version: 1.0.0
{"email":"test01@spark.com","verify_code":"000000","device_id":7,"device_type":"ios","device_name":"iPhone"}
{"email":"test01@spark.com","verify_code":"000000","app_id":1,"device_type":1,"idfv":"00000000-0000-0000-0000-000000000007","device_name":"iPhone"}
```
```http
@ -206,7 +206,7 @@ info:
public static void main(String[] args) throws Exception {
String login = httpPost(API + "/v1/auth/login",
"{\"email\":\"test01@spark.com\",\"verify_code\":\"000000\",\"device_id\":7,\"device_type\":\"android\"}");
"{\"email\":\"test01@spark.com\",\"verify_code\":\"000000\",\"app_id\":1,\"device_type\":2,\"android_id\":\"YOUR_ANDROID_ID\"}");
String token = jsonString(login, "access_token"); // 从 data.access_token 取出
SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault();
@ -298,7 +298,7 @@ info:
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("zh-CN", forHTTPHeaderField: "X-Language")
req.httpBody = """
{"email":"test01@spark.com","verify_code":"000000","device_id":7,"device_type":"ios","device_name":"iPhone"}
{"email":"test01@spark.com","verify_code":"000000","app_id":1,"device_type":1,"idfv":"00000000-0000-0000-0000-000000000007","device_name":"iPhone"}
""".data(using: .utf8)
let data = try URLSession.shared.send(req)
return try JSONDecoder().decode(LoginEnv.self, from: data).data.access_token
@ -521,7 +521,7 @@ paths:
2. **按 target**:同一「渠道 + purpose + 规范化 target」**60 秒内**只能成功触发一次发信/发短信;Redis 键 `sms:cd:*`(无 Redis 时为进程内内存)。
不同 purpose、不同邮箱/手机号互不影响。成功响应 `data.expires_in` 固定 **300**(码有效期秒数,与 60s 冷却无关)。
测试邮箱 `test01@spark.com`~`test10@spark.com` 不发信且不占用 target 冷却。详情 [docs/sms.md](../../../docs/sms.md)。
测试邮箱 `test01@spark.com`~`test100@spark.com` 不发信且不占用 target 冷却。详情 [docs/sms.md](../../../docs/sms.md)。
邮箱经 SMTP;手机走阿里云短信(按号段选运营商模板)。
operationId: sendVerificationCode
@ -550,12 +550,12 @@ paths:
summary: 邮箱验证码登录并签发 JWT
description: |
首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。
登录写入/更新 `user_devices`(device_type 存在 `platform` 列、device_name、app_version、language、last_ip),并追加 `user_login_logs`。
返回 access_token 与 refresh_token。测试账号任意验证码即可。
`device_type` 必填:ios / android / harmony / windows / macos / web(也接受旧字段 `platform`)。
登录 UPSERT `user_devices` 与 `devices`,并追加 `user_login_logs`(含 app_id)。
返回 access_token 与 refresh_token;`device_id` 由服务端生成。测试账号任意验证码即可。
`device_type` 必填:整数 1–6(见 meta `device_type`);可选 `platform` 同枚举作别名。
Header(可选):`X-Language` / `Accept-Language`、`X-App-Version`。不要带 `Authorization`。
Body(JSON):`email`、`verify_code`、`device_id`、`device_type`、`device_name`。`device_id` 只在登录 Body,之后改走 Header `X-Device-ID` 与 TCP AUTH。
Body:`email`、`verify_code`、`app_id`、`device_type`、`idfv`/`android_id`、可选 `device_name`。
operationId: login
parameters:
- $ref: "#/components/parameters/X-Language"
@ -570,8 +570,9 @@ paths:
example:
email: test01@spark.com
verify_code: "000000"
device_id: 7
device_type: ios
app_id: 1
device_type: 1
idfv: "00000000-0000-0000-0000-000000000007"
device_name: iPhone
responses:
"200":
@ -2041,8 +2042,12 @@ components:
enum: [1, 2, 3]
description: mailbox_events.event_type。1漏推消息 2撤回 3删除
DeviceType:
type: string
enum: [ios, android, harmony, windows, macos, web]
type: integer
format: int32
enum: [1, 2, 3, 4, 5, 6]
description: |
设备类型,见 meta `device_type`。1=ios 2=android 3=harmony 4=windows 5=macos 6=web。
指纹 payload 使用该数值。
Language:
type: string
enum: [zh-CN, en, zh-TW]
@ -2316,30 +2321,39 @@ components:
description: 1=登录 2=注销账号
LoginRequest:
type: object
required: [email, verify_code, device_id]
required: [email, verify_code, app_id, device_type]
properties:
email:
type: string
format: email
verify_code:
type: string
description: 邮箱验证码。测试账号 test01@spark.com~test10@spark.com 可任意填写。
device_id:
description: 邮箱验证码。测试账号 test01@spark.com~test100@spark.com 可任意填写。
app_id:
type: integer
format: int64
minimum: 1
description: 当前客户端 App,见 meta `app_id` 枚举(JWT claim `aid`)。
device_type:
$ref: "#/components/schemas/DeviceType"
platform:
allOf:
- $ref: "#/components/schemas/DeviceType"
description: device_type 的兼容字段
type: integer
format: int32
enum: [1, 2, 3, 4, 5, 6]
description: 与 device_type 相同枚举;仅当未传 device_type 时可作别名
device_name:
type: string
maxLength: 128
idfv:
type: string
maxLength: 36
description: iOS / macOS 必填(物理设备标识,服务端据此生成 device_id)。
android_id:
type: string
maxLength: 20
description: Android / Harmony 必填。
LoginData:
type: object
required: [access_token, refresh_token, expires_in, refresh_expires_in, user_id, device_id, spark_id]
required: [access_token, refresh_token, expires_in, refresh_expires_in, user_id, device_id, app_id, spark_id]
properties:
access_token:
type: string
@ -2362,6 +2376,10 @@ components:
device_id:
type: integer
format: int64
description: 服务端按物理指纹生成的设备 id(JWT `did`)。客户端持久化该值,勿自行生成。
app_id:
type: integer
description: 与 JWT claim `aid` 一致
spark_id:
type: string
description: Public handle, unique and user-changeable. New and updated values are 8-16 characters.

View File

@ -13,6 +13,7 @@ import (
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
favlogic "spark-server/internal/module/favorite/logic"
@ -52,7 +53,7 @@ func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.Me
})
favs := favlogic.NewService(favmodel.NewMemoryRepo(), msgs, convs, nil, ids)
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Login: authlogic.NewLoginService(j, users), Users: users,
Auth: j, Login: authlogictest.LoginService(j, users), Users: users,
Files: files, Favorites: favs,
})
return &ginEngineBundle{engine: r}, ids, msgs, convs

View File

@ -13,6 +13,7 @@ import (
"spark-server/internal/infrastructure/id"
authentity "spark-server/internal/module/auth/entity"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
delivermodel "spark-server/internal/module/deliver/model"
@ -42,7 +43,7 @@ func testFriendEngine(t *testing.T) *ginEngineBundle {
)
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j,
Login: authlogic.NewLoginService(j, users),
Login: authlogictest.LoginService(j, users),
Users: users,
Friends: friends,
})
@ -55,7 +56,7 @@ type ginEngineBundle struct {
func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken {
t.Helper()
body := fmt.Sprintf(`{"email":%q,"verify_code":"any","device_id":%d,"device_type":"ios","device_name":"phone"}`, email, device)
body := authlogictest.LoginBodyJSONNamed(email, "any", "phone")
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

View File

@ -10,6 +10,7 @@ import (
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
delivermodel "spark-server/internal/module/deliver/model"
@ -39,7 +40,7 @@ func testGroupEngine(t *testing.T) *ginEngineBundle {
groups := grouplogic.NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), nil)
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j,
Login: authlogic.NewLoginService(j, users),
Login: authlogictest.LoginService(j, users),
Users: users,
Friends: friends,
Groups: groups,

View File

@ -11,6 +11,7 @@ import (
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
deliverlogic "spark-server/internal/module/deliver/logic"
@ -48,7 +49,7 @@ func TestInboxIncrementalHTTP(t *testing.T) {
box := syncmodel.NewMemoryStore(ids)
syncSvc := synclogic.NewService(inbox, box)
h := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, Friends: friends, Messages: msgs, Sync: syncSvc,
Auth: j, Login: authlogictest.LoginService(j, users), Users: users, Friends: friends, Messages: msgs, Sync: syncSvc,
})
a := loginUser(t, h, "test01@spark.com", 1)
b := loginUser(t, h, "test02@spark.com", 2)

View File

@ -10,6 +10,7 @@ import (
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
delivermodel "spark-server/internal/module/deliver/model"
@ -41,7 +42,7 @@ func testMessageEngine(t *testing.T) http.Handler {
)
msgs := msglogic.NewService(store, chats, p2p, friends, nil, ids, q)
return NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, Friends: friends, Messages: msgs,
Auth: j, Login: authlogictest.LoginService(j, users), Users: users, Friends: friends, Messages: msgs,
})
}

View File

@ -10,6 +10,7 @@ import (
"spark-server/internal/infrastructure/id"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
delivermodel "spark-server/internal/module/deliver/model"
@ -41,7 +42,7 @@ func testMomentEngine(t *testing.T) *ginEngineBundle {
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{})
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j, Login: authlogic.NewLoginService(j, users), Users: users,
Auth: j, Login: authlogictest.LoginService(j, users), Users: users,
Friends: friends, Moments: moments, Files: files,
})
return &ginEngineBundle{engine: r}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -16,6 +17,7 @@ import (
"spark-server/internal/infrastructure/logger"
authentity "spark-server/internal/module/auth/entity"
authlogic "spark-server/internal/module/auth/logic"
authlogictest "spark-server/internal/module/auth/logictest"
"spark-server/internal/module/user/logictest"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/ratelimit"
@ -153,7 +155,7 @@ func testLoginEngine(t *testing.T) *gin.Engine {
users.BindAccountCleanup(j, nil)
return NewEngine(zap.NewNop(), &EngineDeps{
Auth: j,
Login: authlogic.NewLoginService(j, users),
Login: authlogictest.LoginService(j, users),
SMS: smsSvc,
Users: users,
})
@ -167,7 +169,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
})
w := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios","device_name":"iPhone"}`))
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(authlogictest.LoginBodyJSONNamed("test01@spark.com", "any", "iPhone")))
loginReq.Header.Set("Content-Type", "application/json")
loginReq.Header.Set("X-App-Version", "1.0.0")
loginReq.Header.Set("X-Real-IP", "203.0.113.10")
@ -183,7 +185,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
t.Fatal(err)
}
if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != 7 || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" {
if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != authlogictest.TestIOSDeviceID || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" {
t.Fatalf("login data %+v", tok)
}
@ -208,7 +210,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
t.Fatal(err)
}
uidJSON, _ := json.Marshal(tok.UserID)
if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(`"device_id":7`)) {
if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(fmt.Sprintf(`"device_id":%d`, authlogictest.TestIOSDeviceID))) {
t.Fatalf("authed %+v", okEnv)
}
@ -229,7 +231,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
func TestUsersMeAndSparkID(t *testing.T) {
r := testLoginEngine(t)
w := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(authlogictest.LoginBodyJSON("test01@spark.com", "any")))
loginReq.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, loginReq)
var loginEnv reply.Envelope
@ -287,7 +289,7 @@ func TestUsersMeAndSparkID(t *testing.T) {
func TestRefreshAndLogout(t *testing.T) {
r := testLoginEngine(t)
w := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(authlogictest.LoginBodyJSON("test01@spark.com", "any")))
loginReq.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, loginReq)
var loginEnv reply.Envelope
@ -361,7 +363,7 @@ func TestRefreshAndLogout(t *testing.T) {
func TestDeleteAccount(t *testing.T) {
r := testLoginEngine(t)
w := httptest.NewRecorder()
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(authlogictest.LoginBodyJSON("test01@spark.com", "any")))
loginReq.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, loginReq)
var loginEnv reply.Envelope
@ -401,7 +403,7 @@ func TestDeleteAccount(t *testing.T) {
}
w = httptest.NewRecorder()
again := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
again := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(authlogictest.LoginBodyJSON("test01@spark.com", "any")))
again.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, again)
var againEnv reply.Envelope
@ -507,12 +509,12 @@ func TestLoginRateLimitedByRealIP(t *testing.T) {
users, smsSvc := logictest.UsersAndSMS(ids, nil)
r := NewEngine(zap.NewNop(), &EngineDeps{
Auth: j,
Login: authlogic.NewLoginService(j, users),
Login: authlogictest.LoginService(j, users),
SMS: smsSvc,
Users: users,
AuthLimiter: ratelimit.New(1),
})
body := `{"email":"test01@spark.com","verify_code":"x","device_id":7,"device_type":"ios"}`
body := authlogictest.LoginBodyJSON("test01@spark.com", "x")
hit := func() reply.Envelope {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))

View File

@ -94,7 +94,7 @@ func TestAuthJWTSameService(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tok, err := j.Issue(context.Background(), 42, 7)
tok, err := j.Issue(context.Background(), 42, 7, 1)
if err != nil {
t.Fatal(err)
}

View File

@ -0,0 +1,28 @@
-- Physical devices registry + slim user_devices + login log app_id.
CREATE TABLE IF NOT EXISTS devices (
device_id BIGINT UNSIGNED NOT NULL COMMENT 'JWT did;多 App 同机共用',
fingerprint_hash CHAR(64) NOT NULL COMMENT '物理指纹 SHA256 hex,不含 app_id',
device_type TINYINT UNSIGNED NOT NULL COMMENT 'device_type 枚举 1=ios 2=android …',
device_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近 login 上报设备名',
idfv VARCHAR(36) NOT NULL DEFAULT '' COMMENT 'iOS IDFV',
android_id VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'Android ID',
last_client_ip VARCHAR(30) NOT NULL DEFAULT '' COMMENT '最近 RealIP',
created_at DATETIME(3) NOT NULL COMMENT '首次注册',
updated_at DATETIME(3) NOT NULL COMMENT '最近 touch',
PRIMARY KEY (device_id),
UNIQUE KEY uk_fingerprint (fingerprint_hash)
) COMMENT='物理设备;与用户、App 无关';
ALTER TABLE user_login_logs
ADD COLUMN app_id TINYINT UNSIGNED NOT NULL DEFAULT 0
COMMENT '当次登录 app_id' AFTER device_id;
ALTER TABLE user_devices
ADD COLUMN last_active_at DATETIME(3) NULL COMMENT '该用户在此设备最近活跃' AFTER last_login_at;
ALTER TABLE user_devices DROP COLUMN platform;
ALTER TABLE user_devices DROP COLUMN device_name;
ALTER TABLE user_devices DROP COLUMN app_version;
ALTER TABLE user_devices DROP COLUMN language;
ALTER TABLE user_devices DROP COLUMN last_ip;