diff --git a/.cursor/skills/spark-im-v3/enums.md b/.cursor/skills/spark-im-v3/enums.md index b31647f..b4f53ca 100644 --- a/.cursor/skills/spark-im-v3/enums.md +++ b/.cursor/skills/spark-im-v3/enums.md @@ -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视频 | diff --git a/README.md b/README.md index 404486a..9878504 100644 --- a/README.md +++ b/README.md @@ -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 写进对象存储路径。 diff --git a/configs/config.yaml b/configs/config.yaml index 38ec834..e3387f5 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -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: diff --git a/docs/devices-client.md b/docs/devices-client.md new file mode 100644 index 0000000..654195e --- /dev/null +++ b/docs/devices-client.md @@ -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 `**(必填) +- **`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` 等 + diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..42a4d37 --- /dev/null +++ b/docs/devices.md @@ -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。 diff --git a/docs/sms.md b/docs/sms.md index c6fec3b..d6e9eda 100644 --- a/docs/sms.md +++ b/docs/sms.md @@ -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`:不发短信;校验任意码(与测试邮箱类似) | --- diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 2608d66..7e1f436 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -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 { diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 7fdf170..f757c59 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -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" } diff --git a/internal/module/auth/entity/principal.go b/internal/module/auth/entity/principal.go index ac0dc7c..e09bbcb 100644 --- a/internal/module/auth/entity/principal.go +++ b/internal/module/auth/entity/principal.go @@ -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"` } diff --git a/internal/module/auth/logic/jwt.go b/internal/module/auth/logic/jwt.go index cf28be8..d67610a 100644 --- a/internal/module/auth/logic/jwt.go +++ b/internal/module/auth/logic/jwt.go @@ -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 { diff --git a/internal/module/auth/logic/jwt_test.go b/internal/module/auth/logic/jwt_test.go index 46c9b30..e529e45 100644 --- a/internal/module/auth/logic/jwt_test.go +++ b/internal/module/auth/logic/jwt_test.go @@ -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 { diff --git a/internal/module/auth/logic/login.go b/internal/module/auth/logic/login.go index 6b566ee..d3c851c 100644 --- a/internal/module/auth/logic/login.go +++ b/internal/module/auth/logic/login.go @@ -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), } diff --git a/internal/module/auth/logic/login_test.go b/internal/module/auth/logic/login_test.go index 861a76b..8116534 100644 --- a/internal/module/auth/logic/login_test.go +++ b/internal/module/auth/logic/login_test.go @@ -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) + } } diff --git a/internal/module/auth/logic/login_test_helpers.go b/internal/module/auth/logic/login_test_helpers.go new file mode 100644 index 0000000..fbf8800 --- /dev/null +++ b/internal/module/auth/logic/login_test_helpers.go @@ -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 +} diff --git a/internal/module/auth/logic/pair_key.go b/internal/module/auth/logic/pair_key.go new file mode 100644 index 0000000..740dd17 --- /dev/null +++ b/internal/module/auth/logic/pair_key.go @@ -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) +} diff --git a/internal/module/auth/logic/refresh.go b/internal/module/auth/logic/refresh.go index 5a90df3..4915293 100644 --- a/internal/module/auth/logic/refresh.go +++ b/internal/module/auth/logic/refresh.go @@ -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 { diff --git a/internal/module/auth/logictest/wire.go b/internal/module/auth/logictest/wire.go new file mode 100644 index 0000000..0f20588 --- /dev/null +++ b/internal/module/auth/logictest/wire.go @@ -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) +} diff --git a/internal/module/device/entity/app_id.go b/internal/module/device/entity/app_id.go new file mode 100644 index 0000000..482b6e8 --- /dev/null +++ b/internal/module/device/entity/app_id.go @@ -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 +} diff --git a/internal/module/device/entity/device_type.go b/internal/module/device/entity/device_type.go new file mode 100644 index 0000000..c6521ad --- /dev/null +++ b/internal/module/device/entity/device_type.go @@ -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 "" + } +} diff --git a/internal/module/device/logic/fingerprint.go b/internal/module/device/logic/fingerprint.go new file mode 100644 index 0000000..774a6be --- /dev/null +++ b/internal/module/device/logic/fingerprint.go @@ -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 +} diff --git a/internal/module/device/logic/fingerprint_test.go b/internal/module/device/logic/fingerprint_test.go new file mode 100644 index 0000000..6613b68 --- /dev/null +++ b/internal/module/device/logic/fingerprint_test.go @@ -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") + } +} diff --git a/internal/module/device/logic/register.go b/internal/module/device/logic/register.go new file mode 100644 index 0000000..e110cd7 --- /dev/null +++ b/internal/module/device/logic/register.go @@ -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 +} diff --git a/internal/module/device/logic/register_test.go b/internal/module/device/logic/register_test.go new file mode 100644 index 0000000..1fed6c6 --- /dev/null +++ b/internal/module/device/logic/register_test.go @@ -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) + } +} diff --git a/internal/module/device/model/memory_registry.go b/internal/module/device/model/memory_registry.go new file mode 100644 index 0000000..b645202 --- /dev/null +++ b/internal/module/device/model/memory_registry.go @@ -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 +} diff --git a/internal/module/device/model/registry.go b/internal/module/device/model/registry.go new file mode 100644 index 0000000..9272cfa --- /dev/null +++ b/internal/module/device/model/registry.go @@ -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) +} diff --git a/internal/module/meta/entity/enum.go b/internal/module/meta/entity/enum.go index dc161a5..aa9bb9c 100644 --- a/internal/module/meta/entity/enum.go +++ b/internal/module/meta/entity/enum.go @@ -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"` diff --git a/internal/module/meta/logic/enums.go b/internal/module/meta/logic/enums.go index 8b3984e..8640cd3 100644 --- a/internal/module/meta/logic/enums.go +++ b/internal/module/meta/logic/enums.go @@ -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), diff --git a/internal/module/meta/logic/enums_app.go b/internal/module/meta/logic/enums_app.go new file mode 100644 index 0000000..2a873a7 --- /dev/null +++ b/internal/module/meta/logic/enums_app.go @@ -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)), + } +} diff --git a/internal/module/meta/logic/enums_device.go b/internal/module/meta/logic/enums_device.go new file mode 100644 index 0000000..b223bfb --- /dev/null +++ b/internal/module/meta/logic/enums_device.go @@ -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)), + } +} diff --git a/internal/module/meta/logic/enums_test.go b/internal/module/meta/logic/enums_test.go index a86191f..36247b4 100644 --- a/internal/module/meta/logic/enums_test.go +++ b/internal/module/meta/logic/enums_test.go @@ -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") diff --git a/internal/module/meta/logic/enums_wire.go b/internal/module/meta/logic/enums_wire.go index 99869a4..32ddd5d 100644 --- a/internal/module/meta/logic/enums_wire.go +++ b/internal/module/meta/logic/enums_wire.go @@ -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)) } diff --git a/internal/module/user/entity/device.go b/internal/module/user/entity/device.go index 3c83794..9f09df5 100644 --- a/internal/module/user/entity/device.go +++ b/internal/module/user/entity/device.go @@ -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 { diff --git a/internal/module/user/entity/email.go b/internal/module/user/entity/email.go index 23949e1..8701424 100644 --- a/internal/module/user/entity/email.go +++ b/internal/module/user/entity/email.go @@ -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 { diff --git a/internal/module/user/entity/email_test.go b/internal/module/user/entity/email_test.go index a4a3a23..0477611 100644 --- a/internal/module/user/entity/email_test.go +++ b/internal/module/user/entity/email_test.go @@ -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) diff --git a/internal/module/user/entity/login_log.go b/internal/module/user/entity/login_log.go index 897172f..405f182 100644 --- a/internal/module/user/entity/login_log.go +++ b/internal/module/user/entity/login_log.go @@ -7,6 +7,7 @@ type LoginLog struct { ID int64 UserID int64 DeviceID uint64 + AppID uint8 DeviceType string DeviceName string AppVersion string diff --git a/internal/module/user/logic/login.go b/internal/module/user/logic/login.go index dc23598..a811ef6 100644 --- a/internal/module/user/logic/login.go +++ b/internal/module/user/logic/login.go @@ -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, diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go index 34121c2..c8506ae 100644 --- a/internal/module/user/logic/login_test.go +++ b/internal/module/user/logic/login_test.go @@ -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) { diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go index 10f0dcf..34a33d5 100644 --- a/internal/module/user/logic/service.go +++ b/internal/module/user/logic/service.go @@ -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) diff --git a/internal/module/user/model/memory.go b/internal/module/user/model/memory.go index b698ae7..2e618f0 100644 --- a/internal/module/user/model/memory.go +++ b/internal/module/user/model/memory.go @@ -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() diff --git a/internal/module/user/model/user_device.go b/internal/module/user/model/user_device.go index 2ff9225..30540de 100644 --- a/internal/module/user/model/user_device.go +++ b/internal/module/user/model/user_device.go @@ -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 +} diff --git a/internal/module/user/model/user_login_log.go b/internal/module/user/model/user_login_log.go index 6932f62..51b89f3 100644 --- a/internal/module/user/model/user_login_log.go +++ b/internal/module/user/model/user_login_log.go @@ -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, diff --git a/internal/pkg/i18n/catalog_enum_wire.go b/internal/pkg/i18n/catalog_enum_wire.go index bea88ac..745a4ab 100644 --- a/internal/pkg/i18n/catalog_enum_wire.go +++ b/internal/pkg/i18n/catalog_enum_wire.go @@ -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: "繁體中文"}, diff --git a/internal/pkg/i18n/catalog_validate_user.go b/internal/pkg/i18n/catalog_validate_user.go index ed17723..722960b 100644 --- a/internal/pkg/i18n/catalog_validate_user.go +++ b/internal/pkg/i18n/catalog_validate_user.go @@ -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", + }, }) } diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index ab53738..7285358 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -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" diff --git a/internal/pkg/reqctx/reqctx.go b/internal/pkg/reqctx/reqctx.go index 1a72c39..b7a1099 100644 --- a/internal/pkg/reqctx/reqctx.go +++ b/internal/pkg/reqctx/reqctx.go @@ -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 { diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index 3273be2..5a00264 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -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() diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 927efae..4a5bc7b 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -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. diff --git a/internal/transport/http/file_test.go b/internal/transport/http/file_test.go index f9c7416..1c8db3d 100644 --- a/internal/transport/http/file_test.go +++ b/internal/transport/http/file_test.go @@ -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 diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go index a403153..9b03f6b 100644 --- a/internal/transport/http/friend_test.go +++ b/internal/transport/http/friend_test.go @@ -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() diff --git a/internal/transport/http/group_test.go b/internal/transport/http/group_test.go index e92f324..1f203c8 100644 --- a/internal/transport/http/group_test.go +++ b/internal/transport/http/group_test.go @@ -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, diff --git a/internal/transport/http/inbox_test.go b/internal/transport/http/inbox_test.go index 0a8568b..7646000 100644 --- a/internal/transport/http/inbox_test.go +++ b/internal/transport/http/inbox_test.go @@ -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) diff --git a/internal/transport/http/message_test.go b/internal/transport/http/message_test.go index 22cad1e..0d8c09c 100644 --- a/internal/transport/http/message_test.go +++ b/internal/transport/http/message_test.go @@ -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, }) } diff --git a/internal/transport/http/moment_test.go b/internal/transport/http/moment_test.go index 8df9193..52c29d7 100644 --- a/internal/transport/http/moment_test.go +++ b/internal/transport/http/moment_test.go @@ -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} diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index bb868cb..f450172 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -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)) diff --git a/internal/transport/tcp/server_test.go b/internal/transport/tcp/server_test.go index c1e0323..4834631 100644 --- a/internal/transport/tcp/server_test.go +++ b/internal/transport/tcp/server_test.go @@ -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) } diff --git a/migrations/0019_devices.sql b/migrations/0019_devices.sql new file mode 100644 index 0000000..6eec77c --- /dev/null +++ b/migrations/0019_devices.sql @@ -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;