feat: implement precise token logout

This commit is contained in:
richjohnrambo
2026-09-10 09:43:02 +08:00
parent 98234db263
commit 6b2f1e06e0
8 changed files with 107 additions and 15 deletions
+2 -2
View File
@@ -965,7 +965,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` |
| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` |
| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` |
| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` |
| `POST /v1/im/auth/logout` | `{}` | `{"status":"ok"}` |
| `GET /v1/im/app/releases/latest?platform=ios\|android&current_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` |
## 2. 消息首页、全局导航与系统通知
@@ -2486,7 +2486,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
**状态:菜单纯客户端;数据接口部分新增。**
- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。
- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。
- 退出:`POST /v1/im/auth/logout` 将当前登录 Token 的 SHA-256 摘要写入 Redis 撤销集合,TTL 与 Token 剩余有效期一致,不影响同账号其他设备。成功后关闭 WS、清安全存储和本租户用户缓存;请求须带 `Authorization: Bearer <token>` 和 `Idempotency-Key`。
- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。
### 10.8 设置(安卓).png
+2 -2
View File
@@ -39,7 +39,7 @@
| 群文件列表、登记、删除 | 已实现 | 新增 `GET/POST/DELETE /v1/im/groups/{topic}/files`,复用现有群文件表。 |
| 普通消息转发 | 未实现 | 待下一步接入消息快照、目标权限校验和消息落库。 |
| 合并转发 Bundle 详情 | 未实现 | 待下一步新增 bundle 表和详情接口。 |
| 登出与指定会话撤销 | 未实现 | 当前 token 缺少独立 session 标识,需要先统一 session 模型。 |
| 登出与指定会话撤销 | 部分实现 | `POST /v1/im/auth/logout` 已通过 Redis 精准撤销当前 token;按 session ID 撤销指定设备仍待统一 session 模型。 |
| 列表 `has_more` 统一 | 部分实现 | 新增/触达接口已补,历史接口需继续横向清理。 |
| 风控 token 单次消费接入业务接口 | 未实现 | 待和具体业务动作一起接入。 |
@@ -47,7 +47,7 @@
| 模块 | 优先级 | 开发项 | 是否依赖第三方 | 当前状态 |
| --- | --- | --- | --- | --- |
| 账号安全 | P0 | 登出与指定会话撤销 | 否 | 文档有接口,代码未见路由 |
| 账号安全 | P0 | 登出与指定会话撤销 | 否 | 当前 token 登出已实现;指定 session 撤销待实现 |
| 消息转发 | P0 | 普通消息转发 | 否 | 仅转发目标接口已实现 |
| 消息转发 | P0 | 合并转发 Bundle 详情 | 否 | 文档有接口,代码未见路由 |
| 群聊 | P0 | 群文件列表、登记、删除 | 否 | 路径已不误兜底,但业务未实现 |
+4 -4
View File
@@ -27,13 +27,13 @@
| --- | --- | --- | --- | --- |
| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 |
| P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
| P0 | 指定会话撤销 | 10.7 | `POST /v1/im/auth/logout` 已实现;`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` |
### 0.3 已抽入第一批开发的项目
以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。其中已落地项已移入各章“当前已完成基线”;仍未完全落地的项目继续以第一批开发文档作为实现、验收和同步更新依据。
1. 仍待落地:登出与指定会话撤销。
1. 仍待落地:按 session ID 撤销指定设备;当前 token 登出已完成。
2. 仍待落地:普通消息转发。
3. 仍待落地:合并转发 Bundle 详情。
4. 已落地:群文件列表、登记和删除。
@@ -74,7 +74,7 @@
| --- | --- | --- | --- |
| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 |
| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 指定会话撤销 | `POST /v1/im/auth/logout` 已通过 Redis 撤销当前 token;`DELETE /v1/im/security/sessions/{id}` 尚未实现。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P1 | 实名后台审核流转 | 当前支持实名提交、状态查询和申诉提交,后续需要后台审核、驳回原因、通过状态变更和申诉处理工单。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有处理状态。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
@@ -313,7 +313,7 @@
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 精准登出与会话撤销 | `auth/logout` 与 `security/sessions/{id}` 未见路由,现有 token 主要靠 `auth_version` 批量失效。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 指定会话撤销 | `auth/logout` 已通过 Redis token 摘要黑名单精准实现;`security/sessions/{id}` 仍未实现。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 |
| P0 | 青少年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
+5
View File
@@ -147,6 +147,11 @@ func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth
features := auth.Feature(tl.Features)
if features&auth.FeatureNoLogin == 0 {
if revoked, enabled, err := store.IsLoginTokenRevoked(types.TenantID(tl.TenantID), token); err != nil {
return nil, nil, err
} else if enabled && revoked {
return nil, nil, types.ErrFailed
}
if store.Security == nil {
return nil, nil, types.ErrInternal
}
+31 -4
View File
@@ -6677,14 +6677,19 @@ func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPr
}
func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
principal, _, _, err := resolveIMHTTPBearerToken(req)
return principal, err
}
func resolveIMHTTPBearerToken(req *http.Request) (*imPrincipal, []byte, time.Duration, error) {
authz := strings.TrimSpace(req.Header.Get("Authorization"))
token, ok := strings.CutPrefix(authz, "Bearer ")
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
return nil, types.ErrPermissionDenied
return nil, nil, 0, types.ErrPermissionDenied
}
handler := store.Store.GetLogicalAuthHandler("token")
if handler == nil || !handler.IsInitialized() {
return nil, types.ErrUnsupported
return nil, nil, 0, types.ErrUnsupported
}
secret := []byte(strings.TrimSpace(token))
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(token)); err == nil {
@@ -6692,9 +6697,31 @@ func resolveIMHTTPBearer(req *http.Request) (*imPrincipal, error) {
}
rec, _, err := handler.Authenticate(auth.AuthContext{}, secret)
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
return nil, types.ErrPermissionDenied
return nil, nil, 0, types.ErrPermissionDenied
}
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, nil
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, secret, time.Duration(rec.Lifetime), nil
}
func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, token, ttl, err := resolveIMHTTPBearerToken(req)
if err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
return
}
if ttl < time.Second {
ttl = time.Second
}
enabled, err := store.RevokeLoginToken(principal.TenantID, token, ttl)
if err != nil || !enabled {
logs.Warn.Println("im logout: token revocation failed", principal.TenantID, principal.Uid, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
+1
View File
@@ -861,6 +861,7 @@ func main() {
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
mux.HandleFunc(config.ApiPath+"v1/im/auth/logout", serveIMAuthLogout)
mux.HandleFunc(config.ApiPath+"v1/im/security/urls/check", serveIMSecurityURLCheck)
mux.HandleFunc(config.ApiPath+"v1/im/security/risk-challenges/", serveIMRiskChallengeConfirm)
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges)
+28
View File
@@ -2,6 +2,8 @@ package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
@@ -78,6 +80,7 @@ type groupMessageRuntime interface {
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
ClaimShortState(namespace, id string, ttl time.Duration) (bool, error)
ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool, bool, error)
ShortStateExists(namespace, id string) (bool, error)
}
var groupMsgRuntime groupMessageRuntime
@@ -146,6 +149,26 @@ func ClaimShortStateValue(namespace, id, value string, ttl time.Duration) (bool,
return claimed, sameValue, true, err
}
func RevokeLoginToken(tenantID types.TenantID, token []byte, ttl time.Duration) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || len(token) == 0 || ttl <= 0 {
return false, nil
}
digest := sha256.Sum256(token)
id := strconv.FormatInt(int64(tenantID), 10) + ":" + hex.EncodeToString(digest[:])
_, err := groupMsgRuntime.ClaimShortState("revoked_login_token", id, ttl)
return true, err
}
func IsLoginTokenRevoked(tenantID types.TenantID, token []byte) (bool, bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || len(token) == 0 {
return false, false, nil
}
digest := sha256.Sum256(token)
id := strconv.FormatInt(int64(tenantID), 10) + ":" + hex.EncodeToString(digest[:])
revoked, err := groupMsgRuntime.ShortStateExists("revoked_login_token", id)
return revoked, true, err
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
@@ -881,6 +904,11 @@ func (r *redisGroupMessageRuntime) ClaimShortStateValue(namespace, id, value str
return false, existing == value, nil
}
func (r *redisGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
count, err := r.client.Exists(context.Background(), r.shortStateKey(namespace, id)).Result()
return count > 0, err
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
+34 -3
View File
@@ -11,13 +11,15 @@ import (
type testGroupMessageRuntime struct {
claims map[string]*groupMessageUniqueRecord
seqs map[string]int64
shortStates map[string]bool
deletedPrefixes []string
}
func newTestGroupMessageRuntime() *testGroupMessageRuntime {
return &testGroupMessageRuntime{
claims: make(map[string]*groupMessageUniqueRecord),
seqs: make(map[string]int64),
claims: make(map[string]*groupMessageUniqueRecord),
seqs: make(map[string]int64),
shortStates: make(map[string]bool),
}
}
@@ -138,7 +140,12 @@ func (r *testGroupMessageRuntime) AllowRate(types.TenantID, string, int, time.Du
return true, 0, 0, nil
}
func (r *testGroupMessageRuntime) ClaimShortState(string, string, time.Duration) (bool, error) {
func (r *testGroupMessageRuntime) ClaimShortState(namespace, id string, _ time.Duration) (bool, error) {
key := namespace + ":" + id
if r.shortStates[key] {
return false, nil
}
r.shortStates[key] = true
return true, nil
}
@@ -146,6 +153,30 @@ func (r *testGroupMessageRuntime) ClaimShortStateValue(string, string, string, t
return true, true, nil
}
func (r *testGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) {
return r.shortStates[namespace+":"+id], nil
}
func TestLoginTokenRevocation(t *testing.T) {
oldRuntime := groupMsgRuntime
groupMsgRuntime = newTestGroupMessageRuntime()
t.Cleanup(func() { groupMsgRuntime = oldRuntime })
token := []byte("current-device-token")
if revoked, enabled, err := IsLoginTokenRevoked(7, token); err != nil || !enabled || revoked {
t.Fatalf("unexpected initial state: revoked=%v enabled=%v err=%v", revoked, enabled, err)
}
if enabled, err := RevokeLoginToken(7, token, time.Hour); err != nil || !enabled {
t.Fatalf("revoke token: enabled=%v err=%v", enabled, err)
}
if revoked, enabled, err := IsLoginTokenRevoked(7, token); err != nil || !enabled || !revoked {
t.Fatalf("revoked token was accepted: revoked=%v enabled=%v err=%v", revoked, enabled, err)
}
if revoked, _, err := IsLoginTokenRevoked(7, []byte("other-device-token")); err != nil || revoked {
t.Fatalf("other device token was revoked: revoked=%v err=%v", revoked, err)
}
}
func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) {
oldRuntime := groupMsgRuntime
runtime := newTestGroupMessageRuntime()