feat: 接入后台消息治理与登录审计

This commit is contained in:
richjohnrambo
2026-09-07 11:53:24 +08:00
parent 90185b6c88
commit 6b20a633ce
17 changed files with 1062 additions and 45 deletions
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"strings"
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func recordSessionLoginSuccess(s *Session, rec *auth.Rec, scheme, suppliedAccount, requestID string, at time.Time) {
if s == nil || rec == nil || rec.Uid.IsZero() || !s.tenantID.IsValid() {
return
}
username := ""
if user, err := store.Users.Get(s.tenantID, rec.Uid); err == nil && user != nil {
username = user.Username
}
accountType, account := loginAuditAccount(scheme, suppliedAccount, rec.Credential)
if account == "" {
accountType, account = classifyLoginAccount(username)
}
event := &types.LoginAuditEvent{
UserID: rec.Uid, Username: username, AccountType: accountType,
AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
LoginType: loginAuditType(scheme), EventType: "login", SessionID: truncateAuditValue(s.sid, 96),
DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), IP: sessionAuditIP(s.remoteAddr),
UserAgent: truncateAuditValue(s.userAgent, 512), RiskLevel: "low",
RequestID: truncateAuditValue(requestID, 64), LoginAt: at.UTC().Round(time.Millisecond),
Metadata: map[string]any{"auth_level": rec.AuthLevel.String(), "tenant_code": s.tenantCode},
}
if err := store.SecurityAudit.RecordLogin(s.tenantID, event); err != nil {
logs.Warn.Printf("login audit write failed tenant:%d uid:%s sid:%s err:%v", s.tenantID, rec.Uid, s.sid, err)
}
}
func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *auth.Rec, requestID string, cause error, at time.Time) {
if s == nil || !s.tenantID.IsValid() {
return
}
suppliedAccount := ""
if strings.EqualFold(strings.TrimSpace(scheme), "basic") {
suppliedAccount = strings.SplitN(string(secret), ":", 2)[0]
}
credential := ""
uid := types.ZeroUid
if rec != nil {
credential = rec.Credential
uid = rec.Uid
}
accountType, account := loginAuditAccount(scheme, suppliedAccount, credential)
failureType, reason, riskLevel, riskAction := classifyLoginFailure(scheme, cause)
event := &types.LoginFailureEvent{
UserID: uid, AccountType: accountType, AccountHash: hashLoginAccount(account), AccountMask: maskLoginAccount(account),
FailureType: failureType, Reason: reason, Detail: truncateAuditValue(loginFailureDetail(cause), 500),
IP: sessionAuditIP(s.remoteAddr), DeviceID: truncateAuditValue(s.deviceID, 255), Platform: normalizeAuditPlatform(s.platf),
AppVersion: truncateAuditValue(versionToString(s.ver), 32), UserAgent: truncateAuditValue(s.userAgent, 512),
RiskLevel: riskLevel, RiskAction: riskAction, RequestID: truncateAuditValue(requestID, 64),
AttemptAt: at.UTC().Round(time.Millisecond), Metadata: map[string]any{"scheme": strings.TrimSpace(scheme), "tenant_code": s.tenantCode},
}
if err := store.SecurityAudit.RecordLoginFailure(s.tenantID, event); err != nil {
logs.Warn.Printf("login failure audit write failed tenant:%d sid:%s err:%v", s.tenantID, s.sid, err)
}
}
func loginAuditAccount(scheme, supplied, credential string) (string, string) {
if parts := strings.SplitN(strings.TrimSpace(credential), ":", 2); len(parts) == 2 && parts[1] != "" {
kind := strings.ToLower(strings.TrimSpace(parts[0]))
if kind == "tel" {
kind = "phone"
}
return kind, strings.TrimSpace(parts[1])
}
if strings.EqualFold(strings.TrimSpace(scheme), "basic") {
return classifyLoginAccount(supplied)
}
return "", ""
}
func classifyLoginAccount(account string) (string, string) {
account = strings.TrimSpace(account)
switch {
case account == "":
return "", ""
case strings.Contains(account, "@"):
return "email", account
case isPhoneLike(account):
return "phone", account
default:
return "username", account
}
}
func isPhoneLike(value string) bool {
digits := 0
for i, r := range value {
if r >= '0' && r <= '9' {
digits++
continue
}
if !(i == 0 && r == '+') {
return false
}
}
return digits >= 7
}
func maskLoginAccount(account string) string {
account = strings.TrimSpace(account)
if account == "" {
return ""
}
if at := strings.LastIndex(account, "@"); at > 0 {
return string([]rune(account[:at])[:1]) + "***" + account[at:]
}
runes := []rune(account)
if isPhoneLike(account) && len(runes) > 7 {
return string(runes[:3]) + "****" + string(runes[len(runes)-4:])
}
if len(runes) <= 2 {
return strings.Repeat("*", len(runes))
}
return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:])
}
func hashLoginAccount(account string) string {
account = strings.ToLower(strings.TrimSpace(account))
if account == "" {
return ""
}
sum := sha256.Sum256([]byte(account))
return hex.EncodeToString(sum[:])
}
func loginAuditType(scheme string) string {
switch strings.ToLower(strings.TrimSpace(scheme)) {
case "basic":
return "password"
case "token":
return "token_refresh"
case "code", "sms", "sms_code":
return "sms_code"
case "sso":
return "sso"
case "qr", "qr_code":
return "qr_code"
default:
return "password"
}
}
func classifyLoginFailure(scheme string, cause error) (failureType, reason, riskLevel, riskAction string) {
failureType, reason, riskLevel, riskAction = "password_error", "AUTH_FAILED", "medium", "audit_only"
detail := strings.ToLower(loginFailureDetail(cause))
switch {
case strings.Contains(detail, "rate") || strings.Contains(detail, "too many"):
return "rate_limited", "RATE_LIMITED", "high", "blocked"
case errors.Is(cause, types.ErrPolicy) || strings.Contains(detail, "risk"):
return "risk_blocked", "RISK_BLOCKED", "high", "blocked"
case errors.Is(cause, types.ErrPermissionDenied):
return "account_disabled", "ACCOUNT_DISABLED", "high", "blocked"
case errors.Is(cause, types.ErrUserNotFound) || errors.Is(cause, types.ErrNotFound):
return "account_not_found", "ACCOUNT_NOT_FOUND", "medium", "audit_only"
case loginAuditType(scheme) == "sms_code":
return "code_error", "CODE_ERROR", "medium", "audit_only"
default:
return
}
}
func loginFailureDetail(cause error) string {
if cause == nil {
return "authentication failed"
}
return cause.Error()
}
func sessionAuditIP(remoteAddr string) string {
remoteAddr = strings.TrimSpace(remoteAddr)
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return truncateAuditValue(host, 64)
}
return truncateAuditValue(strings.Trim(remoteAddr, "[]"), 64)
}
func normalizeAuditPlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
if platform == "" {
return "unknown"
}
return truncateAuditValue(platform, 32)
}
func truncateAuditValue(value string, max int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max])
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestLoginAuditAccountDoesNotTreatTokenAsAccount(t *testing.T) {
kind, account := loginAuditAccount("token", "secret-token", "")
if kind != "" || account != "" {
t.Fatalf("token must not be persisted as an account: kind=%q account=%q", kind, account)
}
kind, account = loginAuditAccount("basic", "13800138000", "")
if kind != "phone" || account != "13800138000" {
t.Fatalf("unexpected basic account classification: kind=%q account=%q", kind, account)
}
}
func TestMaskAndHashLoginAccount(t *testing.T) {
tests := map[string]string{
"13800138000": "138****8000",
"alice@example.com": "a***@example.com",
"alice": "a***e",
"ab": "**",
}
for input, want := range tests {
if got := maskLoginAccount(input); got != want {
t.Errorf("maskLoginAccount(%q)=%q want %q", input, got, want)
}
}
hash := hashLoginAccount(" Alice ")
if len(hash) != 64 || hash != hashLoginAccount("alice") || strings.Contains(hash, "alice") {
t.Fatalf("unexpected account hash %q", hash)
}
}
func TestClassifyLoginFailure(t *testing.T) {
tests := []struct {
scheme string
err error
want string
}{
{"basic", types.ErrFailed, "password_error"},
{"sms_code", types.ErrFailed, "code_error"},
{"basic", types.ErrUserNotFound, "account_not_found"},
{"basic", types.ErrPermissionDenied, "account_disabled"},
{"basic", types.ErrPolicy, "risk_blocked"},
{"basic", errors.New("too many attempts"), "rate_limited"},
}
for _, tc := range tests {
got, _, _, _ := classifyLoginFailure(tc.scheme, tc.err)
if got != tc.want {
t.Errorf("classifyLoginFailure(%q,%v)=%q want %q", tc.scheme, tc.err, got, tc.want)
}
}
}
func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
privateSpec := privateMessagesInternalQuerySpec()
groupSpec := groupMessagesInternalQuerySpec()
for label, spec := range map[string]*internalQuerySpec{"private": privateSpec, "group": groupSpec} {
joined := strings.ToLower(spec.Select + " " + spec.From)
for _, field := range []string{"from_user_id", "content_type", "content_excerpt", "status", "im_message_admin_actions"} {
if !strings.Contains(joined, field) {
t.Errorf("%s query missing %s", label, field)
}
}
}
if strings.Contains(strings.ToLower(privateSpec.Select), "m.content as content") || strings.Contains(strings.ToLower(groupSpec.Select), "m.content as content") {
t.Fatal("message list query must not expose full message content")
}
}
func TestRedactClientMessageForLog(t *testing.T) {
raw := []byte(`{"login":{"id":"1","scheme":"basic","secret":"YWxpY2U6YWxpY2UxMjM="},"acc":{"secret":"account-secret","tmpsecret":"temp-secret"}}`)
redacted := string(redactClientMessageForLog(raw))
for _, secret := range []string{"YWxpY2U6YWxpY2UxMjM=", "account-secret", "temp-secret"} {
if strings.Contains(redacted, secret) {
t.Fatalf("redacted log still contains secret %q: %s", secret, redacted)
}
}
if strings.Count(redacted, "[REDACTED]") != 3 {
t.Fatalf("unexpected redacted payload: %s", redacted)
}
}
+7
View File
@@ -240,6 +240,13 @@ type TenantInternalAdminQueryAdapter interface {
TenantInternalAdminQueryOne(tenantID t.TenantID, query string, args []any) (map[string]any, error)
}
type TenantSecurityAuditAdapter interface {
TenantLoginAuditCreate(t.TenantID, *t.LoginAuditEvent) error
TenantLoginFailureCreate(t.TenantID, *t.LoginFailureEvent) error
TenantLoginAuditClose(t.TenantID, string, time.Time) error
TenantMessageAdminApply(t.TenantID, *t.MessageAdminAction) error
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
+10
View File
@@ -272,6 +272,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureIMHTTPInfrastructureTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error())
}
if err = ensureIMAdminGovernanceTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure admin governance schema: " + err.Error())
}
if err = ensureIMFileReviewTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure file review schema: " + err.Error())
}
@@ -512,6 +515,10 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMAdminGovernanceTables(db); err != nil {
db.Close()
return nil, nil, err
}
if err = ensureIMFileReviewTables(db); err != nil {
db.Close()
return nil, nil, err
@@ -950,6 +957,9 @@ func (a *adapter) CreateDb(reset bool) error {
if err = ensureIMHTTPInfrastructureTables(tx); err != nil {
return err
}
if err = ensureIMAdminGovernanceTables(tx); err != nil {
return err
}
if err = ensureIMFileReviewTables(tx); err != nil {
return err
}
@@ -0,0 +1,64 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '', account_mask VARCHAR(128) NOT NULL DEFAULT '',
login_type VARCHAR(24) NOT NULL, event_type VARCHAR(24) NOT NULL DEFAULT 'login',
session_id VARCHAR(96) NOT NULL DEFAULT '', device_id VARCHAR(255) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT 'unknown', app_version VARCHAR(32) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '', region VARCHAR(128) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '', risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
INDEX idx_im_user_login_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_login_failure_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT,
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, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_login_failure_account_time(account_hash, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_user_time(user_id, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_ip_time(ip, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_type_time(failure_type, attempt_at DESC, id DESC),
INDEX idx_im_login_failure_request(request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_message_admin_actions (
id BIGINT NOT NULL AUTO_INCREMENT, action_id VARCHAR(96) NOT NULL,
command_id VARCHAR(96) NOT NULL, conversation_type VARCHAR(16) NOT NULL,
topic VARCHAR(64) NOT NULL, seqid BIGINT NOT NULL, sender_user_id BIGINT,
action VARCHAR(16) NOT NULL, action_status VARCHAR(16) NOT NULL DEFAULT 'pending',
reason VARCHAR(500) NOT NULL DEFAULT '', actor_admin_id BIGINT NOT NULL DEFAULT 0,
request_id VARCHAR(64) NOT NULL DEFAULT '', before_snapshot JSON, after_snapshot JSON,
failure_reason VARCHAR(96) NOT NULL DEFAULT '', failure_message VARCHAR(1000) NOT NULL DEFAULT '',
processed_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_message_admin_action_id(action_id),
UNIQUE INDEX uk_im_message_admin_command(command_id),
INDEX idx_im_message_admin_target(conversation_type, topic, seqid, id DESC),
INDEX idx_im_message_admin_status(action_status, created_at, id),
INDEX idx_im_message_admin_actor(actor_admin_id, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,28 @@
//go:build mysql
// +build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 3 {
t.Fatalf("created %d admin governance tables, want 3", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined)
}
}
+166
View File
@@ -0,0 +1,166 @@
//go:build mysql
// +build mysql
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jmoiron/sqlx"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil || event.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditCreate(id, event)
}
metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
_, err = a.db.Exec(`INSERT INTO im_user_login_logs
(user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask,
event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion,
event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata))
return err
}
func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || event == nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginFailureCreate(id, event)
}
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
(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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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 {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || strings.TrimSpace(sessionID) == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
}
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE session_id=? AND logout_at IS NULL", at, sessionID)
return err
}
func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminAction) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || action == nil || action.Topic == "" || action.SeqID <= 0 || (action.Action != "hide" && action.Action != "delete") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantMessageAdminApply(id, action)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var existing int64
if err = tx.GetContext(ctx, &existing, "SELECT COUNT(*) FROM im_message_admin_actions WHERE command_id=?", action.CommandID); err != nil {
return err
}
if existing > 0 {
return tx.Commit()
}
sender, beforeStatus, err := lockAdminMessage(ctx, tx, action.ConversationType, action.Topic, action.SeqID)
if err != nil {
return err
}
before, _ := json.Marshal(map[string]any{"status": beforeStatus, "sender_user_id": sender})
afterStatus := "hidden"
if action.Action == "delete" {
afterStatus = "deleted"
}
if action.ConversationType == "group" {
_, err = tx.ExecContext(ctx, "UPDATE im_group_messages SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
} else {
_, err = tx.ExecContext(ctx, "UPDATE im_messages SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID)
}
if err != nil {
return err
}
after, _ := json.Marshal(map[string]any{"status": afterStatus, "sender_user_id": sender})
_, err = tx.ExecContext(ctx, `INSERT INTO im_message_admin_actions
(action_id,command_id,conversation_type,topic,seqid,sender_user_id,action,action_status,reason,actor_admin_id,request_id,before_snapshot,after_snapshot,processed_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)`,
action.ActionID, action.CommandID, action.ConversationType, action.Topic, action.SeqID, sender,
action.Action, "success", action.Reason, action.ActorAdminID, action.RequestID, string(before), string(after), action.ProcessedAt)
if err != nil {
return err
}
return tx.Commit()
}
func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, conversationType, topic string, seqID int64) (int64, string, error) {
if conversationType == "group" {
var row struct {
Sender int64 `db:"from_user"`
Status string `db:"status"`
}
err := tx.GetContext(ctx, &row, "SELECT from_user,status FROM im_group_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
return row.Sender, row.Status, err
}
var row struct {
Sender int64 `db:"from"`
DeletedAt sql.NullTime `db:"deletedat"`
RecalledAt sql.NullTime `db:"recalled_at"`
}
err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM im_messages WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", t.ErrNotFound
}
status := "normal"
if row.RecalledAt.Valid {
status = "recalled"
}
if row.DeletedAt.Valid {
status = "deleted"
}
return row.Sender, status, err
}
func nullableJSON(raw []byte) any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return string(raw)
}
+92
View File
@@ -60,6 +60,18 @@ var internalGroupResourceSpecs = map[string]*internalQuerySpec{
}
var internalTenantResourceSpecs = map[string]*internalQuerySpec{
"private-messages": privateMessagesInternalQuerySpec(),
"group-messages": groupMessagesInternalQuerySpec(),
"user-login-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
},
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": {
Select: "SELECT id,request_id,actor_user_id,action,target_type,target_id,ip,user_agent,detail,created_at",
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id",
@@ -278,6 +290,86 @@ func groupsInternalQuerySpec() *internalQuerySpec {
}
}
func privateMessagesInternalQuerySpec() *internalQuerySpec {
rawContentType := `COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$."x-im-type"')),''),NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.head,'$.type')),''),CASE WHEN JSON_EXTRACT(m.content,'$.callId') IS NOT NULL THEN 'call' ELSE 'text' END)`
contentType := `CASE ` + rawContentType + ` WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE ` + rawContentType + ` END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.recalled_at IS NOT NULL THEN 'recalled' WHEN m.deletedat IS NOT NULL THEN 'deleted' ELSE 'normal' END`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.` + "`from`" + ` AS CHAR) AS from_user_id,
COALESCE(su.username,'') AS sender_username,COALESCE(sp.nickname,'') AS sender_nickname,
CAST(rs.userid AS CHAR) AS to_user_id,COALESCE(ru.username,'') AS receiver_username,COALESCE(rp.nickname,'') AS receiver_nickname,
` + contentType + ` AS content_type,` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,m.createdat AS created_at,m.updatedat AS updated_at`,
From: `FROM (SELECT * FROM im_messages WHERE topic LIKE 'p2p%') AS m
LEFT JOIN im_users AS su ON su.id=m.` + "`from`" + `
LEFT JOIN im_user_profiles AS sp ON sp.user_id=m.` + "`from`" + `
LEFT JOIN im_subscriptions AS rs ON rs.id=(SELECT MIN(s2.id) FROM im_subscriptions AS s2 WHERE s2.topic=m.topic AND s2.userid<>m.` + "`from`" + `)
LEFT JOIN im_users AS ru ON ru.id=rs.userid
LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}},
}
}
func groupMessagesInternalQuerySpec() *internalQuerySpec {
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
contentExcerpt := `LEFT(COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.content,'$.txt')),''),CASE JSON_TYPE(m.content) WHEN 'STRING' THEN JSON_UNQUOTE(m.content) ELSE CONCAT('[',` + contentType + `,']') END,''),500)`
return &internalQuerySpec{
Select: `SELECT m.topic,m.seqid AS seq_id,CAST(m.from_user AS CHAR) AS from_user_id,
COALESCE(u.username,'') AS sender_username,COALESCE(p.nickname,'') AS sender_nickname,
COALESCE(gs.name,'') AS group_name,m.topic AS group_no,` + contentType + ` AS content_type,
` + contentExcerpt + ` AS content_excerpt,
` + status + ` AS status,COALESCE(m.reply_seqid,0) AS reply_to_seq,m.mentioned_user_ids AS at_users,
m.created_at,m.updated_at`,
From: `FROM im_group_messages AS m
LEFT JOIN im_users AS u ON u.id=m.from_user
LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"sender_user_id", "m.from_user", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.created_at", ">=", "time"}, {"created_at_end", "m.created_at", "<=", "time"}},
}
}
func serveInternalDashboardOverview(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
row, err := store.InternalAdminQueries.QueryOne(tenantID, `SELECT
(SELECT COUNT(*) FROM im_users WHERE state<>20) AS total_users,
(SELECT COUNT(*) FROM im_users WHERE state=0 AND lastseen>=UTC_DATE()) AS active_users,
(SELECT COUNT(*) FROM im_group_settings WHERE group_status='active') AS total_groups,
(SELECT COUNT(*) FROM im_topics WHERE state<>20 AND (name LIKE 'p2p%' OR name LIKE 'grp%')) AS total_conversations,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%')+(SELECT COUNT(*) FROM im_group_messages)) AS total_messages,
((SELECT COUNT(*) FROM im_messages WHERE topic LIKE 'p2p%' AND createdat>=UTC_DATE())+(SELECT COUNT(*) FROM im_group_messages WHERE created_at>=UTC_DATE())) AS today_messages`, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
days := parseIMPositiveInt(req.URL.Query().Get("days"), 7)
if days > 31 {
days = 31
}
query := fmt.Sprintf(`WITH RECURSIVE dates AS (
SELECT UTC_DATE()-INTERVAL %d DAY AS day
UNION ALL SELECT day+INTERVAL 1 DAY FROM dates WHERE day<UTC_DATE()
) SELECT DATE_FORMAT(d.day,'%%Y-%%m-%%d') AS date,
(SELECT COUNT(*) FROM im_users AS u WHERE u.createdat>=d.day AND u.createdat<d.day+INTERVAL 1 DAY) AS user_count,
((SELECT COUNT(*) FROM im_messages AS pm WHERE pm.topic LIKE 'p2p%%' AND pm.createdat>=d.day AND pm.createdat<d.day+INTERVAL 1 DAY)+
(SELECT COUNT(*) FROM im_group_messages AS gm WHERE gm.created_at>=d.day AND gm.created_at<d.day+INTERVAL 1 DAY)) AS message_count
FROM dates AS d ORDER BY d.day ASC`, days-1)
result, err := store.InternalAdminQueries.Query(tenantID, query, fmt.Sprintf("SELECT %d", days), nil, 1, days)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage})
}
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) {
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
+19 -15
View File
@@ -99,6 +99,14 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
}
switch {
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "overview":
serveInternalDashboardOverview(wrt, req, tenant.ID)
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "trends":
serveInternalDashboardTrends(wrt, req, tenant.ID)
case len(parts) == 3 && parts[2] == "private-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "private-messages", "")
case len(parts) == 3 && parts[2] == "group-messages":
serveInternalAdminResourceList(wrt, req, tenant.ID, "group-messages", "")
case len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
@@ -377,25 +385,21 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
msg, err := store.Messages.GetOne(tenantID, topic, seq)
resource := "private-messages"
spec := privateMessagesInternalQuerySpec()
where := "m.topic=? AND m.seqid=?"
if strings.HasPrefix(topic, "grp") {
resource = "group-messages"
spec = groupMessagesInternalQuerySpec()
}
spec.Select += ",m.head,JSON_UNQUOTE(m.content) AS content"
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE "+where, []any{topic, seq})
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"topic": msg.Topic,
"seq_id": msg.SeqId,
"from": internalAdminUIDStringRaw(msg.From),
"client_msg_id": msg.ClientMsgID,
"version": msg.Version,
"head": msg.Head,
"content": msg.Content,
"deleted_at": msg.DeletedAt,
"recalled_at": msg.RecalledAt,
"recalled_by": internalAdminUIDStringRaw(msg.RecalledBy),
"created_at": msg.CreatedAt,
"updated_at": msg.UpdatedAt,
})
row["conversation_type"] = strings.TrimSuffix(resource, "-messages")
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
}
func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, fileID string) {
+68 -19
View File
@@ -33,6 +33,8 @@ const (
adminCommandUserCancel = "user.account.cancel"
adminCommandUserDelete = "user.account.delete"
adminCommandUserPasswordReset = "user.password.reset"
adminCommandMessageHide = "message.hide"
adminCommandMessageDelete = "message.delete"
adminCommandKafkaDeadReplay = "kafka.dead_letter.replay"
adminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore"
adminCommandKafkaDeadResolve = "kafka.dead_letter.resolve"
@@ -41,22 +43,24 @@ const (
var publishKafkaDeadLetter = publishKafkaEnvelope
type adminCommandPayload struct {
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
Topic string `json:"topic"`
UserID string `json:"user_id"`
UserIDs []string `json:"user_ids"`
TargetID string `json:"target_user_id"`
DeviceID string `json:"device_id"`
PasswordHash string `json:"password_hash"`
Version int `json:"version"`
Reason string `json:"reason"`
DeadID string `json:"dead_id"`
Note string `json:"note"`
Hard bool `json:"hard"`
ExpiresAt *time.Time `json:"expires_at"`
Profile groupProfilePatch `json:"profile"`
Governance groupGovernancePatch `json:"governance"`
CommandID string `json:"command_id"`
ActorUserID string `json:"actor_user_id"`
Topic string `json:"topic"`
ConversationType string `json:"conversation_type"`
SeqID int64 `json:"seq_id"`
UserID string `json:"user_id"`
UserIDs []string `json:"user_ids"`
TargetID string `json:"target_user_id"`
DeviceID string `json:"device_id"`
PasswordHash string `json:"password_hash"`
Version int `json:"version"`
Reason string `json:"reason"`
DeadID string `json:"dead_id"`
Note string `json:"note"`
Hard bool `json:"hard"`
ExpiresAt *time.Time `json:"expires_at"`
Profile groupProfilePatch `json:"profile"`
Governance groupGovernancePatch `json:"governance"`
}
type groupProfilePatch struct {
@@ -166,6 +170,15 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
if eventType == adminCommandUserPasswordReset {
return executeKafkaUserPasswordResetCommand(tenantID, payload)
}
if eventType == adminCommandMessageHide || eventType == adminCommandMessageDelete {
return executeKafkaMessageAdminCommand(tenantID, envelope, payload)
}
if eventType == adminCommandUserBan || eventType == adminCommandUserUnban ||
eventType == adminCommandUserFreeze || eventType == adminCommandUserUnfreeze ||
eventType == adminCommandUserDeviceKick || eventType == adminCommandUserLogoutAll ||
eventType == adminCommandUserCancel || eventType == adminCommandUserDelete {
return executeKafkaUserGovernanceCommand(tenantID, envelope, payload, types.ZeroUid)
}
actor := parseKafkaCommandUser(payload.ActorUserID)
if actor.IsZero() {
return nil, types.ErrMalformed
@@ -187,9 +200,6 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
return nil, err
}
return map[string]any{"dead_id": payload.DeadID, "status": types.KafkaDeadStatusResolved}, nil
case adminCommandUserBan, adminCommandUserUnban, adminCommandUserFreeze, adminCommandUserUnfreeze,
adminCommandUserDeviceKick, adminCommandUserLogoutAll, adminCommandUserCancel, adminCommandUserDelete:
return executeKafkaUserGovernanceCommand(tenantID, envelope, payload, actor)
}
if store.GroupBusiness == nil {
return nil, errors.New("group business store is not initialized")
@@ -320,6 +330,45 @@ func executeKafkaAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, p
return nil, errors.New("unsupported admin command: " + envelope.EventType)
}
func executeKafkaMessageAdminCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload) (map[string]any, error) {
conversationType := strings.TrimSpace(payload.ConversationType)
topic := strings.TrimSpace(payload.Topic)
if (conversationType != "private" && conversationType != "group") || topic == "" || payload.SeqID <= 0 {
return nil, types.ErrMalformed
}
action := strings.TrimPrefix(strings.TrimSpace(envelope.EventType), "message.")
actorAdminID := int64(0)
if envelope.Actor != nil {
actorAdminID, _ = strconv.ParseInt(strings.TrimSpace(envelope.Actor.ID), 10, 64)
}
now := time.Now().UTC().Round(time.Millisecond)
commandID := firstNonEmpty(payload.CommandID, envelope.EventID)
actionID := "action_" + envelope.EventID
if len(actionID) > 96 {
actionID = actionID[:96]
}
requestID := strings.TrimSpace(envelope.TraceID)
if len(requestID) > 64 {
requestID = requestID[:64]
}
if err := store.SecurityAudit.ApplyMessageAction(tenantID, &types.MessageAdminAction{
ActionID: actionID, CommandID: commandID, ConversationType: conversationType,
Topic: topic, SeqID: payload.SeqID, Action: action, Reason: strings.TrimSpace(payload.Reason),
ActorAdminID: actorAdminID, RequestID: requestID, ProcessedAt: now,
}); err != nil {
return nil, err
}
if conversationType == "group" {
if err := store.InvalidateHotMessages(tenantID, topic, []types.Range{{Low: payload.SeqID}}); err != nil {
logs.Warn.Printf("message admin cache invalidation failed tenant:%d topic:%s seq:%d err:%v", tenantID, topic, payload.SeqID, err)
}
}
return map[string]any{
"conversation_type": conversationType, "topic": topic, "seq_id": payload.SeqID,
"action": action, "status": "success", "processed_at": now,
}, nil
}
func executeKafkaUserGovernanceCommand(tenantID types.TenantID, envelope kafkaEnvelope, payload adminCommandPayload, actor types.Uid) (map[string]any, error) {
target := parseKafkaCommandUser(firstNonEmpty(payload.TargetID, payload.UserID))
if target.IsZero() {
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type messageAdminAuditRecorder struct {
action *types.MessageAdminAction
}
func (r *messageAdminAuditRecorder) RecordLogin(types.TenantID, *types.LoginAuditEvent) error {
return nil
}
func (r *messageAdminAuditRecorder) RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error {
return nil
}
func (r *messageAdminAuditRecorder) CloseLogin(types.TenantID, string, time.Time) error {
return nil
}
func (r *messageAdminAuditRecorder) ApplyMessageAction(_ types.TenantID, action *types.MessageAdminAction) error {
r.action = action
return nil
}
func TestExecuteKafkaMessageAdminCommand(t *testing.T) {
previous := store.SecurityAudit
recorder := &messageAdminAuditRecorder{}
store.SecurityAudit = recorder
t.Cleanup(func() { store.SecurityAudit = previous })
result, err := executeKafkaMessageAdminCommand(1, kafkaEnvelope{
EventID: "event-1", TraceID: "trace-1", EventType: adminCommandMessageHide,
Actor: &kafkaActor{Type: "admin", ID: "42", DisplayName: "operator"},
}, adminCommandPayload{
CommandID: "command-1", ConversationType: "private", Topic: "p2ptest", SeqID: 7, Reason: "sensitive content",
})
if err != nil {
t.Fatal(err)
}
if recorder.action == nil {
t.Fatal("message action was not persisted")
}
if recorder.action.ActionID != "action_event-1" || recorder.action.CommandID != "command-1" ||
recorder.action.ActorAdminID != 42 || recorder.action.Action != "hide" || recorder.action.Topic != "p2ptest" || recorder.action.SeqID != 7 {
t.Fatalf("unexpected persisted action: %+v", recorder.action)
}
if result["status"] != "success" || result["action"] != "hide" {
t.Fatalf("unexpected command result: %+v", result)
}
}
+43 -10
View File
@@ -461,6 +461,9 @@ func (s *Session) purgeChannels() {
func (s *Session) cleanUp(expired bool) {
atomic.StoreInt32(&s.terminating, 1)
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.SecurityAudit.CloseLogin(s.tenantID, s.sid, time.Now().UTC().Round(time.Millisecond)); err != nil {
logs.Warn.Println("s.cleanUp: failed to close login audit", s.uid, err, s.sid)
}
if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid)
}
@@ -498,24 +501,46 @@ func (s *Session) dispatchRaw(raw []byte) {
return
}
toLog := raw
truncated := ""
if len(raw) > 512 {
toLog = raw[:512]
truncated = "<...>"
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
if err := json.Unmarshal(raw, &msg); err != nil {
// Malformed message
logs.Warn.Println("s.dispatch", err, s.sid)
s.queueOut(ErrMalformed("", "", now))
return
}
toLog := redactClientMessageForLog(raw)
truncated := ""
if len(toLog) > 512 {
toLog = toLog[:512]
truncated = "<...>"
}
logs.Info.Printf("in: '%s%s' sid='%s' uid='%s'", toLog, truncated, s.sid, s.uid)
s.dispatch(&msg)
}
func redactClientMessageForLog(raw []byte) []byte {
var payload map[string]any
if json.Unmarshal(raw, &payload) != nil {
return []byte("[malformed payload]")
}
for _, section := range []string{"login", "acc"} {
body, ok := payload[section].(map[string]any)
if !ok {
continue
}
for _, field := range []string{"secret", "tmpsecret"} {
if _, exists := body[field]; exists {
body[field] = "[REDACTED]"
}
}
}
redacted, err := json.Marshal(payload)
if err != nil {
return []byte("[unprintable payload]")
}
return redacted
}
func (s *Session) dispatch(msg *ClientComMessage) {
now := types.TimeNow()
atomic.StoreInt64(&s.lastAction, now.UnixNano())
@@ -1089,6 +1114,7 @@ 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
@@ -1097,6 +1123,7 @@ func (s *Session) login(msg *ClientComMessage) {
rec, challenge, err := handler.Authenticate(
auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}, msg.Login.Secret)
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
if resp.Ctrl.Code >= 500 {
// Log internal errors
@@ -1107,6 +1134,7 @@ 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
@@ -1121,6 +1149,7 @@ 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
@@ -1142,10 +1171,11 @@ 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 {
s.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing))
s.queueOut(s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Login.Scheme, strings.SplitN(string(msg.Login.Secret), ":", 2)[0]))
}
}
@@ -1209,7 +1239,7 @@ func (s *Session) authSecretReset(params []byte) error {
}
// onLogin performs steps after successful authentication.
func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string) *ServerComMessage {
func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, missing []string, scheme, account string) *ServerComMessage {
var reply *ServerComMessage
var params map[string]any
@@ -1266,6 +1296,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
logs.Warn.Println("failed to update device record", err)
}
}
if !s.uid.IsZero() {
recordSessionLoginSuccess(s, rec, scheme, account, msgID, timestamp)
}
}
// GenSecret fails only if tokenLifetime is < 0. It can't be < 0 here,
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type SecurityAuditPersistenceInterface interface {
RecordLogin(types.TenantID, *types.LoginAuditEvent) error
RecordLoginFailure(types.TenantID, *types.LoginFailureEvent) error
CloseLogin(types.TenantID, string, time.Time) error
ApplyMessageAction(types.TenantID, *types.MessageAdminAction) error
}
type securityAuditMapper struct{}
var SecurityAudit SecurityAuditPersistenceInterface = securityAuditMapper{}
func tenantSecurityAuditAdapter(tenantID types.TenantID) (adapter.TenantSecurityAuditAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
audit, ok := business.(adapter.TenantSecurityAuditAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return audit, nil
}
func (securityAuditMapper) RecordLogin(tenantID types.TenantID, event *types.LoginAuditEvent) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginAuditCreate(tenantID, event)
}
func (securityAuditMapper) RecordLoginFailure(tenantID types.TenantID, event *types.LoginFailureEvent) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginFailureCreate(tenantID, event)
}
func (securityAuditMapper) CloseLogin(tenantID types.TenantID, sessionID string, at time.Time) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantLoginAuditClose(tenantID, sessionID, at)
}
func (securityAuditMapper) ApplyMessageAction(tenantID types.TenantID, action *types.MessageAdminAction) error {
audit, err := tenantSecurityAuditAdapter(tenantID)
if err != nil {
return err
}
return audit.TenantMessageAdminApply(tenantID, action)
}
+1
View File
@@ -4714,6 +4714,7 @@ func init() {
RegistrationConfig = registrationConfigMapper{}
TenantEntry = tenantEntryMapper{}
InternalAdminQueries = internalAdminQueryMapper{}
SecurityAudit = securityAuditMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
+59
View File
@@ -0,0 +1,59 @@
package types
import "time"
type LoginAuditEvent struct {
UserID Uid
Username string
AccountType string
AccountHash string
AccountMask string
LoginType string
EventType string
SessionID string
DeviceID string
Platform string
AppVersion string
IP string
Region string
UserAgent string
RiskLevel string
RequestID string
LoginAt time.Time
LogoutAt *time.Time
Metadata map[string]any
}
type LoginFailureEvent struct {
UserID Uid
AccountType string
AccountHash string
AccountMask string
FailureType string
Reason string
Detail string
IP string
Region string
DeviceID string
Platform string
AppVersion string
UserAgent string
RiskLevel string
RiskAction string
RequestID string
AttemptAt time.Time
Metadata map[string]any
}
type MessageAdminAction struct {
ActionID string
CommandID string
ConversationType string
Topic string
SeqID int64
Action string
Reason string
ActorAdminID int64
RequestID string
ProcessedAt time.Time
}
+1 -1
View File
@@ -307,7 +307,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
if msg.Acc.Login {
// Process user's login request.
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account)
} else {
// Not using the new account for logging in.
reply = NoErrCreated(msg.Id, "", msg.Timestamp)