feat: 新增共库模式下登录失败日志表及相关功能
- 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离 - 调整登录失败日志逻辑,仅记录触发账号锁定的事件 - 新增管理端接口功能,为登录失败日志补充实时锁定状态 - 更新测试用例适配共库模式下的租户过滤逻辑
This commit is contained in:
@@ -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;
|
||||||
@@ -74,7 +74,7 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestGroupMessageQuerySupportsOperationalFilters(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)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
|
|||||||
t.Errorf("group message query missing %q: %s", clause, query)
|
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) {
|
if !reflect.DeepEqual(args, want) {
|
||||||
t.Fatalf("unexpected filter arguments: got %#v, want %#v", 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") {
|
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)
|
t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select)
|
||||||
}
|
}
|
||||||
if internalTenantResourceSpecs["login-failure-logs"] != nil {
|
// login-failure-logs 保留(仅记录触发锁定的事件,见 session.go lockedNow 分支),
|
||||||
t.Fatal("login-failure-logs query spec must be removed with im_login_failure_logs")
|
// 但共库模式必须带 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) {
|
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -113,7 +119,7 @@ func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
|
|||||||
t.Fatalf("user query missing %q: %s", clause, query)
|
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) {
|
if !reflect.DeepEqual(args, want) {
|
||||||
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
|
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,21 @@ func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureE
|
|||||||
if tenantAdp != a {
|
if tenantAdp != a {
|
||||||
return tenantAdp.TenantLoginFailureCreate(id, event)
|
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 {
|
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tinode/chat/server/store"
|
"github.com/tinode/chat/server/store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
@@ -156,6 +157,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resource == "login-failure-logs" {
|
||||||
|
attachLoginLockoutState(tenantID, result.Items)
|
||||||
|
}
|
||||||
writeIMSuccess(wrt, req, map[string]any{
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
"items": normalizeInternalAdminRowsForAPI(result.Items),
|
"items": normalizeInternalAdminRowsForAPI(result.Items),
|
||||||
"total": result.Total,
|
"total": result.Total,
|
||||||
@@ -637,6 +641,40 @@ func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any {
|
|||||||
return row
|
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) {
|
func parseInternalAdminDBID(raw string) (int64, bool) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
+1
-5
@@ -1151,7 +1151,6 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
|
|
||||||
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
|
||||||
if handler == nil {
|
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)
|
logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid)
|
||||||
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
|
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
|
||||||
return
|
return
|
||||||
@@ -1180,7 +1179,7 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
|
// 登录失败日志只记录触发锁定的事件(见上方 lockedNow 分支),普通失败不落库
|
||||||
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
|
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
|
||||||
if resp.Ctrl.Code >= 500 {
|
if resp.Ctrl.Code >= 500 {
|
||||||
// Log internal errors
|
// Log internal errors
|
||||||
@@ -1191,7 +1190,6 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rec == nil || rec.TenantID != s.tenantID {
|
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)
|
logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid)
|
||||||
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
|
||||||
return
|
return
|
||||||
@@ -1217,7 +1215,6 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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)
|
logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid)
|
||||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
||||||
return
|
return
|
||||||
@@ -1239,7 +1236,6 @@ func (s *Session) login(msg *ClientComMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid)
|
||||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user