From 6bbe955e7531b2da358504815a149d38dd3a56a0 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 23 Sep 2026 13:18:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=85=B1=E5=BA=93?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=A1=A8=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离 - 调整登录失败日志逻辑,仅记录触发账号锁定的事件 - 新增管理端接口功能,为登录失败日志补充实时锁定状态 - 更新测试用例适配共库模式下的租户过滤逻辑 --- docs/sql/IM-登录失败日志表-共库租户列.sql | 36 +++++++++++++++++++++ server/auth_audit_test.go | 18 +++++++---- server/db/mysql/tenant_security_audit.go | 16 +++++++++- server/internal_admin_query_resources.go | 38 +++++++++++++++++++++++ server/session.go | 6 +--- 5 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 docs/sql/IM-登录失败日志表-共库租户列.sql diff --git a/docs/sql/IM-登录失败日志表-共库租户列.sql b/docs/sql/IM-登录失败日志表-共库租户列.sql new file mode 100644 index 0000000..462e088 --- /dev/null +++ b/docs/sql/IM-登录失败日志表-共库租户列.sql @@ -0,0 +1,36 @@ +-- 登录失败日志表(共库多租户版)。 +-- 背景:登录失败日志仅记录触发账号锁定的事件(见 session.go lockedNow 分支), +-- 普通失败不再落库;锁定计数器存 Redis,im_login_lockouts 不再使用。 +-- 执行范围:每个租户业务库(共库模式即 tinode / meeting_im 库各执行一次)。 +-- 上游 scripts/IM-租户业务库初始化.sql 中的同名表无 tenant_id 列,勿用旧版执行。 + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0, + `user_id` bigint DEFAULT NULL, + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '', + `detail` varchar(500) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_tenant_time` (`tenant_id`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/server/auth_audit_test.go b/server/auth_audit_test.go index 9087c95..eac2cb2 100644 --- a/server/auth_audit_test.go +++ b/server/auth_audit_test.go @@ -74,7 +74,7 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) { func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) { req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil) - query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "") + query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1)) if err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) { t.Errorf("group message query missing %q: %s", clause, query) } } - want := []any{int64(42), "%alice%", "%小明%", "%测试群%"} + want := []any{int64(1), int64(42), "%alice%", "%小明%", "%测试群%"} if !reflect.DeepEqual(args, want) { t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want) } @@ -97,14 +97,20 @@ func TestLoginAuditQuerySpecsExposeUserIDAsString(t *testing.T) { if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") { t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select) } - if internalTenantResourceSpecs["login-failure-logs"] != nil { - t.Fatal("login-failure-logs query spec must be removed with im_login_failure_logs") + // login-failure-logs 保留(仅记录触发锁定的事件,见 session.go lockedNow 分支), + // 但共库模式必须带 tenant_id 隔离列,防止跨租户看到彼此的失败日志。 + failureSpec := internalTenantResourceSpecs["login-failure-logs"] + if failureSpec == nil { + t.Fatal("login-failure-logs query spec missing") + } + if failureSpec.TenantColumn != "tenant_id" { + t.Fatalf("login-failure-logs query spec must filter by tenant_id: %+v", failureSpec) } } func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) { req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil) - query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "") + query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "", types.TenantID(1)) if err != nil { t.Fatal(err) } @@ -113,7 +119,7 @@ func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) { t.Fatalf("user query missing %q: %s", clause, query) } } - want := []any{int64(101), int64(202), int64(1000), "%alice%"} + want := []any{int64(1), int64(101), int64(202), int64(1000), "%alice%"} if !reflect.DeepEqual(args, want) { t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want) } diff --git a/server/db/mysql/tenant_security_audit.go b/server/db/mysql/tenant_security_audit.go index 4e2fe20..1dee782 100644 --- a/server/db/mysql/tenant_security_audit.go +++ b/server/db/mysql/tenant_security_audit.go @@ -68,7 +68,21 @@ func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureE if tenantAdp != a { return tenantAdp.TenantLoginFailureCreate(id, event) } - return nil + metadata, err := json.Marshal(event.Metadata) + if err != nil { + return err + } + var userID any + if !event.UserID.IsZero() { + userID = decodeUID(event.UserID) + } + _, err = a.db.Exec(`INSERT INTO im_login_failure_logs + (tenant_id,user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + int64(id), userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail, + event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent, + event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata)) + return err } func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error { diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index a803c19..6efd79c 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" @@ -156,6 +157,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request, return } } + if resource == "login-failure-logs" { + attachLoginLockoutState(tenantID, result.Items) + } writeIMSuccess(wrt, req, map[string]any{ "items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, @@ -637,6 +641,40 @@ func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any { return row } +// internalAdminRowString 把 DB 行字段安全转为字符串(driver 可能返回 string 或 []byte)。 +func internalAdminRowString(row map[string]any, key string) string { + switch v := row[key].(type) { + case string: + return strings.TrimSpace(v) + case []byte: + return strings.TrimSpace(string(v)) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +// attachLoginLockoutState 为登录失败日志的锁定行补充 Redis 实时锁定状态。 +// 锁定记录存于 Redis(im_login_lockouts 表已废弃写入),日志行是历史快照不会更新, +// 管理端解锁按钮依赖 locked_now 判断"当前是否仍锁定中",解锁成功后按钮随刷新消失。 +func attachLoginLockoutState(tenantID types.TenantID, items []map[string]any) { + now := time.Now() + for _, item := range items { + if item == nil || !strings.EqualFold(internalAdminRowString(item, "risk_action"), loginAuditRiskActionLocked) { + continue + } + accountHash := internalAdminRowString(item, "account_hash") + if accountHash == "" { + continue + } + locked := false + if lockout, err := store.LoginLockouts.Get(tenantID, accountHash); err == nil && lockout != nil && + lockout.LockedUntil != nil && lockout.LockedUntil.After(now) { + locked = true + } + item["locked_now"] = locked + } +} + func parseInternalAdminDBID(raw string) (int64, bool) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/server/session.go b/server/session.go index a64b1cb..1b1635b 100644 --- a/server/session.go +++ b/server/session.go @@ -1151,7 +1151,6 @@ func (s *Session) login(msg *ClientComMessage) { handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme) if handler == nil { - recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp) logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid) s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp)) return @@ -1180,7 +1179,7 @@ func (s *Session) login(msg *ClientComMessage) { return } } - recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp) + // 登录失败日志只记录触发锁定的事件(见上方 lockedNow 分支),普通失败不落库 resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil) if resp.Ctrl.Code >= 500 { // Log internal errors @@ -1191,7 +1190,6 @@ func (s *Session) login(msg *ClientComMessage) { } if rec == nil || rec.TenantID != s.tenantID { - recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, types.ErrFailed, msg.Timestamp) logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid) s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil)) return @@ -1217,7 +1215,6 @@ func (s *Session) login(msg *ClientComMessage) { } if err != nil { - recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp) logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) return @@ -1239,7 +1236,6 @@ func (s *Session) login(msg *ClientComMessage) { } } if err != nil { - recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp) logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) } else {