feat: 新增共库模式下登录失败日志表及相关功能

- 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离
- 调整登录失败日志逻辑,仅记录触发账号锁定的事件
- 新增管理端接口功能,为登录失败日志补充实时锁定状态
- 更新测试用例适配共库模式下的租户过滤逻辑
This commit is contained in:
Austin
2026-09-23 13:18:28 +08:00
parent b8b21d3548
commit 6bbe955e75
5 changed files with 102 additions and 12 deletions
+12 -6
View File
@@ -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)
}
+15 -1
View File
@@ -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 {
+38
View File
@@ -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 == "" {
+1 -5
View File
@@ -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 {