From 6b2f1e06e0bd46c006ffbf59514b3a33c86ad490 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 10 Sep 2026 09:42:48 +0800 Subject: [PATCH] feat: implement precise token logout --- docs/IM-前后端对接文档.md | 4 +-- docs/IM-增强项第一批开发.md | 4 +-- docs/IM-未实现后续增强项.md | 8 ++--- server/auth/token/auth_token.go | 5 +++ server/im_http.go | 35 +++++++++++++++++--- server/main.go | 1 + server/store/group_message_runtime.go | 28 ++++++++++++++++ server/store/group_message_runtime_test.go | 37 ++++++++++++++++++++-- 8 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 1cd37ed..ece50ff 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -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¤t_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 ` 和 `Idempotency-Key`。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png diff --git a/docs/IM-增强项第一批开发.md b/docs/IM-增强项第一批开发.md index ea8b1bd..462e709 100644 --- a/docs/IM-增强项第一批开发.md +++ b/docs/IM-增强项第一批开发.md @@ -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 | 群文件列表、登记、删除 | 否 | 路径已不误兜底,但业务未实现 | diff --git a/docs/IM-未实现后续增强项.md b/docs/IM-未实现后续增强项.md index e8d9c62..fbe20f2 100644 --- a/docs/IM-未实现后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -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,重新登录可恢复。 | diff --git a/server/auth/token/auth_token.go b/server/auth/token/auth_token.go index 8c28530..8cf661d 100644 --- a/server/auth/token/auth_token.go +++ b/server/auth/token/auth_token.go @@ -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 } diff --git a/server/im_http.go b/server/im_http.go index 3a5aefb..edd7168 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -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) { diff --git a/server/main.go b/server/main.go index 58e6d9c..bdb8e5f 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/store/group_message_runtime.go b/server/store/group_message_runtime.go index 85ccb33..a2fdf0d 100644 --- a/server/store/group_message_runtime.go +++ b/server/store/group_message_runtime.go @@ -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) diff --git a/server/store/group_message_runtime_test.go b/server/store/group_message_runtime_test.go index 1708a83..c32c297 100644 --- a/server/store/group_message_runtime_test.go +++ b/server/store/group_message_runtime_test.go @@ -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()