feat: 1、update device rregister logic
This commit is contained in:
parent
b909cfbac0
commit
bec98be1da
@ -76,7 +76,7 @@ Do not convert these to integers.
|
|||||||
|
|
||||||
| OpenAPI schema | Go | JSON | Values |
|
| 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`) |
|
| `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` |
|
| `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视频 |
|
| `MomentKind` | `moment/entity.Kind` | `kind` | 1纯文 2图 3视频 |
|
||||||
|
|||||||
@ -107,7 +107,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
|
|||||||
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
||||||
5. 在线发消息、收推送、增量同步走 **TCP**。`POST /v1/messages` 只是 HTTP 调试通道,不要当 App 主路径。
|
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 写进对象存储路径。
|
`uid` 就是 Snowflake `users.id`。对外展示用可变的 `spark_id`,不要把 uid 写进对象存储路径。
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,7 @@ jwt:
|
|||||||
refresh_ttl: 720h
|
refresh_ttl: 720h
|
||||||
denylist_prefix: "denylist:"
|
denylist_prefix: "denylist:"
|
||||||
refresh_prefix: "refresh:"
|
refresh_prefix: "refresh:"
|
||||||
|
pair_key_mode: "triple"
|
||||||
|
|
||||||
# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login).
|
# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login).
|
||||||
admin:
|
admin:
|
||||||
|
|||||||
118
docs/devices-client.md
Normal file
118
docs/devices-client.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# 设备与登录 — 客户端对接说明
|
||||||
|
|
||||||
|
面向 iOS / Android / Web 等客户端
|
||||||
|
|
||||||
|
## 变更摘要(Breaking)
|
||||||
|
|
||||||
|
| 项 | 旧行为 | 新行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 登录 Body `device_id` | 客户端生成并必填 | **删除**;由服务端根据物理指纹生成 |
|
||||||
|
| 登录 Body | 无 | **`app_id` 必填**;**`device_type` 为整数 1–6**(见 meta) |
|
||||||
|
| 物理标识 | 无 | **`idfv`(iOS/macOS)或 `android_id`(Android/Harmony)** 按平台必填 |
|
||||||
|
| JWT | `sub` + `did` | 增加 **`aid`(app_id)**;access / refresh 均含 |
|
||||||
|
| 登录响应 `data` | `device_id` | 仍为服务端 **`device_id`**,并增加 **`app_id`** |
|
||||||
|
| 同一邮箱 | 同一用户 | 不变 |
|
||||||
|
| 同机多 App | 各 App 各自 `device_id` | **同机共用一个 `device_id`**;会话按 **`app_id` / JWT `aid`** 区分 |
|
||||||
|
|
||||||
|
## 登录 `POST /v1/auth/login`
|
||||||
|
|
||||||
|
### 必填字段
|
||||||
|
|
||||||
|
- `email`、`verify_code`
|
||||||
|
- `app_id`:例如 Spark IM 为 `1`(以 meta 枚举为准)
|
||||||
|
- `device_type`:**整数枚举**(见 `GET /v1/meta/enums` → `device_type`);`1`=ios,`2`=android,`3`=harmony,`4`=windows,`5`=macos,`6`=web。可选 **`platform`** 同枚举,仅当未传 `device_type` 时作别名。
|
||||||
|
|
||||||
|
### 物理设备字段(生成 `device_id`)
|
||||||
|
|
||||||
|
| `device_type` | 必填 Body 字段 |
|
||||||
|
| --- | --- |
|
||||||
|
| `1`(ios)、`5`(macos) | `idfv` |
|
||||||
|
| `2`(android)、`3`(harmony) | `android_id` |
|
||||||
|
| `4`(windows)、`6`(web) | `idfv` 或 `android_id` 至少一个(建议 Web 用稳定 local id 映射到其一) |
|
||||||
|
|
||||||
|
可选:`device_name`(展示名)。
|
||||||
|
|
||||||
|
**不要**再传 `device_id`。指纹算法(与客户端实现一致即可):
|
||||||
|
|
||||||
|
```text
|
||||||
|
payload = "{device_type}:{idfv|android_id}"
|
||||||
|
fingerprint_hash = HEX(SHA256(payload))
|
||||||
|
device_id = uint64(SHA256(fingerprint_hash)[0:8]),非 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_type` 与 meta / DB / 指纹 **同一套数值**(ios=1, android=2, …)。
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
保存:
|
||||||
|
|
||||||
|
- `access_token`、`refresh_token`
|
||||||
|
- **`device_id`**(写入本地,与 JWT `did` 相同)
|
||||||
|
- **`app_id`**(与 JWT `aid` 相同)
|
||||||
|
- `user_id`、`spark_id`
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/auth/login HTTP/1.1
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Language: zh-CN
|
||||||
|
X-App-Version: 1.0.0
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"verify_code": "123456",
|
||||||
|
"app_id": 1,
|
||||||
|
"device_type": 1,
|
||||||
|
"idfv": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||||
|
"device_name": "iPhone 15"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JWT
|
||||||
|
|
||||||
|
解码 access / refresh token(HS256,生产由服务端签发):
|
||||||
|
|
||||||
|
| claim | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `sub` | `user_id` |
|
||||||
|
| `did` | `device_id`(服务端生成) |
|
||||||
|
| **`aid`** | **`app_id`(当次登录 App)** |
|
||||||
|
| `use` | `access` 或 `refresh` |
|
||||||
|
|
||||||
|
刷新 token 后 **`aid` 不变**(与首次登录 App 绑定)。
|
||||||
|
|
||||||
|
## 登录后的 HTTP
|
||||||
|
|
||||||
|
- **`Authorization: Bearer <access_token>`**(必填)
|
||||||
|
- **`X-Device-ID`**:十进制 `device_id`,须等于 JWT **`did`**(与登录响应一致)
|
||||||
|
- 可选 **`X-App-ID`**:若传,须与 JWT **`aid`** 一致
|
||||||
|
- **`X-Language` / `Accept-Language`**、`X-App-Version` 仍走 Header
|
||||||
|
|
||||||
|
业务 JSON **不要**再塞 `device_id` / `app_id`(登录、刷新除外)。
|
||||||
|
|
||||||
|
## 刷新与注销
|
||||||
|
|
||||||
|
- **`POST /v1/auth/token/refresh`**:Body 仅 `refresh_token`;新 token 对仍带相同 `did` / `aid`。
|
||||||
|
- **`POST /v1/auth/logout`**:吊销当前 **`(user, device, app)`** 会话(默认服务端配置下各 App 独立;若运维切到 `pair` 模式,同设备会互踢 refresh,见下)。
|
||||||
|
|
||||||
|
## TCP AUTH
|
||||||
|
|
||||||
|
- Body:`token`(access)、可选 **`device_id`**(须等于 JWT `did`)、`app_version`、`language`
|
||||||
|
- **不必**在 AUTH Body 传 `app_id`;以 token 内 **`aid`** 为准
|
||||||
|
- 连接仍按 **`(user_id, device_id)`** 绑定;同机多 App 各连各的 token 即可
|
||||||
|
|
||||||
|
## 同机多 App
|
||||||
|
|
||||||
|
- **同一物理机 → 同一 `device_id`**(指纹不含 `app_id`)
|
||||||
|
- **不同 App → 不同 JWT `aid`**,各自 login 拿各自 token
|
||||||
|
- 默认(`triple`):App A 与 App B **可同时** refresh,互不踢
|
||||||
|
- 若服务端改为 `pair` 模式:后登录 App 会 **踢掉同设备上其它 App 的 refresh**(需产品公告)
|
||||||
|
|
||||||
|
## 客户端迁移清单
|
||||||
|
|
||||||
|
1. 登录 Body:加 **`app_id`**、**`idfv` / `android_id`**;去掉自研 **`device_id`**
|
||||||
|
2. 首次登录成功后 **持久化响应里的 `device_id`**,后续 Header / TCP 使用该值
|
||||||
|
3. 解析 JWT 时读取 **`aid`**,多 App 共用一套 `device_id` 存储键时应用 **`(device_id, app_id)`** 区分 token
|
||||||
|
4. 拉取 **`GET /v1/meta/enums`** 获取 `app_id`、`device_type` 等
|
||||||
|
|
||||||
209
docs/devices.md
Normal file
209
docs/devices.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# 设备与用户设备关系(方案定稿)
|
||||||
|
|
||||||
|
不改代码前的设计说明。与 JWT、`user_devices`、`user_login_logs`、PairStore 对齐。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
| 项 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 物理设备 | 同一指纹 → **一个 `device_id`**(指纹 **不含 `app_id`**),多 App 同机共用 |
|
||||||
|
| **用户** | **同一邮箱 = 同一 `user_id`**,与从哪个 App 登录无关 |
|
||||||
|
| **App 会话** | Login Body **`app_id` → JWT `aid`**;**`user_login_logs.app_id`** 记当次 App |
|
||||||
|
| **JWT** | **`did` + `aid`(app_id)** |
|
||||||
|
| **PairStore** | 默认 **`(user_id, device_id, app_id)`**;配置可切 **`(user_id, device_id)`**(见 §PairStore) |
|
||||||
|
| **`users`** | **不增加 `app_id` 列**;同一邮箱同一用户 |
|
||||||
|
| **`user_devices`** | 仅 **`(user_id, device_id)`** + 登录/活跃时间,**不存 `app_id`** |
|
||||||
|
| **审计** | **`user_login_logs.app_id`**:当次从哪个 App 登录 |
|
||||||
|
| API | **`GET /v1/users/me/devices` 首版不提供** |
|
||||||
|
|
||||||
|
在线 / 活跃:更新 **`user_devices(user_id, device_id)`**;**当前 App** 看 JWT **`aid`** / 登录日志,不看 `user_devices`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 枚举
|
||||||
|
|
||||||
|
### `device_type`
|
||||||
|
|
||||||
|
| value | name |
|
||||||
|
| ---: | --- |
|
||||||
|
| 0 | unknown |
|
||||||
|
| 1 | ios |
|
||||||
|
| 2 | android |
|
||||||
|
| 3 | harmony |
|
||||||
|
| 4 | windows |
|
||||||
|
| 5 | macos |
|
||||||
|
| 6 | web |
|
||||||
|
|
||||||
|
### `app_id`
|
||||||
|
|
||||||
|
| value | name |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | spark_im |
|
||||||
|
| … | … |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 指纹与 `device_id`
|
||||||
|
|
||||||
|
```text
|
||||||
|
payload = "{device_type}:{idfv|android_id}"
|
||||||
|
fingerprint_hash = HEX(SHA256(payload))
|
||||||
|
device_id = uint64(SHA256(fingerprint_hash)[0:8]),非 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`devices`**:`UNIQUE(fingerprint_hash)`,**无 `app_id` 列**。
|
||||||
|
- Android 多 App 共 **`device_id`**:客户端集团统一上报策略(见前版说明),服务端不合并不同 ANDROID_ID。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表:`users`
|
||||||
|
|
||||||
|
**不新增 `app_id`**。登录仍 **仅按 email** 查/建用户(`user_emails` 不变,同一邮箱 = 同一 `user_id`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表:`devices`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE devices (
|
||||||
|
device_id BIGINT UNSIGNED NOT NULL COMMENT 'JWT did;多 App 同机共用',
|
||||||
|
fingerprint_hash CHAR(64) NOT NULL COMMENT '物理指纹,UNIQUE,不含 app_id',
|
||||||
|
device_type TINYINT UNSIGNED NOT NULL COMMENT 'device_type 枚举',
|
||||||
|
device_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近 login 上报设备名',
|
||||||
|
idfv VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
|
android_id VARCHAR(20) NOT NULL DEFAULT '',
|
||||||
|
last_client_ip VARCHAR(30) NOT NULL DEFAULT '' COMMENT '最近 RealIP',
|
||||||
|
created_at DATETIME(3) NOT NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL,
|
||||||
|
PRIMARY KEY (device_id),
|
||||||
|
UNIQUE KEY uk_fingerprint (fingerprint_hash)
|
||||||
|
) COMMENT='物理设备;与用户、App 无关';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表:`user_devices`(无 `app_id`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_devices (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 uid',
|
||||||
|
device_id BIGINT UNSIGNED NOT NULL COMMENT 'devices.device_id',
|
||||||
|
last_login_at DATETIME(3) NOT NULL COMMENT '该用户在此设备最近登录成功',
|
||||||
|
last_active_at DATETIME(3) NULL COMMENT '该用户在此设备最近活跃',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '该用户首次在此设备绑定',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_user_device (user_id, device_id),
|
||||||
|
KEY idx_user_id (user_id),
|
||||||
|
KEY idx_device_id (device_id)
|
||||||
|
) COMMENT='用户与设备关系;当前 App 见 JWT aid';
|
||||||
|
```
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 同机用户 A、B | `(A,did)`、`(B,did)`,**同一 `devices` 行** |
|
||||||
|
| **同邮箱、App1 与 App2 登录** | **同一 `user_id`**;**同一 `device_id`** → **一行 `user_devices`**;JWT **`aid` 不同**(各次 login) |
|
||||||
|
| B 在用、查 A 设备活跃 | 只更新 **A 的 `(A,did)`**,与 B 无关 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表:`user_login_logs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_login_logs
|
||||||
|
ADD COLUMN app_id TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||||
|
COMMENT '当次登录 App,见 meta app_id' AFTER device_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
当次 **`app_id`** = Login Body(当次从哪个 App 登录)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT
|
||||||
|
|
||||||
|
### Claims(access / refresh 均含)
|
||||||
|
|
||||||
|
| claim | JSON | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sub` | uid | 不变 |
|
||||||
|
| **`did`** | device_id | 服务端指纹生成 |
|
||||||
|
| **`aid`** | app_id | **当次登录 App**(Body `app_id`),refresh 链上固定 |
|
||||||
|
| `use` | access / refresh | 不变 |
|
||||||
|
| `jti` / `exp` / … | | 不变 |
|
||||||
|
|
||||||
|
### PairStore(refresh 键策略)
|
||||||
|
|
||||||
|
配置项(示例 **`auth.pair_key_mode`**):**`triple`** | **`pair`**,**上线前可改**,实现须 **两种都支持**。
|
||||||
|
|
||||||
|
| 模式 | Redis / 内存键 | **App2 登录同 `(user, device)` 时** |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **`triple`(默认)** | `(user_id, device_id, app_id)` | **不踢** App1 的 refresh;App1 / App2 **各一套** access+refresh |
|
||||||
|
| **`pair`** | `(user_id, device_id)` | **踢掉**该用户在该设备上 **所有 App** 的旧 refresh(及对应 access denylist),只保留 **最新一次 Issue** |
|
||||||
|
|
||||||
|
**JWT 始终带 `aid`**,与模式无关;Authenticate / refresh 仍校验 token 内 **`aid`**。
|
||||||
|
|
||||||
|
**`triple` → Issue`**:只 **replace** 当前 **`(uid, did, aid)`** 这一对 jti。
|
||||||
|
**`pair` → Issue`**:`replacePair` 前 **Delete 该 `(uid, did)` 下全部 pair 键**(或 scan 前缀 `auth:pair:{uid}:{did}:*`),再写入新 pair;可选 **LogoutAllAppsOnDevice** 钩子踢 TCP 仅 **`(uid, did)`**(不按 aid 拆连接,与现 TCP 模型一致)。
|
||||||
|
|
||||||
|
**Refresh**:键解析与 Issue 同模式;refresh token 内 **`aid` 必须与存储键一致**。
|
||||||
|
|
||||||
|
**Logout(当前设备)**:
|
||||||
|
- `triple`:只删 **`(uid, did, aid)`**(JWT 当前 `aid`)。
|
||||||
|
- `pair`:删 **`(uid, did)`** 唯一 pair(等价于该设备上所有 App session 一并注销)。
|
||||||
|
|
||||||
|
**迁移建议**:先 **`triple`** 上线;切 **`pair`** 前公告「同设备换 App 登录将踢其它 App 登录态」;Redis 键规范预留 **`auth:pair:{uid}:{did}`** 与 **`auth:pair:{uid}:{did}:{aid}`** 两种 layout,或统一 hash field 由 mode 决定 field 名。
|
||||||
|
|
||||||
|
### 签发与校验(其它)
|
||||||
|
|
||||||
|
- **`Issue(userID, deviceID, appID)`**。
|
||||||
|
- **Authenticate**:**`did` + `aid`** → Principal / reqctx。
|
||||||
|
- 可选 Header **`X-App-ID`** 与 JWT **`aid`** 一致。
|
||||||
|
|
||||||
|
### Login 响应
|
||||||
|
|
||||||
|
仍返回 **`device_id`**、**`user_id`**;可增 **`app_id`** 与 JWT 一致(可选,避免客户端只解 JWT)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 登录流程(`POST /v1/auth/login`)
|
||||||
|
|
||||||
|
1. 校验 Body **`app_id`**、OTP 等。
|
||||||
|
2. **email** 查/建用户。
|
||||||
|
3. 指纹 → **`device_id`** → UPSERT **`devices`**。
|
||||||
|
4. UPSERT **`user_devices(user_id, device_id)`**。
|
||||||
|
5. INSERT **`user_login_logs`**(含当次 **`app_id`**)。
|
||||||
|
6. **`Issue(user_id, device_id, app_id)`**(Body `app_id` → JWT `aid`;PairStore 按 **`pair_key_mode`**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TCP AUTH
|
||||||
|
|
||||||
|
- 验 access token → **`user_id` + `device_id` + `app_id`**。
|
||||||
|
- 连接绑定 **`(user_id, device_id)`**(与现网一致);**`app_id` 进 conn 上下文** 供日志/限流扩展。
|
||||||
|
- Body **`device_id`** 可选,须与 JWT **`did`** 一致;**不要求 Body 再传 `app_id`**(以 token **`aid`** 为准)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关系小结
|
||||||
|
|
||||||
|
```text
|
||||||
|
JWT aid ← 当次登录 App
|
||||||
|
JWT did ← devices.device_id
|
||||||
|
user_devices (user_id, device_id)
|
||||||
|
user_login_logs.app_id
|
||||||
|
PairStore ← triple 或 pair(配置)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移与检查清单
|
||||||
|
|
||||||
|
- [ ] **`users` 不加 `app_id`**;`user_emails` 不变
|
||||||
|
- [ ] `devices` / `user_devices` / `user_login_logs.app_id`
|
||||||
|
- [ ] JWT **`aid`** + Principal / reqctx
|
||||||
|
- [ ] **`auth.pair_key_mode`**:`triple`(App2 **不踢** App1 refresh)/ `pair`(**踢**全 App 同设备 refresh)
|
||||||
|
- [ ] PairStore 实现 **双模式** + Redis 键 layout 可切换
|
||||||
|
- [ ] login OpenAPI:`app_id` 必填;去掉客户端 **`device_id` 必填**
|
||||||
|
- [ ] meta **`app_id`** 枚举
|
||||||
|
|
||||||
|
首版不做:`GET /v1/users/me/devices`、attestation。
|
||||||
@ -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`:不发短信;校验任意码(与测试邮箱类似) |
|
| 手机 | `+8613800138000`~`+8613800138002`:不发短信;校验任意码(与测试邮箱类似) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import (
|
|||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
deliverlogic "spark-server/internal/module/deliver/logic"
|
deliverlogic "spark-server/internal/module/deliver/logic"
|
||||||
delivermodel "spark-server/internal/module/deliver/model"
|
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"
|
favlogic "spark-server/internal/module/favorite/logic"
|
||||||
favmodel "spark-server/internal/module/favorite/model"
|
favmodel "spark-server/internal/module/favorite/model"
|
||||||
friendlogic "spark-server/internal/module/friend/logic"
|
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) {
|
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{
|
jwtSvc, err := authlogic.NewJWT(&authlogic.Options{
|
||||||
Secret: cfg.JWT.Secret,
|
Secret: cfg.JWT.Secret,
|
||||||
Issuer: cfg.JWT.Issuer,
|
Issuer: cfg.JWT.Issuer,
|
||||||
TTL: cfg.JWT.TTL,
|
TTL: cfg.JWT.TTL,
|
||||||
RefreshTTL: cfg.JWT.RefreshTTL,
|
RefreshTTL: cfg.JWT.RefreshTTL,
|
||||||
Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix),
|
Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix),
|
||||||
Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix),
|
Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix, authlogic.PairKeyMode(cfg.JWT.PairKeyMode)),
|
||||||
|
PairKeyMode: authlogic.PairKeyMode(cfg.JWT.PairKeyMode),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rdb.Close()
|
_ = rdb.Close()
|
||||||
@ -166,7 +169,9 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
|||||||
userRepo := wireUserRepo(db)
|
userRepo := wireUserRepo(db)
|
||||||
smsSvc := wireSMS(cfg, rdb, userRepo)
|
smsSvc := wireSMS(cfg, rdb, userRepo)
|
||||||
users := userlogic.NewService(userRepo, smsSvc, idgen, wireUserObjects(objects))
|
users := userlogic.NewService(userRepo, smsSvc, idgen, wireUserObjects(objects))
|
||||||
|
devices := wireDevices(db)
|
||||||
login := authlogic.NewLoginService(jwtSvc, users)
|
login := authlogic.NewLoginService(jwtSvc, users)
|
||||||
|
login.Devices = devices
|
||||||
login.Sessions = sessions
|
login.Sessions = sessions
|
||||||
var deliverQueue deliverlogic.Queue
|
var deliverQueue deliverlogic.Queue
|
||||||
inbox, mailbox := wireInbox(idgen, db, rdb)
|
inbox, mailbox := wireInbox(idgen, db, rdb)
|
||||||
@ -461,11 +466,18 @@ func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist {
|
|||||||
return authlogic.NewMemoryDenylist()
|
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() {
|
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 {
|
func (a *App) Run() error {
|
||||||
|
|||||||
@ -179,6 +179,7 @@ type JWTConfig struct {
|
|||||||
RefreshTTL time.Duration `yaml:"refresh_ttl"`
|
RefreshTTL time.Duration `yaml:"refresh_ttl"`
|
||||||
DenylistPrefix string `yaml:"denylist_prefix"`
|
DenylistPrefix string `yaml:"denylist_prefix"`
|
||||||
RefreshPrefix string `yaml:"refresh_prefix"`
|
RefreshPrefix string `yaml:"refresh_prefix"`
|
||||||
|
PairKeyMode string `yaml:"pair_key_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
@ -247,6 +248,9 @@ func applyDefaults(cfg *Config) {
|
|||||||
if cfg.JWT.RefreshPrefix == "" {
|
if cfg.JWT.RefreshPrefix == "" {
|
||||||
cfg.JWT.RefreshPrefix = "refresh:"
|
cfg.JWT.RefreshPrefix = "refresh:"
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.JWT.PairKeyMode) == "" {
|
||||||
|
cfg.JWT.PairKeyMode = "triple"
|
||||||
|
}
|
||||||
if cfg.Redis.DeliverQueueKey == "" {
|
if cfg.Redis.DeliverQueueKey == "" {
|
||||||
cfg.Redis.DeliverQueueKey = "deliver:queue"
|
cfg.Redis.DeliverQueueKey = "deliver:queue"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import "time"
|
|||||||
type Principal struct {
|
type Principal struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
DeviceID uint64
|
DeviceID uint64
|
||||||
|
AppID uint32
|
||||||
JTI string
|
JTI string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
@ -19,5 +20,6 @@ type IssuedToken struct {
|
|||||||
RefreshExpiresIn int64 `json:"refresh_expires_in"`
|
RefreshExpiresIn int64 `json:"refresh_expires_in"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
DeviceID uint64 `json:"device_id"`
|
DeviceID uint64 `json:"device_id"`
|
||||||
|
AppID uint32 `json:"app_id"`
|
||||||
SparkID string `json:"spark_id"`
|
SparkID string `json:"spark_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,9 @@ type Service interface {
|
|||||||
|
|
||||||
// Issuer mints, refreshes, and revokes access tokens.
|
// Issuer mints, refreshes, and revokes access tokens.
|
||||||
type Issuer interface {
|
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)
|
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
|
LogoutAll(ctx context.Context, userID int64) error
|
||||||
Revoke(ctx context.Context, token string) error
|
Revoke(ctx context.Context, token string) error
|
||||||
}
|
}
|
||||||
@ -42,17 +42,19 @@ const (
|
|||||||
|
|
||||||
// Options configure HS256 JWT issuance and validation.
|
// Options configure HS256 JWT issuance and validation.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Secret string
|
Secret string
|
||||||
Issuer string
|
Issuer string
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
RefreshTTL time.Duration
|
RefreshTTL time.Duration
|
||||||
Denylist Denylist
|
Denylist Denylist
|
||||||
Pairs PairStore
|
Pairs PairStore
|
||||||
Clock func() time.Time
|
PairKeyMode PairKeyMode
|
||||||
|
Clock func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type claims struct {
|
type claims struct {
|
||||||
DeviceID uint64 `json:"did"`
|
DeviceID uint64 `json:"did"`
|
||||||
|
AppID uint32 `json:"aid"`
|
||||||
Use string `json:"use,omitempty"`
|
Use string `json:"use,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
@ -65,6 +67,7 @@ type JWT struct {
|
|||||||
refreshTTL time.Duration
|
refreshTTL time.Duration
|
||||||
denylist Denylist
|
denylist Denylist
|
||||||
pairs PairStore
|
pairs PairStore
|
||||||
|
pairMode PairKeyMode
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,26 +106,27 @@ func NewJWT(opt *Options) (*JWT, error) {
|
|||||||
refreshTTL: refreshTTL,
|
refreshTTL: refreshTTL,
|
||||||
denylist: dl,
|
denylist: dl,
|
||||||
pairs: pairs,
|
pairs: pairs,
|
||||||
|
pairMode: normalizePairKeyMode(opt.PairKeyMode),
|
||||||
clock: clock,
|
clock: clock,
|
||||||
}, nil
|
}, 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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, errs.FromRPCError("jwt", err)
|
return nil, errs.FromRPCError("jwt", err)
|
||||||
}
|
}
|
||||||
if userID <= 0 || deviceID == 0 {
|
if userID <= 0 || deviceID == 0 || appID == 0 {
|
||||||
return nil, errs.ErrInvalidArgument
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &authentity.IssuedToken{
|
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),
|
RefreshExpiresIn: int64(j.refreshTTL / time.Second),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
|
AppID: appID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,11 +145,12 @@ type signedToken struct {
|
|||||||
jti string
|
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()
|
now := j.clock()
|
||||||
jti := reqctx.NewID()
|
jti := reqctx.NewID()
|
||||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
|
||||||
DeviceID: deviceID,
|
DeviceID: deviceID,
|
||||||
|
AppID: appID,
|
||||||
Use: use,
|
Use: use,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: strconv.FormatInt(userID, 10),
|
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
|
return signedToken{raw: raw, jti: jti}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error {
|
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, appID uint32, accessJTI, refreshJTI string) error {
|
||||||
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 {
|
if err != nil {
|
||||||
return errs.FromDependencyError("jwt-refresh", err)
|
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 != "" {
|
if old.Access != "" {
|
||||||
_ = j.denylist.Add(ctx, old.Access, j.ttl)
|
_ = j.denylist.Add(ctx, old.Access, j.ttl)
|
||||||
}
|
}
|
||||||
if old.Refresh != "" {
|
if old.Refresh != "" {
|
||||||
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,20 +218,27 @@ func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.Iss
|
|||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uid, err := strconv.ParseInt(c.Subject, 10, 64)
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, errs.FromDependencyError("jwt-refresh", err)
|
return nil, errs.FromDependencyError("jwt-refresh", err)
|
||||||
}
|
}
|
||||||
if stored.Refresh == "" || stored.Refresh != c.ID {
|
if stored.Refresh == "" || stored.Refresh != c.ID {
|
||||||
return nil, errs.ErrUnauthorized
|
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 != "" {
|
if accessJTI != "" {
|
||||||
remain := accessExp.Sub(j.clock())
|
remain := accessExp.Sub(j.clock())
|
||||||
if remain > 0 {
|
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 {
|
if err != nil {
|
||||||
return errs.FromDependencyError("jwt-refresh", err)
|
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 != "" {
|
if old.Refresh != "" {
|
||||||
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
|
_ = 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 errs.FromDependencyError("jwt-refresh", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -240,15 +283,23 @@ func (j *JWT) LogoutAll(ctx context.Context, userID int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.FromDependencyError("jwt-refresh", err)
|
return errs.FromDependencyError("jwt-refresh", err)
|
||||||
}
|
}
|
||||||
|
seen := make(map[uint64]struct{})
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
row := rows[i]
|
row := rows[i]
|
||||||
if row.Pair.Access != "" {
|
if err := j.revokePairJTIs(ctx, row.Pair); err != nil {
|
||||||
_ = j.denylist.Add(ctx, row.Pair.Access, j.ttl)
|
return err
|
||||||
}
|
}
|
||||||
if row.Pair.Refresh != "" {
|
if j.pairMode == PairKeyPair {
|
||||||
_ = j.denylist.Add(ctx, row.Pair.Refresh, j.refreshTTL)
|
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)
|
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
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
uid, err := strconv.ParseInt(c.Subject, 10, 64)
|
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
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
exp := time.Time{}
|
exp := time.Time{}
|
||||||
if c.ExpiresAt != nil {
|
if c.ExpiresAt != nil {
|
||||||
exp = c.ExpiresAt.Time
|
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 {
|
func (j *JWT) Revoke(ctx context.Context, token string) error {
|
||||||
|
|||||||
@ -25,10 +25,12 @@ func testJWT(t *testing.T, clock func() time.Time) *JWT {
|
|||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jwtTestAppID uint32 = 1
|
||||||
|
|
||||||
func TestJWTIssueAuthenticate(t *testing.T) {
|
func TestJWTIssueAuthenticate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
tok, err := j.Issue(ctx, 42, 7)
|
tok, err := j.Issue(ctx, 42, 7, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -39,7 +41,7 @@ func TestJWTIssueAuthenticate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("%+v", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,7 +59,7 @@ func TestJWTRejectsBadToken(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tok, err := other.Issue(ctx, 1, 1)
|
tok, err := other.Issue(ctx, 1, 1, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -71,7 +73,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
clock := func() time.Time { return now }
|
clock := func() time.Time { return now }
|
||||||
j := testJWT(t, clock)
|
j := testJWT(t, clock)
|
||||||
tok, err := j.Issue(ctx, 1, 2)
|
tok, err := j.Issue(ctx, 1, 2, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -84,7 +86,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
issTok, err := wrongIss.Issue(ctx, 1, 2)
|
issTok, err := wrongIss.Issue(ctx, 1, 2, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -96,7 +98,7 @@ func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
|
|||||||
func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
|
func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
tok, err := j.Issue(ctx, 5, 8)
|
tok, err := j.Issue(ctx, 5, 8, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -124,7 +126,7 @@ func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
|
|||||||
func TestJWTLogoutRevokesPair(t *testing.T) {
|
func TestJWTLogoutRevokesPair(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
tok, err := j.Issue(ctx, 4, 2)
|
tok, err := j.Issue(ctx, 4, 2, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -132,7 +134,7 @@ func TestJWTLogoutRevokesPair(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
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) {
|
func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
first, err := j.Issue(ctx, 8, 1)
|
first, err := j.Issue(ctx, 8, 1, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
second, err := j.Issue(ctx, 8, 2)
|
second, err := j.Issue(ctx, 8, 2, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -171,7 +173,7 @@ func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) {
|
|||||||
func TestJWTDenylistRevoke(t *testing.T) {
|
func TestJWTDenylistRevoke(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
j := testJWT(t, nil)
|
j := testJWT(t, nil)
|
||||||
tok, err := j.Issue(ctx, 9, 3)
|
tok, err := j.Issue(ctx, 9, 3, jwtTestAppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -185,12 +187,15 @@ func TestJWTDenylistRevoke(t *testing.T) {
|
|||||||
|
|
||||||
func TestJWTIssueRejectsBadIDs(t *testing.T) {
|
func TestJWTIssueRejectsBadIDs(t *testing.T) {
|
||||||
j := testJWT(t, nil)
|
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)
|
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)
|
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) {
|
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) {
|
func TestNewJWTRequiresSecret(t *testing.T) {
|
||||||
if _, err := NewJWT(&Options{}); err == nil {
|
if _, err := NewJWT(&Options{}); err == nil {
|
||||||
t.Fatal("expected secret error")
|
t.Fatal("expected secret error")
|
||||||
@ -243,13 +293,13 @@ func TestRedisStoresRequireClient(t *testing.T) {
|
|||||||
t.Fatal("denylist add")
|
t.Fatal("denylist add")
|
||||||
}
|
}
|
||||||
p := NewRedisPairs(nil, "")
|
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")
|
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")
|
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")
|
t.Fatal("pairs del")
|
||||||
}
|
}
|
||||||
if _, err := p.ListByUser(ctx, 1); err == nil {
|
if _, err := p.ListByUser(ctx, 1); err == nil {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
authentity "spark-server/internal/module/auth/entity"
|
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"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
"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)
|
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 {
|
type LoginInput struct {
|
||||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
|
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"`
|
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"`
|
AppID uint8 `json:"app_id" validate:"required,min=1" label:"field.app_id" errmsg:"required:validate.login.app_id_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"`
|
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 string `json:"platform" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof: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"`
|
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 {
|
type RefreshInput struct {
|
||||||
@ -35,10 +44,11 @@ type SessionKicker interface {
|
|||||||
KickDevice(userID int64, deviceID uint64)
|
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 {
|
type LoginService struct {
|
||||||
Issuer Issuer
|
Issuer Issuer
|
||||||
Users Verifier
|
Users Verifier
|
||||||
|
Devices DeviceRegistrar
|
||||||
Sessions SessionKicker
|
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) {
|
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
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||||
return nil, err
|
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))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if u == nil || u.ID <= 0 {
|
if u == nil || u.ID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -86,10 +111,10 @@ func (s *LoginService) Logout(ctx context.Context) error {
|
|||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
rc := reqctx.From(ctx)
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
if s.Sessions != nil {
|
if s.Sessions != nil {
|
||||||
@ -98,25 +123,31 @@ func (s *LoginService) Logout(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginDeviceType(in *LoginInput) string {
|
func loginResolvedDeviceType(in *LoginInput) deviceentity.DeviceType {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return ""
|
return deviceentity.TypeUnknown
|
||||||
}
|
}
|
||||||
if t := userentity.NormalizeDeviceType(in.DeviceType); userentity.ValidDeviceType(t) {
|
if in.DeviceType >= 1 {
|
||||||
return t
|
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 {
|
if in == nil {
|
||||||
return &userentity.LoginDevice{}
|
return &userentity.LoginDevice{DeviceID: deviceID}
|
||||||
}
|
}
|
||||||
|
dtype := loginResolvedDeviceType(in)
|
||||||
d := &userentity.LoginDevice{
|
d := &userentity.LoginDevice{
|
||||||
DeviceID: in.DeviceID,
|
DeviceID: deviceID,
|
||||||
DeviceType: loginDeviceType(in),
|
AppID: in.AppID,
|
||||||
Platform: in.Platform,
|
DeviceType: dtype.PlatformName(),
|
||||||
DeviceName: in.DeviceName,
|
DeviceName: in.DeviceName,
|
||||||
|
IDFV: in.IDFV,
|
||||||
|
AndroidID: in.AndroidID,
|
||||||
RealIP: reqctx.RealIP(ctx),
|
RealIP: reqctx.RealIP(ctx),
|
||||||
Language: reqctx.Language(ctx),
|
Language: reqctx.Language(ctx),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package logic
|
package logic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@ -19,7 +20,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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"})
|
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
|
||||||
|
|
||||||
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
||||||
@ -35,11 +36,15 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
|||||||
t.Fatalf("en validate %+v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("%+v", tok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,10 +56,13 @@ func TestLoginServiceRefreshLogout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
users := StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"}
|
users := StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"}
|
||||||
kicker := &kickRec{}
|
kicker := &kickRec{}
|
||||||
svc := NewLoginService(j, users)
|
svc := testLoginService(j, users)
|
||||||
svc.Sessions = kicker
|
svc.Sessions = kicker
|
||||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -66,7 +74,9 @@ func TestLoginServiceRefreshLogout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err := svc.Logout(outCtx); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestStaticAuthenticate(t *testing.T) {
|
||||||
s := Static{Token: "abc", UserID: 1, DeviceID: 2}
|
s := Static{Token: "abc", UserID: 1, DeviceID: 2}
|
||||||
p, err := s.Authenticate(t.Context(), "abc")
|
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) {
|
func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
||||||
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -125,21 +189,26 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
|||||||
store := smsmodel.NewMemoryStore()
|
store := smsmodel.NewMemoryStore()
|
||||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||||
users := userlogic.NewService(repo, smsSvc, idgen, nil)
|
users := userlogic.NewService(repo, smsSvc, idgen, nil)
|
||||||
svc := NewLoginService(j, users)
|
svc := testLoginService(j, users)
|
||||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
||||||
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
||||||
})
|
})
|
||||||
tok, err := svc.Login(ctx, &LoginInput{
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
dev, err := repo.GetDevice(ctx, tok.UserID, 9)
|
dev, err := repo.GetDevice(ctx, tok.UserID, testAndroidDeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
internal/module/auth/logic/login_test_helpers.go
Normal file
40
internal/module/auth/logic/login_test_helpers.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
devicelogic "spark-server/internal/module/device/logic"
|
||||||
|
devicemodel "spark-server/internal/module/device/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testIOSDeviceID = mustTestDeviceID(deviceentity.TypeIOS, "00000000-0000-0000-0000-000000000007", "")
|
||||||
|
testAndroidDeviceID = mustTestDeviceID(deviceentity.TypeAndroid, "", "spark-test-android-9")
|
||||||
|
)
|
||||||
|
|
||||||
|
const testAppID uint8 = 1
|
||||||
|
|
||||||
|
func mustTestDeviceID(dtype deviceentity.DeviceType, idfv, androidID string) uint64 {
|
||||||
|
id, _, err := devicelogic.DeviceIDFromFingerprint(devicelogic.FingerprintInput{
|
||||||
|
DeviceType: dtype, IDFV: idfv, AndroidID: androidID,
|
||||||
|
})
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoginService(j *JWT, users Verifier) *LoginService {
|
||||||
|
reg := devicemodel.NewMemoryRegistry()
|
||||||
|
devices := devicelogic.NewService(reg)
|
||||||
|
svc := NewLoginService(j, users)
|
||||||
|
svc.Devices = devices
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedDeviceRegistrar struct{ id uint64 }
|
||||||
|
|
||||||
|
func (f fixedDeviceRegistrar) Register(_ context.Context, _ devicelogic.RegisterInput) (uint64, error) {
|
||||||
|
return f.id, nil
|
||||||
|
}
|
||||||
39
internal/module/auth/logic/pair_key.go
Normal file
39
internal/module/auth/logic/pair_key.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PairKeyMode controls refresh token storage key shape.
|
||||||
|
type PairKeyMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PairKeyTriple PairKeyMode = "triple" // (user_id, device_id, app_id)
|
||||||
|
PairKeyPair PairKeyMode = "pair" // (user_id, device_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m PairKeyMode) Valid() bool {
|
||||||
|
return m == PairKeyTriple || m == PairKeyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePairKeyMode(m PairKeyMode) PairKeyMode {
|
||||||
|
if m.Valid() {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return PairKeyTriple
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageKey(mode PairKeyMode, userID int64, deviceID uint64, appID uint32) string {
|
||||||
|
if normalizePairKeyMode(mode) == PairKeyPair {
|
||||||
|
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10) + ":" + strconv.FormatUint(uint64(appID), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userKeyPrefix(userID int64) string {
|
||||||
|
return strconv.FormatInt(userID, 10) + ":"
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceKeyPrefix(userID int64, deviceID uint64) string {
|
||||||
|
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
|
||||||
|
}
|
||||||
@ -26,20 +26,23 @@ type TokenPair struct {
|
|||||||
|
|
||||||
// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both.
|
// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both.
|
||||||
type PairStore interface {
|
type PairStore interface {
|
||||||
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) 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) (TokenPair, error)
|
Get(ctx context.Context, userID int64, deviceID uint64, appID uint32) (TokenPair, error)
|
||||||
Delete(ctx context.Context, userID int64, deviceID uint64) error
|
Delete(ctx context.Context, userID int64, deviceID uint64, appID uint32) error
|
||||||
ListByUser(ctx context.Context, userID int64) ([]DevicePair, 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.
|
// DevicePair is the live token pair for one device of a user.
|
||||||
type DevicePair struct {
|
type DevicePair struct {
|
||||||
DeviceID uint64
|
DeviceID uint64
|
||||||
|
AppID uint32
|
||||||
Pair TokenPair
|
Pair TokenPair
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryPairs struct {
|
type MemoryPairs struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
mode PairKeyMode
|
||||||
items map[string]storedPair
|
items map[string]storedPair
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,49 +52,67 @@ type storedPair struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryPairs() *MemoryPairs {
|
func NewMemoryPairs() *MemoryPairs {
|
||||||
return &MemoryPairs{items: make(map[string]storedPair)}
|
return NewMemoryPairsMode(PairKeyTriple)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pairKey(userID int64, deviceID uint64) string {
|
func NewMemoryPairsMode(mode PairKeyMode) *MemoryPairs {
|
||||||
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
|
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 {
|
if userID <= 0 || deviceID == 0 || ttl <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
key := storageKey(s.mode, userID, deviceID, appID)
|
||||||
s.mu.Lock()
|
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()
|
s.mu.Unlock()
|
||||||
return nil
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 {
|
if !ok {
|
||||||
return TokenPair{}, nil
|
return TokenPair{}, nil
|
||||||
}
|
}
|
||||||
if time.Now().UnixNano() >= row.exp {
|
if time.Now().UnixNano() >= row.exp {
|
||||||
delete(s.items, pairKey(userID, deviceID))
|
delete(s.items, key)
|
||||||
return TokenPair{}, nil
|
return TokenPair{}, nil
|
||||||
}
|
}
|
||||||
return row.pair, 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()
|
s.mu.Lock()
|
||||||
delete(s.items, pairKey(userID, deviceID))
|
delete(s.items, storageKey(s.mode, userID, deviceID, appID))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
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) {
|
func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair, error) {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
prefix := strconv.FormatInt(userID, 10) + ":"
|
prefix := userKeyPrefix(userID)
|
||||||
now := time.Now().UnixNano()
|
now := time.Now().UnixNano()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -105,32 +126,58 @@ func (s *MemoryPairs) ListByUser(_ context.Context, userID int64) ([]DevicePair,
|
|||||||
delete(s.items, key)
|
delete(s.items, key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
did, err := strconv.ParseUint(key[len(prefix):], 10, 64)
|
did, aid, ok := parsePairKey(key, prefix)
|
||||||
if err != nil || did == 0 {
|
if !ok || did == 0 {
|
||||||
continue
|
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
|
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 {
|
type RedisPairs struct {
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
prefix string
|
prefix string
|
||||||
|
mode PairKeyMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRedisPairs(rdb *redis.Client, prefix string) *RedisPairs {
|
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 == "" {
|
if prefix == "" {
|
||||||
prefix = "refresh:"
|
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 {
|
func (s *RedisPairs) key(userID int64, deviceID uint64, appID uint32) string {
|
||||||
return s.prefix + pairKey(userID, deviceID)
|
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 {
|
if s == nil || s.rdb == nil {
|
||||||
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if s == nil || s.rdb == nil {
|
||||||
return TokenPair{}, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
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 {
|
if err == redis.Nil {
|
||||||
return TokenPair{}, nil
|
return TokenPair{}, nil
|
||||||
}
|
}
|
||||||
@ -162,11 +209,38 @@ func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (To
|
|||||||
return pair, nil
|
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 {
|
if s == nil || s.rdb == nil {
|
||||||
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
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) {
|
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 {
|
if userID <= 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
prefix := s.prefix + strconv.FormatInt(userID, 10) + ":"
|
prefix := s.prefix + userKeyPrefix(userID)
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
out := make([]DevicePair, 0)
|
out := make([]DevicePair, 0)
|
||||||
for {
|
for {
|
||||||
@ -185,7 +259,12 @@ func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, key := range keys {
|
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 {
|
if err != nil || did == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -196,11 +275,18 @@ func (s *RedisPairs) ListByUser(ctx context.Context, userID int64) ([]DevicePair
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var pair TokenPair
|
||||||
if err := json.Unmarshal(raw, &pair); err != nil {
|
if err := json.Unmarshal(raw, &pair); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, DevicePair{DeviceID: did, Pair: pair})
|
out = append(out, DevicePair{DeviceID: did, AppID: aid, Pair: pair})
|
||||||
}
|
}
|
||||||
cursor = next
|
cursor = next
|
||||||
if cursor == 0 {
|
if cursor == 0 {
|
||||||
|
|||||||
82
internal/module/auth/logictest/wire.go
Normal file
82
internal/module/auth/logictest/wire.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Package logictest wires login + in-memory device registry for HTTP/integration tests.
|
||||||
|
package logictest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
devicelogic "spark-server/internal/module/device/logic"
|
||||||
|
devicemodel "spark-server/internal/module/device/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TestAppID = uint8(1)
|
||||||
|
TestIOSIDFV = "00000000-0000-0000-0000-000000000007"
|
||||||
|
TestAndroidID = "spark-test-android-9"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
TestIOSDeviceID = mustDeviceID(deviceentity.TypeIOS, TestIOSIDFV, "")
|
||||||
|
TestAndroidDeviceID = mustDeviceID(deviceentity.TypeAndroid, "", TestAndroidID)
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustDeviceID(dtype deviceentity.DeviceType, idfv, androidID string) uint64 {
|
||||||
|
id, _, err := devicelogic.DeviceIDFromFingerprint(devicelogic.FingerprintInput{
|
||||||
|
DeviceType: dtype,
|
||||||
|
IDFV: idfv,
|
||||||
|
AndroidID: androidID,
|
||||||
|
})
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginService returns LoginService with memory device registry.
|
||||||
|
func LoginService(j authlogic.Issuer, users authlogic.Verifier) *authlogic.LoginService {
|
||||||
|
reg := devicemodel.NewMemoryRegistry()
|
||||||
|
devices := devicelogic.NewService(reg)
|
||||||
|
svc := authlogic.NewLoginService(j, users)
|
||||||
|
svc.Devices = devices
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginBodyJSON is a minimal iOS login body for tests (stable device_id via TestIOSIDFV).
|
||||||
|
func LoginBodyJSON(email, verifyCode string) string {
|
||||||
|
return LoginBodyJSONNamed(email, verifyCode, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginBodyJSONNamed adds optional device_name.
|
||||||
|
func LoginBodyJSONNamed(email, verifyCode, deviceName string) string {
|
||||||
|
m := map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"verify_code": verifyCode,
|
||||||
|
"app_id": TestAppID,
|
||||||
|
"device_type": int(deviceentity.TypeIOS),
|
||||||
|
"idfv": TestIOSIDFV,
|
||||||
|
}
|
||||||
|
if deviceName != "" {
|
||||||
|
m["device_name"] = deviceName
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginBodyJSONWithDevice builds login JSON with explicit identity fields.
|
||||||
|
func LoginBodyJSONWithDevice(email, verifyCode string, deviceType uint8, idfv, androidID string) string {
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"verify_code": verifyCode,
|
||||||
|
"app_id": TestAppID,
|
||||||
|
"device_type": deviceType,
|
||||||
|
"idfv": idfv,
|
||||||
|
"android_id": androidID,
|
||||||
|
})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginBodyJSONAndroid is login with android_id (stable TestAndroidDeviceID).
|
||||||
|
func LoginBodyJSONAndroid(email, verifyCode string) string {
|
||||||
|
return LoginBodyJSONWithDevice(email, verifyCode, uint8(deviceentity.TypeAndroid), "", TestAndroidID)
|
||||||
|
}
|
||||||
13
internal/module/device/entity/app_id.go
Normal file
13
internal/module/device/entity/app_id.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
// AppID identifies a client application (independent package/signing).
|
||||||
|
type AppID uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppUnknown AppID = 0
|
||||||
|
AppSparkIM AppID = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a AppID) Valid() bool {
|
||||||
|
return a > 0
|
||||||
|
}
|
||||||
68
internal/module/device/entity/device_type.go
Normal file
68
internal/module/device/entity/device_type.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import userentity "spark-server/internal/module/user/entity"
|
||||||
|
|
||||||
|
// DeviceType is stored as TINYINT in devices.device_type.
|
||||||
|
type DeviceType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeUnknown DeviceType = 0
|
||||||
|
TypeIOS DeviceType = 1
|
||||||
|
TypeAndroid DeviceType = 2
|
||||||
|
TypeHarmony DeviceType = 3
|
||||||
|
TypeWindows DeviceType = 4
|
||||||
|
TypeMacOS DeviceType = 5
|
||||||
|
TypeWeb DeviceType = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeviceTypeFromPlatform(platform string) DeviceType {
|
||||||
|
switch userentity.NormalizeDeviceType(platform) {
|
||||||
|
case userentity.DeviceIOS:
|
||||||
|
return TypeIOS
|
||||||
|
case userentity.DeviceAndroid:
|
||||||
|
return TypeAndroid
|
||||||
|
case userentity.DeviceHarmony:
|
||||||
|
return TypeHarmony
|
||||||
|
case userentity.DeviceWindows:
|
||||||
|
return TypeWindows
|
||||||
|
case userentity.DeviceMacOS:
|
||||||
|
return TypeMacOS
|
||||||
|
case userentity.DeviceWeb:
|
||||||
|
return TypeWeb
|
||||||
|
default:
|
||||||
|
return TypeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t DeviceType) Valid() bool {
|
||||||
|
return t >= TypeIOS && t <= TypeWeb
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceTypeFromWire maps login/meta wire value to DeviceType.
|
||||||
|
func DeviceTypeFromWire(v uint8) DeviceType {
|
||||||
|
t := DeviceType(v)
|
||||||
|
if t.Valid() {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return TypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlatformName is the audit/log string for user_login_logs.device_type.
|
||||||
|
func (t DeviceType) PlatformName() string {
|
||||||
|
switch t {
|
||||||
|
case TypeIOS:
|
||||||
|
return userentity.DeviceIOS
|
||||||
|
case TypeAndroid:
|
||||||
|
return userentity.DeviceAndroid
|
||||||
|
case TypeHarmony:
|
||||||
|
return userentity.DeviceHarmony
|
||||||
|
case TypeWindows:
|
||||||
|
return userentity.DeviceWindows
|
||||||
|
case TypeMacOS:
|
||||||
|
return userentity.DeviceMacOS
|
||||||
|
case TypeWeb:
|
||||||
|
return userentity.DeviceWeb
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/module/device/logic/fingerprint.go
Normal file
68
internal/module/device/logic/fingerprint.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FingerprintInput is the physical identity for one login/register.
|
||||||
|
type FingerprintInput struct {
|
||||||
|
DeviceType deviceentity.DeviceType
|
||||||
|
IDFV string
|
||||||
|
AndroidID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintHash(in FingerprintInput) (string, error) {
|
||||||
|
if !in.DeviceType.Valid() {
|
||||||
|
return "", fmt.Errorf("device_type")
|
||||||
|
}
|
||||||
|
idfv := strings.TrimSpace(in.IDFV)
|
||||||
|
androidID := strings.TrimSpace(in.AndroidID)
|
||||||
|
var tail string
|
||||||
|
switch in.DeviceType {
|
||||||
|
case deviceentity.TypeIOS, deviceentity.TypeMacOS:
|
||||||
|
if idfv == "" {
|
||||||
|
return "", fmt.Errorf("idfv")
|
||||||
|
}
|
||||||
|
tail = idfv
|
||||||
|
case deviceentity.TypeAndroid, deviceentity.TypeHarmony:
|
||||||
|
if androidID == "" {
|
||||||
|
return "", fmt.Errorf("android_id")
|
||||||
|
}
|
||||||
|
tail = androidID
|
||||||
|
default:
|
||||||
|
switch {
|
||||||
|
case idfv != "":
|
||||||
|
tail = idfv
|
||||||
|
case androidID != "":
|
||||||
|
tail = androidID
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("device_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload := fmt.Sprintf("%d:%s", in.DeviceType, tail)
|
||||||
|
sum := sha256.Sum256([]byte(payload))
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceIDFromFingerprint derives the stable business device id.
|
||||||
|
func DeviceIDFromFingerprint(in FingerprintInput) (uint64, string, error) {
|
||||||
|
hash, err := fingerprintHash(in)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(hash))
|
||||||
|
id := binary.BigEndian.Uint64(sum[:8])
|
||||||
|
if id == 0 {
|
||||||
|
id = binary.BigEndian.Uint64(sum[1:9])
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
id = 1
|
||||||
|
}
|
||||||
|
return id, hash, nil
|
||||||
|
}
|
||||||
33
internal/module/device/logic/fingerprint_test.go
Normal file
33
internal/module/device/logic/fingerprint_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeviceIDFromFingerprintIOS(t *testing.T) {
|
||||||
|
id, hash, err := DeviceIDFromFingerprint(FingerprintInput{
|
||||||
|
DeviceType: deviceentity.TypeIOS,
|
||||||
|
IDFV: "00000000-0000-0000-0000-000000000007",
|
||||||
|
})
|
||||||
|
if err != nil || hash == "" || id == 0 {
|
||||||
|
t.Fatalf("id=%d hash=%q err=%v", id, hash, err)
|
||||||
|
}
|
||||||
|
id2, _, err := DeviceIDFromFingerprint(FingerprintInput{
|
||||||
|
DeviceType: deviceentity.TypeIOS,
|
||||||
|
IDFV: "00000000-0000-0000-0000-000000000007",
|
||||||
|
})
|
||||||
|
if err != nil || id2 != id {
|
||||||
|
t.Fatalf("stable %d %d", id, id2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceIDFromFingerprintRequiresFields(t *testing.T) {
|
||||||
|
if _, _, err := DeviceIDFromFingerprint(FingerprintInput{DeviceType: deviceentity.TypeIOS}); err == nil {
|
||||||
|
t.Fatal("ios without idfv")
|
||||||
|
}
|
||||||
|
if _, _, err := DeviceIDFromFingerprint(FingerprintInput{DeviceType: deviceentity.TypeAndroid}); err == nil {
|
||||||
|
t.Fatal("android without id")
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/module/device/logic/register.go
Normal file
78
internal/module/device/logic/register.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
"spark-server/internal/pkg/errs"
|
||||||
|
"spark-server/internal/pkg/i18n"
|
||||||
|
"spark-server/internal/pkg/reqctx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterInput is supplied at login before JWT issue.
|
||||||
|
type RegisterInput struct {
|
||||||
|
DeviceType deviceentity.DeviceType
|
||||||
|
DeviceName string
|
||||||
|
IDFV string
|
||||||
|
AndroidID string
|
||||||
|
ClientIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryRow is the persisted physical device.
|
||||||
|
type RegistryRow struct {
|
||||||
|
DeviceID uint64
|
||||||
|
FingerprintHash string
|
||||||
|
DeviceType deviceentity.DeviceType
|
||||||
|
DeviceName string
|
||||||
|
IDFV string
|
||||||
|
AndroidID string
|
||||||
|
LastClientIP string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry interface {
|
||||||
|
Upsert(ctx context.Context, row *RegistryRow) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
reg Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(reg Registry) *Service {
|
||||||
|
return &Service{reg: reg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(ctx context.Context, in RegisterInput) (uint64, error) {
|
||||||
|
if s == nil || s.reg == nil {
|
||||||
|
return 0, errs.ErrAvailable
|
||||||
|
}
|
||||||
|
deviceID, hash, err := DeviceIDFromFingerprint(FingerprintInput{
|
||||||
|
DeviceType: in.DeviceType,
|
||||||
|
IDFV: in.IDFV,
|
||||||
|
AndroidID: in.AndroidID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyDeviceIdentityInvalid, nil))
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ip := strings.TrimSpace(in.ClientIP)
|
||||||
|
if ip == "" {
|
||||||
|
ip = reqctx.RealIP(ctx)
|
||||||
|
}
|
||||||
|
row := &RegistryRow{
|
||||||
|
DeviceID: deviceID,
|
||||||
|
FingerprintHash: hash,
|
||||||
|
DeviceType: in.DeviceType,
|
||||||
|
DeviceName: strings.TrimSpace(in.DeviceName),
|
||||||
|
IDFV: strings.TrimSpace(in.IDFV),
|
||||||
|
AndroidID: strings.TrimSpace(in.AndroidID),
|
||||||
|
LastClientIP: ip,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.reg.Upsert(ctx, row); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return deviceID, nil
|
||||||
|
}
|
||||||
54
internal/module/device/logic/register_test.go
Normal file
54
internal/module/device/logic/register_test.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memReg struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*RegistryRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memReg) Upsert(_ context.Context, row *RegistryRow) error {
|
||||||
|
if row == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.m == nil {
|
||||||
|
m.m = make(map[string]*RegistryRow)
|
||||||
|
}
|
||||||
|
cp := *row
|
||||||
|
if cp.UpdatedAt.IsZero() {
|
||||||
|
cp.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
m.m[row.FingerprintHash] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUpsertsDevice(t *testing.T) {
|
||||||
|
svc := NewService(&memReg{})
|
||||||
|
ctx := context.Background()
|
||||||
|
id1, err := svc.Register(ctx, RegisterInput{
|
||||||
|
DeviceType: deviceentity.TypeIOS,
|
||||||
|
IDFV: "00000000-0000-0000-0000-000000000007",
|
||||||
|
DeviceName: "Phone",
|
||||||
|
ClientIP: "203.0.113.1",
|
||||||
|
})
|
||||||
|
if err != nil || id1 == 0 {
|
||||||
|
t.Fatalf("register %d %v", id1, err)
|
||||||
|
}
|
||||||
|
id2, err := svc.Register(ctx, RegisterInput{
|
||||||
|
DeviceType: deviceentity.TypeIOS,
|
||||||
|
IDFV: "00000000-0000-0000-0000-000000000007",
|
||||||
|
DeviceName: "Phone 2",
|
||||||
|
})
|
||||||
|
if err != nil || id2 != id1 {
|
||||||
|
t.Fatalf("same device %d %d %v", id1, id2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/module/device/model/memory_registry.go
Normal file
48
internal/module/device/model/memory_registry.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
devicelogic "spark-server/internal/module/device/logic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryRegistry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
byFP map[string]*devicelogic.RegistryRow
|
||||||
|
byDID map[uint64]*devicelogic.RegistryRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryRegistry() *MemoryRegistry {
|
||||||
|
return &MemoryRegistry{
|
||||||
|
byFP: make(map[string]*devicelogic.RegistryRow),
|
||||||
|
byDID: make(map[uint64]*devicelogic.RegistryRow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryRegistry) Upsert(_ context.Context, row *devicelogic.RegistryRow) error {
|
||||||
|
if row == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if old, ok := m.byFP[row.FingerprintHash]; ok {
|
||||||
|
cp := *old
|
||||||
|
cp.DeviceName = row.DeviceName
|
||||||
|
cp.IDFV = row.IDFV
|
||||||
|
cp.AndroidID = row.AndroidID
|
||||||
|
cp.LastClientIP = row.LastClientIP
|
||||||
|
cp.UpdatedAt = time.Now()
|
||||||
|
m.byFP[row.FingerprintHash] = &cp
|
||||||
|
m.byDID[cp.DeviceID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := *row
|
||||||
|
if cp.UpdatedAt.IsZero() {
|
||||||
|
cp.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
m.byFP[row.FingerprintHash] = &cp
|
||||||
|
m.byDID[row.DeviceID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
78
internal/module/device/model/registry.go
Normal file
78
internal/module/device/model/registry.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
devicelogic "spark-server/internal/module/device/logic"
|
||||||
|
"spark-server/internal/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceRow struct {
|
||||||
|
DeviceID uint64 `gorm:"column:device_id;primaryKey;comment:JWT did"`
|
||||||
|
FingerprintHash string `gorm:"column:fingerprint_hash;uniqueIndex:uk_fingerprint;comment:物理指纹"`
|
||||||
|
DeviceType uint8 `gorm:"column:device_type;comment:device_type 枚举"`
|
||||||
|
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
|
||||||
|
IDFV string `gorm:"column:idfv;comment:iOS IDFV"`
|
||||||
|
AndroidID string `gorm:"column:android_id;comment:Android ID"`
|
||||||
|
LastClientIP string `gorm:"column:last_client_ip;comment:最近 RealIP"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;comment:首次注册"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近 touch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceRow) TableName() string { return "devices" }
|
||||||
|
|
||||||
|
type RegistryRepo struct {
|
||||||
|
gdb *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistryRepo(gdb *gorm.DB) *RegistryRepo {
|
||||||
|
return &RegistryRepo{gdb: gdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepo) Upsert(ctx context.Context, row *devicelogic.RegistryRow) error {
|
||||||
|
if r == nil || r.gdb == nil || row == nil {
|
||||||
|
return errs.ErrAvailable
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if row.UpdatedAt.IsZero() {
|
||||||
|
row.UpdatedAt = now
|
||||||
|
}
|
||||||
|
dr := DeviceRow{
|
||||||
|
DeviceID: row.DeviceID,
|
||||||
|
FingerprintHash: row.FingerprintHash,
|
||||||
|
DeviceType: uint8(row.DeviceType),
|
||||||
|
DeviceName: row.DeviceName,
|
||||||
|
IDFV: row.IDFV,
|
||||||
|
AndroidID: row.AndroidID,
|
||||||
|
LastClientIP: row.LastClientIP,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
}
|
||||||
|
err := r.gdb.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "fingerprint_hash"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"device_name", "idfv", "android_id", "last_client_ip", "updated_at",
|
||||||
|
}),
|
||||||
|
}).Create(&dr).Error
|
||||||
|
return mapDBErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDBErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errs.ErrNotFound
|
||||||
|
}
|
||||||
|
var me *mysqldriver.MySQLError
|
||||||
|
if errors.As(err, &me) && me.Number == 1062 {
|
||||||
|
return errs.ErrDuplicate
|
||||||
|
}
|
||||||
|
return errs.ErrAvailable.WithError("device-db", err)
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ type Catalog struct {
|
|||||||
TCPCommand []Item `json:"tcp_command"`
|
TCPCommand []Item `json:"tcp_command"`
|
||||||
EnvelopeCode []Item `json:"envelope_code"`
|
EnvelopeCode []Item `json:"envelope_code"`
|
||||||
DeviceType []Item `json:"device_type"`
|
DeviceType []Item `json:"device_type"`
|
||||||
|
AppID []Item `json:"app_id"`
|
||||||
Language []Item `json:"language"`
|
Language []Item `json:"language"`
|
||||||
UploadPurpose []Item `json:"upload_purpose"`
|
UploadPurpose []Item `json:"upload_purpose"`
|
||||||
SystemKind []Item `json:"system_kind"`
|
SystemKind []Item `json:"system_kind"`
|
||||||
|
|||||||
@ -27,6 +27,7 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
|||||||
TCPCommand: tcpCommand(lang),
|
TCPCommand: tcpCommand(lang),
|
||||||
EnvelopeCode: envelopeCode(lang),
|
EnvelopeCode: envelopeCode(lang),
|
||||||
DeviceType: deviceType(lang),
|
DeviceType: deviceType(lang),
|
||||||
|
AppID: appID(lang),
|
||||||
Language: language(lang),
|
Language: language(lang),
|
||||||
UploadPurpose: uploadPurpose(lang),
|
UploadPurpose: uploadPurpose(lang),
|
||||||
SystemKind: systemKind(lang),
|
SystemKind: systemKind(lang),
|
||||||
|
|||||||
13
internal/module/meta/logic/enums_app.go
Normal file
13
internal/module/meta/logic/enums_app.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
"spark-server/internal/module/meta/entity"
|
||||||
|
"spark-server/internal/pkg/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func appID(lang i18n.Lang) []entity.Item {
|
||||||
|
return []entity.Item{
|
||||||
|
item(lang, "app_id", "spark_im", int(deviceentity.AppSparkIM)),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/module/meta/logic/enums_device.go
Normal file
18
internal/module/meta/logic/enums_device.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
deviceentity "spark-server/internal/module/device/entity"
|
||||||
|
"spark-server/internal/module/meta/entity"
|
||||||
|
"spark-server/internal/pkg/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func deviceType(lang i18n.Lang) []entity.Item {
|
||||||
|
return []entity.Item{
|
||||||
|
item(lang, "device_type", "ios", int(deviceentity.TypeIOS)),
|
||||||
|
item(lang, "device_type", "android", int(deviceentity.TypeAndroid)),
|
||||||
|
item(lang, "device_type", "harmony", int(deviceentity.TypeHarmony)),
|
||||||
|
item(lang, "device_type", "windows", int(deviceentity.TypeWindows)),
|
||||||
|
item(lang, "device_type", "macos", int(deviceentity.TypeMacOS)),
|
||||||
|
item(lang, "device_type", "web", int(deviceentity.TypeWeb)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,9 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
|||||||
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
|
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
|
||||||
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
|
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)
|
en := Catalog(i18n.En)
|
||||||
if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" {
|
if en.Gender[1].Label != "Male" || en.MessageType[0].Label != "Text" {
|
||||||
t.Fatalf("en labels %+v %+v", en.Gender, en.MessageType)
|
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.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.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
|
||||||
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
||||||
|
c.AppID,
|
||||||
} {
|
} {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
t.Fatal("empty set")
|
t.Fatal("empty set")
|
||||||
|
|||||||
@ -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)}
|
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 {
|
func language(lang i18n.Lang) []entity.Item {
|
||||||
return strSet(lang, "language", string(i18n.ZhCN), string(i18n.En), string(i18n.ZhTW))
|
return strSet(lang, "language", string(i18n.ZhCN), string(i18n.En), string(i18n.ZhTW))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,29 +23,28 @@ var deviceTypes = map[string]struct{}{
|
|||||||
DeviceWeb: {},
|
DeviceWeb: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device is a login device row for one user.
|
// UserDeviceLink is one user bound to one physical device (no app_id on this row).
|
||||||
type Device struct {
|
type UserDeviceLink struct {
|
||||||
ID int64 // table AUTO_INCREMENT; business key is (UserID, DeviceID)
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
DeviceID uint64
|
DeviceID uint64
|
||||||
DeviceType string
|
LastLoginAt time.Time
|
||||||
DeviceName string
|
LastActiveAt *time.Time
|
||||||
AppVersion string
|
CreatedAt time.Time
|
||||||
Language string
|
|
||||||
LastIP string
|
|
||||||
LastLoginAt 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 {
|
type LoginDevice struct {
|
||||||
DeviceID uint64
|
DeviceID uint64
|
||||||
|
AppID uint8
|
||||||
DeviceType string
|
DeviceType string
|
||||||
Platform string // alias of DeviceType; HTTP still accepts `platform`
|
Platform string
|
||||||
DeviceName string
|
DeviceName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
Language string
|
Language string
|
||||||
RealIP string
|
RealIP string
|
||||||
|
IDFV string
|
||||||
|
AndroidID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NormalizeDeviceType(s string) string {
|
func NormalizeDeviceType(s string) string {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"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.
|
// EmailBinding maps one normalized email to one user.
|
||||||
type EmailBinding struct {
|
type EmailBinding struct {
|
||||||
|
|||||||
@ -3,13 +3,19 @@ package entity
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestIsTestEmail(t *testing.T) {
|
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 {
|
for _, e := range ok {
|
||||||
if !IsTestEmail(e) {
|
if !IsTestEmail(e) {
|
||||||
t.Fatalf("want test %s", 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 {
|
for _, e := range bad {
|
||||||
if IsTestEmail(e) {
|
if IsTestEmail(e) {
|
||||||
t.Fatalf("not test %s", e)
|
t.Fatalf("not test %s", e)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type LoginLog struct {
|
|||||||
ID int64
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
DeviceID uint64
|
DeviceID uint64
|
||||||
|
AppID uint8
|
||||||
DeviceType string
|
DeviceType string
|
||||||
DeviceName string
|
DeviceName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
"spark-server/internal/pkg/reqctx"
|
"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.
|
// 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) {
|
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 {
|
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))
|
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
ip := strings.TrimSpace(device.RealIP)
|
active := now
|
||||||
if ip == "" {
|
return s.repo.UpsertDevice(ctx, &entity.UserDeviceLink{
|
||||||
ip = reqctx.RealIP(ctx)
|
UserID: userID,
|
||||||
}
|
DeviceID: device.DeviceID,
|
||||||
lang := strings.TrimSpace(device.Language)
|
LastLoginAt: now,
|
||||||
if lang == "" {
|
LastActiveAt: &active,
|
||||||
lang = reqctx.Language(ctx)
|
CreatedAt: now,
|
||||||
}
|
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +153,7 @@ func (s *Service) recordLogin(ctx context.Context, userID int64, device *entity.
|
|||||||
return s.repo.InsertLoginLog(ctx, &entity.LoginLog{
|
return s.repo.InsertLoginLog(ctx, &entity.LoginLog{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
DeviceID: device.DeviceID,
|
DeviceID: device.DeviceID,
|
||||||
|
AppID: device.AppID,
|
||||||
DeviceType: entity.DeviceTypeOf(device),
|
DeviceType: entity.DeviceTypeOf(device),
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
AppVersion: device.AppVersion,
|
AppVersion: device.AppVersion,
|
||||||
|
|||||||
@ -68,7 +68,7 @@ func TestAuthenticateTestEmailAnyCode(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("%+v", dev)
|
||||||
}
|
}
|
||||||
device.AppVersion = "1.0.0"
|
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 {
|
if _, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "x", device); err != nil {
|
||||||
t.Fatal(err)
|
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()
|
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)
|
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) {
|
func TestStaleUIDQRCodeIsReplaced(t *testing.T) {
|
||||||
|
|||||||
@ -20,8 +20,9 @@ type Repository interface {
|
|||||||
UpdateQRCode(ctx context.Context, userID int64, qrcode string) error
|
UpdateQRCode(ctx context.Context, userID int64, qrcode string) error
|
||||||
UpdateProfile(ctx context.Context, u *entity.User) error
|
UpdateProfile(ctx context.Context, u *entity.User) error
|
||||||
TouchLogin(ctx context.Context, userID int64, at time.Time) error
|
TouchLogin(ctx context.Context, userID int64, at time.Time) error
|
||||||
UpsertDevice(ctx context.Context, d *entity.Device) error
|
UpsertDevice(ctx context.Context, d *entity.UserDeviceLink) error
|
||||||
GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, 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
|
InsertLoginLog(ctx context.Context, log *entity.LoginLog) error
|
||||||
GetEmail(ctx context.Context, userID int64) (string, error)
|
GetEmail(ctx context.Context, userID int64) (string, error)
|
||||||
GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error)
|
GetDeleted(ctx context.Context, id int64) (*entity.Deleted, error)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ type MemoryRepo struct {
|
|||||||
byID map[int64]*entity.User
|
byID map[int64]*entity.User
|
||||||
byEmail map[string]int64
|
byEmail map[string]int64
|
||||||
bySpark map[string]int64
|
bySpark map[string]int64
|
||||||
devices map[deviceKey]*entity.Device
|
devices map[deviceKey]*entity.UserDeviceLink
|
||||||
logs []*entity.LoginLog
|
logs []*entity.LoginLog
|
||||||
deleted map[int64]*entity.Deleted
|
deleted map[int64]*entity.Deleted
|
||||||
deviceSeq int64
|
deviceSeq int64
|
||||||
@ -31,7 +31,7 @@ func NewMemoryRepo() *MemoryRepo {
|
|||||||
byID: make(map[int64]*entity.User),
|
byID: make(map[int64]*entity.User),
|
||||||
byEmail: make(map[string]int64),
|
byEmail: make(map[string]int64),
|
||||||
bySpark: 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),
|
logs: make([]*entity.LoginLog, 0),
|
||||||
deleted: make(map[int64]*entity.Deleted),
|
deleted: make(map[int64]*entity.Deleted),
|
||||||
}
|
}
|
||||||
@ -133,7 +133,7 @@ func (m *MemoryRepo) TouchLogin(_ context.Context, userID int64, at time.Time) e
|
|||||||
return nil
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
key := deviceKey{UserID: d.UserID, DeviceID: d.DeviceID}
|
key := deviceKey{UserID: d.UserID, DeviceID: d.DeviceID}
|
||||||
@ -149,7 +149,7 @@ func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.Device) error {
|
|||||||
return nil
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
d, ok := m.devices[deviceKey{UserID: userID, DeviceID: deviceID}]
|
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
|
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 {
|
func (m *MemoryRepo) UpdateQRCode(_ context.Context, userID int64, qrcode string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -9,66 +9,56 @@ import (
|
|||||||
"spark-server/internal/module/user/entity"
|
"spark-server/internal/module/user/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserDevice maps table user_devices: devices that have logged in.
|
|
||||||
type UserDevice struct {
|
type UserDevice struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
|
||||||
UserID int64 `gorm:"column:user_id;comment:所属用户"`
|
UserID int64 `gorm:"column:user_id;comment:所属用户"`
|
||||||
DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id,写入 JWT did"`
|
DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id,JWT did"`
|
||||||
DeviceType string `gorm:"column:platform;comment:设备类型:ios/android/harmony/windows/macos/web"`
|
LastLoginAt time.Time `gorm:"column:last_login_at;comment:该用户在此设备最近登录成功"`
|
||||||
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
|
LastActiveAt *time.Time `gorm:"column:last_active_at;comment:该用户在此设备最近活跃"`
|
||||||
AppVersion string `gorm:"column:app_version;comment:最近一次登录的客户端版本"`
|
CreatedAt time.Time `gorm:"column:created_at;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:该设备首次登录时间"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UserDevice) TableName() string { return "user_devices" }
|
func (UserDevice) TableName() string { return "user_devices" }
|
||||||
|
|
||||||
func (row *UserDevice) entity() *entity.Device {
|
func (row *UserDevice) entity() *entity.UserDeviceLink {
|
||||||
if row == nil {
|
if row == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &entity.Device{
|
return &entity.UserDeviceLink{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
UserID: row.UserID,
|
UserID: row.UserID,
|
||||||
DeviceID: row.DeviceID,
|
DeviceID: row.DeviceID,
|
||||||
DeviceType: row.DeviceType,
|
LastLoginAt: row.LastLoginAt,
|
||||||
DeviceName: row.DeviceName,
|
LastActiveAt: row.LastActiveAt,
|
||||||
AppVersion: row.AppVersion,
|
CreatedAt: row.CreatedAt,
|
||||||
Language: row.Language,
|
|
||||||
LastIP: row.LastIP,
|
|
||||||
LastLoginAt: row.LastLoginAt,
|
|
||||||
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)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
row := UserDevice{
|
row := UserDevice{
|
||||||
UserID: d.UserID,
|
UserID: d.UserID,
|
||||||
DeviceID: d.DeviceID,
|
DeviceID: d.DeviceID,
|
||||||
DeviceType: d.DeviceType,
|
LastLoginAt: d.LastLoginAt,
|
||||||
DeviceName: d.DeviceName,
|
LastActiveAt: d.LastActiveAt,
|
||||||
AppVersion: d.AppVersion,
|
CreatedAt: d.CreatedAt,
|
||||||
Language: d.Language,
|
|
||||||
LastIP: d.LastIP,
|
|
||||||
LastLoginAt: d.LastLoginAt,
|
|
||||||
CreatedAt: d.CreatedAt,
|
|
||||||
}
|
}
|
||||||
err = db.Clauses(clause.OnConflict{
|
err = db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}},
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"platform", "device_name", "app_version", "language", "last_ip", "last_login_at",
|
"last_login_at", "last_active_at",
|
||||||
}),
|
}),
|
||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
return mapDBErr(err)
|
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)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -79,3 +69,17 @@ func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*e
|
|||||||
}
|
}
|
||||||
return row.entity(), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -11,8 +11,9 @@ import (
|
|||||||
type UserLoginLog struct {
|
type UserLoginLog struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
|
||||||
UserID int64 `gorm:"column:user_id;comment:登录用户"`
|
UserID int64 `gorm:"column:user_id;comment:登录用户"`
|
||||||
DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id"`
|
DeviceID uint64 `gorm:"column:device_id;comment:devices.device_id"`
|
||||||
DeviceType string `gorm:"column:device_type;comment:设备类型:ios/android/harmony/windows/macos/web"`
|
AppID uint8 `gorm:"column:app_id;comment:当次登录 app_id"`
|
||||||
|
DeviceType string `gorm:"column:device_type;comment:设备类型"`
|
||||||
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
|
DeviceName string `gorm:"column:device_name;comment:设备展示名"`
|
||||||
AppVersion string `gorm:"column:app_version;comment:客户端版本"`
|
AppVersion string `gorm:"column:app_version;comment:客户端版本"`
|
||||||
Language string `gorm:"column:language;comment:登录语言"`
|
Language string `gorm:"column:language;comment:登录语言"`
|
||||||
@ -33,6 +34,7 @@ func (m *Repo) InsertLoginLog(ctx context.Context, log *entity.LoginLog) error {
|
|||||||
row := UserLoginLog{
|
row := UserLoginLog{
|
||||||
UserID: log.UserID,
|
UserID: log.UserID,
|
||||||
DeviceID: log.DeviceID,
|
DeviceID: log.DeviceID,
|
||||||
|
AppID: log.AppID,
|
||||||
DeviceType: log.DeviceType,
|
DeviceType: log.DeviceType,
|
||||||
DeviceName: log.DeviceName,
|
DeviceName: log.DeviceName,
|
||||||
AppVersion: log.AppVersion,
|
AppVersion: log.AppVersion,
|
||||||
|
|||||||
@ -23,6 +23,7 @@ func init() {
|
|||||||
"enum.device_type.windows": {ZhCN: "Windows", En: "Windows", ZhTW: "Windows"},
|
"enum.device_type.windows": {ZhCN: "Windows", En: "Windows", ZhTW: "Windows"},
|
||||||
"enum.device_type.macos": {ZhCN: "macOS", En: "macOS", ZhTW: "macOS"},
|
"enum.device_type.macos": {ZhCN: "macOS", En: "macOS", ZhTW: "macOS"},
|
||||||
"enum.device_type.web": {ZhCN: "Web", En: "Web", ZhTW: "Web"},
|
"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.zh_CN": {ZhCN: "简体中文", En: "Simplified Chinese", ZhTW: "簡體中文"},
|
||||||
"enum.language.en": {ZhCN: "English", En: "English", ZhTW: "English"},
|
"enum.language.en": {ZhCN: "English", En: "English", ZhTW: "English"},
|
||||||
"enum.language.zh_TW": {ZhCN: "繁体中文", En: "Traditional Chinese", ZhTW: "繁體中文"},
|
"enum.language.zh_TW": {ZhCN: "繁体中文", En: "Traditional Chinese", ZhTW: "繁體中文"},
|
||||||
|
|||||||
@ -41,5 +41,23 @@ func init() {
|
|||||||
KeyLoginRefreshRequired: {
|
KeyLoginRefreshRequired: {
|
||||||
ZhCN: "请填写刷新令牌", En: "Please enter the refresh token", ZhTW: "請填寫重新整理令牌",
|
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",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,9 @@ const (
|
|||||||
KeyFieldPlatform = "field.platform"
|
KeyFieldPlatform = "field.platform"
|
||||||
KeyFieldDeviceName = "field.device_name"
|
KeyFieldDeviceName = "field.device_name"
|
||||||
KeyFieldDeviceType = "field.device_type"
|
KeyFieldDeviceType = "field.device_type"
|
||||||
|
KeyFieldAppID = "field.app_id"
|
||||||
|
KeyFieldIDFV = "field.idfv"
|
||||||
|
KeyFieldAndroidID = "field.android_id"
|
||||||
KeyFieldRefreshToken = "field.refresh_token"
|
KeyFieldRefreshToken = "field.refresh_token"
|
||||||
KeyFieldNickname = "field.nickname"
|
KeyFieldNickname = "field.nickname"
|
||||||
KeyFieldAvatar = "field.avatar"
|
KeyFieldAvatar = "field.avatar"
|
||||||
@ -67,6 +70,9 @@ const (
|
|||||||
KeyLoginDeviceTypeRequired = "validate.login.device_type_required"
|
KeyLoginDeviceTypeRequired = "validate.login.device_type_required"
|
||||||
KeyLoginDeviceTypeInvalid = "validate.login.device_type_invalid"
|
KeyLoginDeviceTypeInvalid = "validate.login.device_type_invalid"
|
||||||
KeyLoginRefreshRequired = "validate.login.refresh_required"
|
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"
|
KeyFriendTargetRequired = "validate.friend.target_required"
|
||||||
KeyFriendQueryRequired = "validate.friend.query_required"
|
KeyFriendQueryRequired = "validate.friend.query_required"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type RequestContext struct {
|
|||||||
RealIP string
|
RealIP string
|
||||||
JTI string
|
JTI string
|
||||||
TokenExpires time.Time
|
TokenExpires time.Time
|
||||||
|
AppID uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
func With(ctx context.Context, rc *RequestContext) context.Context {
|
func With(ctx context.Context, rc *RequestContext) context.Context {
|
||||||
|
|||||||
@ -52,6 +52,7 @@ func Auth(svc authlogic.Service) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
rc.UserID = principal.UserID
|
rc.UserID = principal.UserID
|
||||||
rc.DeviceID = principal.DeviceID
|
rc.DeviceID = principal.DeviceID
|
||||||
|
rc.AppID = principal.AppID
|
||||||
rc.JTI = principal.JTI
|
rc.JTI = principal.JTI
|
||||||
rc.TokenExpires = principal.ExpiresAt
|
rc.TokenExpires = principal.ExpiresAt
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@ -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` 由客户端生成并持久化,不要每次换。
|
2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。
|
||||||
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
|
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
|
||||||
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
|
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
|
||||||
@ -108,7 +108,7 @@ info:
|
|||||||
X-Language: zh-CN
|
X-Language: zh-CN
|
||||||
X-App-Version: 1.0.0
|
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
|
```http
|
||||||
@ -206,7 +206,7 @@ info:
|
|||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
public static void main(String[] args) throws Exception {
|
||||||
String login = httpPost(API + "/v1/auth/login",
|
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 取出
|
String token = jsonString(login, "access_token"); // 从 data.access_token 取出
|
||||||
|
|
||||||
SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
SSLSocketFactory f = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||||
@ -298,7 +298,7 @@ info:
|
|||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("zh-CN", forHTTPHeaderField: "X-Language")
|
req.setValue("zh-CN", forHTTPHeaderField: "X-Language")
|
||||||
req.httpBody = """
|
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)
|
""".data(using: .utf8)
|
||||||
let data = try URLSession.shared.send(req)
|
let data = try URLSession.shared.send(req)
|
||||||
return try JSONDecoder().decode(LoginEnv.self, from: data).data.access_token
|
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 时为进程内内存)。
|
2. **按 target**:同一「渠道 + purpose + 规范化 target」**60 秒内**只能成功触发一次发信/发短信;Redis 键 `sms:cd:*`(无 Redis 时为进程内内存)。
|
||||||
不同 purpose、不同邮箱/手机号互不影响。成功响应 `data.expires_in` 固定 **300**(码有效期秒数,与 60s 冷却无关)。
|
不同 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;手机走阿里云短信(按号段选运营商模板)。
|
邮箱经 SMTP;手机走阿里云短信(按号段选运营商模板)。
|
||||||
operationId: sendVerificationCode
|
operationId: sendVerificationCode
|
||||||
@ -550,12 +550,12 @@ paths:
|
|||||||
summary: 邮箱验证码登录并签发 JWT
|
summary: 邮箱验证码登录并签发 JWT
|
||||||
description: |
|
description: |
|
||||||
首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。
|
首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。
|
||||||
登录写入/更新 `user_devices`(device_type 存在 `platform` 列、device_name、app_version、language、last_ip),并追加 `user_login_logs`。
|
登录 UPSERT `user_devices` 与 `devices`,并追加 `user_login_logs`(含 app_id)。
|
||||||
返回 access_token 与 refresh_token。测试账号任意验证码即可。
|
返回 access_token 与 refresh_token;`device_id` 由服务端生成。测试账号任意验证码即可。
|
||||||
`device_type` 必填:ios / android / harmony / windows / macos / web(也接受旧字段 `platform`)。
|
`device_type` 必填:整数 1–6(见 meta `device_type`);可选 `platform` 同枚举作别名。
|
||||||
|
|
||||||
Header(可选):`X-Language` / `Accept-Language`、`X-App-Version`。不要带 `Authorization`。
|
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
|
operationId: login
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/X-Language"
|
- $ref: "#/components/parameters/X-Language"
|
||||||
@ -570,8 +570,9 @@ paths:
|
|||||||
example:
|
example:
|
||||||
email: test01@spark.com
|
email: test01@spark.com
|
||||||
verify_code: "000000"
|
verify_code: "000000"
|
||||||
device_id: 7
|
app_id: 1
|
||||||
device_type: ios
|
device_type: 1
|
||||||
|
idfv: "00000000-0000-0000-0000-000000000007"
|
||||||
device_name: iPhone
|
device_name: iPhone
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@ -2041,8 +2042,12 @@ components:
|
|||||||
enum: [1, 2, 3]
|
enum: [1, 2, 3]
|
||||||
description: mailbox_events.event_type。1漏推消息 2撤回 3删除
|
description: mailbox_events.event_type。1漏推消息 2撤回 3删除
|
||||||
DeviceType:
|
DeviceType:
|
||||||
type: string
|
type: integer
|
||||||
enum: [ios, android, harmony, windows, macos, web]
|
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:
|
Language:
|
||||||
type: string
|
type: string
|
||||||
enum: [zh-CN, en, zh-TW]
|
enum: [zh-CN, en, zh-TW]
|
||||||
@ -2316,30 +2321,39 @@ components:
|
|||||||
description: 1=登录 2=注销账号
|
description: 1=登录 2=注销账号
|
||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email, verify_code, device_id]
|
required: [email, verify_code, app_id, device_type]
|
||||||
properties:
|
properties:
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
verify_code:
|
verify_code:
|
||||||
type: string
|
type: string
|
||||||
description: 邮箱验证码。测试账号 test01@spark.com~test10@spark.com 可任意填写。
|
description: 邮箱验证码。测试账号 test01@spark.com~test100@spark.com 可任意填写。
|
||||||
device_id:
|
app_id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
description: 当前客户端 App,见 meta `app_id` 枚举(JWT claim `aid`)。
|
||||||
device_type:
|
device_type:
|
||||||
$ref: "#/components/schemas/DeviceType"
|
$ref: "#/components/schemas/DeviceType"
|
||||||
platform:
|
platform:
|
||||||
allOf:
|
type: integer
|
||||||
- $ref: "#/components/schemas/DeviceType"
|
format: int32
|
||||||
description: device_type 的兼容字段
|
enum: [1, 2, 3, 4, 5, 6]
|
||||||
|
description: 与 device_type 相同枚举;仅当未传 device_type 时可作别名
|
||||||
device_name:
|
device_name:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 128
|
maxLength: 128
|
||||||
|
idfv:
|
||||||
|
type: string
|
||||||
|
maxLength: 36
|
||||||
|
description: iOS / macOS 必填(物理设备标识,服务端据此生成 device_id)。
|
||||||
|
android_id:
|
||||||
|
type: string
|
||||||
|
maxLength: 20
|
||||||
|
description: Android / Harmony 必填。
|
||||||
LoginData:
|
LoginData:
|
||||||
type: object
|
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:
|
properties:
|
||||||
access_token:
|
access_token:
|
||||||
type: string
|
type: string
|
||||||
@ -2362,6 +2376,10 @@ components:
|
|||||||
device_id:
|
device_id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
description: 服务端按物理指纹生成的设备 id(JWT `did`)。客户端持久化该值,勿自行生成。
|
||||||
|
app_id:
|
||||||
|
type: integer
|
||||||
|
description: 与 JWT claim `aid` 一致
|
||||||
spark_id:
|
spark_id:
|
||||||
type: string
|
type: string
|
||||||
description: Public handle, unique and user-changeable. New and updated values are 8-16 characters.
|
description: Public handle, unique and user-changeable. New and updated values are 8-16 characters.
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
favlogic "spark-server/internal/module/favorite/logic"
|
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)
|
favs := favlogic.NewService(favmodel.NewMemoryRepo(), msgs, convs, nil, ids)
|
||||||
r := NewEngine(zap.NewNop(), &EngineDeps{
|
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,
|
Files: files, Favorites: favs,
|
||||||
})
|
})
|
||||||
return &ginEngineBundle{engine: r}, ids, msgs, convs
|
return &ginEngineBundle{engine: r}, ids, msgs, convs
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authentity "spark-server/internal/module/auth/entity"
|
authentity "spark-server/internal/module/auth/entity"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
delivermodel "spark-server/internal/module/deliver/model"
|
delivermodel "spark-server/internal/module/deliver/model"
|
||||||
@ -42,7 +43,7 @@ func testFriendEngine(t *testing.T) *ginEngineBundle {
|
|||||||
)
|
)
|
||||||
r := NewEngine(zap.NewNop(), &EngineDeps{
|
r := NewEngine(zap.NewNop(), &EngineDeps{
|
||||||
Auth: j,
|
Auth: j,
|
||||||
Login: authlogic.NewLoginService(j, users),
|
Login: authlogictest.LoginService(j, users),
|
||||||
Users: users,
|
Users: users,
|
||||||
Friends: friends,
|
Friends: friends,
|
||||||
})
|
})
|
||||||
@ -55,7 +56,7 @@ type ginEngineBundle struct {
|
|||||||
|
|
||||||
func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken {
|
func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken {
|
||||||
t.Helper()
|
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 := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
delivermodel "spark-server/internal/module/deliver/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)
|
groups := grouplogic.NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), nil)
|
||||||
r := NewEngine(zap.NewNop(), &EngineDeps{
|
r := NewEngine(zap.NewNop(), &EngineDeps{
|
||||||
Auth: j,
|
Auth: j,
|
||||||
Login: authlogic.NewLoginService(j, users),
|
Login: authlogictest.LoginService(j, users),
|
||||||
Users: users,
|
Users: users,
|
||||||
Friends: friends,
|
Friends: friends,
|
||||||
Groups: groups,
|
Groups: groups,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
deliverlogic "spark-server/internal/module/deliver/logic"
|
deliverlogic "spark-server/internal/module/deliver/logic"
|
||||||
@ -48,7 +49,7 @@ func TestInboxIncrementalHTTP(t *testing.T) {
|
|||||||
box := syncmodel.NewMemoryStore(ids)
|
box := syncmodel.NewMemoryStore(ids)
|
||||||
syncSvc := synclogic.NewService(inbox, box)
|
syncSvc := synclogic.NewService(inbox, box)
|
||||||
h := NewEngine(zap.NewNop(), &EngineDeps{
|
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)
|
a := loginUser(t, h, "test01@spark.com", 1)
|
||||||
b := loginUser(t, h, "test02@spark.com", 2)
|
b := loginUser(t, h, "test02@spark.com", 2)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
delivermodel "spark-server/internal/module/deliver/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)
|
msgs := msglogic.NewService(store, chats, p2p, friends, nil, ids, q)
|
||||||
return NewEngine(zap.NewNop(), &EngineDeps{
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"spark-server/internal/infrastructure/id"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
delivermodel "spark-server/internal/module/deliver/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)
|
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
|
||||||
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{})
|
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{})
|
||||||
r := NewEngine(zap.NewNop(), &EngineDeps{
|
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,
|
Friends: friends, Moments: moments, Files: files,
|
||||||
})
|
})
|
||||||
return &ginEngineBundle{engine: r}
|
return &ginEngineBundle{engine: r}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@ -16,6 +17,7 @@ import (
|
|||||||
"spark-server/internal/infrastructure/logger"
|
"spark-server/internal/infrastructure/logger"
|
||||||
authentity "spark-server/internal/module/auth/entity"
|
authentity "spark-server/internal/module/auth/entity"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
authlogictest "spark-server/internal/module/auth/logictest"
|
||||||
"spark-server/internal/module/user/logictest"
|
"spark-server/internal/module/user/logictest"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/ratelimit"
|
"spark-server/internal/pkg/ratelimit"
|
||||||
@ -153,7 +155,7 @@ func testLoginEngine(t *testing.T) *gin.Engine {
|
|||||||
users.BindAccountCleanup(j, nil)
|
users.BindAccountCleanup(j, nil)
|
||||||
return NewEngine(zap.NewNop(), &EngineDeps{
|
return NewEngine(zap.NewNop(), &EngineDeps{
|
||||||
Auth: j,
|
Auth: j,
|
||||||
Login: authlogic.NewLoginService(j, users),
|
Login: authlogictest.LoginService(j, users),
|
||||||
SMS: smsSvc,
|
SMS: smsSvc,
|
||||||
Users: users,
|
Users: users,
|
||||||
})
|
})
|
||||||
@ -167,7 +169,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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("Content-Type", "application/json")
|
||||||
loginReq.Header.Set("X-App-Version", "1.0.0")
|
loginReq.Header.Set("X-App-Version", "1.0.0")
|
||||||
loginReq.Header.Set("X-Real-IP", "203.0.113.10")
|
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 {
|
if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("login data %+v", tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +210,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
uidJSON, _ := json.Marshal(tok.UserID)
|
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)
|
t.Fatalf("authed %+v", okEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,7 +231,7 @@ func TestLoginAndBearerAuth(t *testing.T) {
|
|||||||
func TestUsersMeAndSparkID(t *testing.T) {
|
func TestUsersMeAndSparkID(t *testing.T) {
|
||||||
r := testLoginEngine(t)
|
r := testLoginEngine(t)
|
||||||
w := httptest.NewRecorder()
|
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")
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, loginReq)
|
r.ServeHTTP(w, loginReq)
|
||||||
var loginEnv reply.Envelope
|
var loginEnv reply.Envelope
|
||||||
@ -287,7 +289,7 @@ func TestUsersMeAndSparkID(t *testing.T) {
|
|||||||
func TestRefreshAndLogout(t *testing.T) {
|
func TestRefreshAndLogout(t *testing.T) {
|
||||||
r := testLoginEngine(t)
|
r := testLoginEngine(t)
|
||||||
w := httptest.NewRecorder()
|
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")
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, loginReq)
|
r.ServeHTTP(w, loginReq)
|
||||||
var loginEnv reply.Envelope
|
var loginEnv reply.Envelope
|
||||||
@ -361,7 +363,7 @@ func TestRefreshAndLogout(t *testing.T) {
|
|||||||
func TestDeleteAccount(t *testing.T) {
|
func TestDeleteAccount(t *testing.T) {
|
||||||
r := testLoginEngine(t)
|
r := testLoginEngine(t)
|
||||||
w := httptest.NewRecorder()
|
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")
|
loginReq.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, loginReq)
|
r.ServeHTTP(w, loginReq)
|
||||||
var loginEnv reply.Envelope
|
var loginEnv reply.Envelope
|
||||||
@ -401,7 +403,7 @@ func TestDeleteAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
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")
|
again.Header.Set("Content-Type", "application/json")
|
||||||
r.ServeHTTP(w, again)
|
r.ServeHTTP(w, again)
|
||||||
var againEnv reply.Envelope
|
var againEnv reply.Envelope
|
||||||
@ -507,12 +509,12 @@ func TestLoginRateLimitedByRealIP(t *testing.T) {
|
|||||||
users, smsSvc := logictest.UsersAndSMS(ids, nil)
|
users, smsSvc := logictest.UsersAndSMS(ids, nil)
|
||||||
r := NewEngine(zap.NewNop(), &EngineDeps{
|
r := NewEngine(zap.NewNop(), &EngineDeps{
|
||||||
Auth: j,
|
Auth: j,
|
||||||
Login: authlogic.NewLoginService(j, users),
|
Login: authlogictest.LoginService(j, users),
|
||||||
SMS: smsSvc,
|
SMS: smsSvc,
|
||||||
Users: users,
|
Users: users,
|
||||||
AuthLimiter: ratelimit.New(1),
|
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 {
|
hit := func() reply.Envelope {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
|
||||||
|
|||||||
@ -94,7 +94,7 @@ func TestAuthJWTSameService(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tok, err := j.Issue(context.Background(), 42, 7)
|
tok, err := j.Issue(context.Background(), 42, 7, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
28
migrations/0019_devices.sql
Normal file
28
migrations/0019_devices.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-- Physical devices registry + slim user_devices + login log app_id.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
device_id BIGINT UNSIGNED NOT NULL COMMENT 'JWT did;多 App 同机共用',
|
||||||
|
fingerprint_hash CHAR(64) NOT NULL COMMENT '物理指纹 SHA256 hex,不含 app_id',
|
||||||
|
device_type TINYINT UNSIGNED NOT NULL COMMENT 'device_type 枚举 1=ios 2=android …',
|
||||||
|
device_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近 login 上报设备名',
|
||||||
|
idfv VARCHAR(36) NOT NULL DEFAULT '' COMMENT 'iOS IDFV',
|
||||||
|
android_id VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'Android ID',
|
||||||
|
last_client_ip VARCHAR(30) NOT NULL DEFAULT '' COMMENT '最近 RealIP',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '首次注册',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '最近 touch',
|
||||||
|
PRIMARY KEY (device_id),
|
||||||
|
UNIQUE KEY uk_fingerprint (fingerprint_hash)
|
||||||
|
) COMMENT='物理设备;与用户、App 无关';
|
||||||
|
|
||||||
|
ALTER TABLE user_login_logs
|
||||||
|
ADD COLUMN app_id TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||||
|
COMMENT '当次登录 app_id' AFTER device_id;
|
||||||
|
|
||||||
|
ALTER TABLE user_devices
|
||||||
|
ADD COLUMN last_active_at DATETIME(3) NULL COMMENT '该用户在此设备最近活跃' AFTER last_login_at;
|
||||||
|
|
||||||
|
ALTER TABLE user_devices DROP COLUMN platform;
|
||||||
|
ALTER TABLE user_devices DROP COLUMN device_name;
|
||||||
|
ALTER TABLE user_devices DROP COLUMN app_version;
|
||||||
|
ALTER TABLE user_devices DROP COLUMN language;
|
||||||
|
ALTER TABLE user_devices DROP COLUMN last_ip;
|
||||||
Loading…
Reference in New Issue
Block a user