feat: 完善用户治理与登录白名单

This commit is contained in:
richjohnrambo
2026-09-08 10:55:26 +08:00
parent d239bd9ad0
commit 8116fb6fe8
8 changed files with 196 additions and 8 deletions
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS im_user_ip_whitelists (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
ip_cidr VARCHAR(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL DEFAULT 0,
deleted_at DATETIME(3) DEFAULT NULL,
deleted_by BIGINT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_user_ip_whitelist (user_id, ip_cidr),
KEY idx_im_user_ip_whitelist_user (user_id, status),
KEY idx_im_user_ip_whitelist_deleted (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Per-user login IP whitelist';
+32
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"time"
@@ -70,6 +71,37 @@ func recordSessionLoginFailure(s *Session, scheme string, secret []byte, rec *au
}
}
func checkUserLoginIPWhitelist(tenantID types.TenantID, uid types.Uid, remoteAddr string) error {
result, err := store.InternalAdminQueries.Query(tenantID,
"SELECT ip_cidr FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL ORDER BY id",
"SELECT COUNT(*) FROM im_user_ip_whitelists WHERE user_id=? AND status=1 AND deleted_at IS NULL",
[]any{store.DecodeUid(uid)}, 1, 500)
if err != nil {
if errors.Is(err, types.ErrUnsupported) {
return nil
}
return err
}
if result == nil || len(result.Items) == 0 {
return nil
}
ipText := sessionAuditIP(strings.Split(remoteAddr, ",")[0])
ip := net.ParseIP(ipText)
if ip == nil {
return types.ErrPermissionDenied
}
for _, item := range result.Items {
value := strings.TrimSpace(fmt.Sprint(item["ip_cidr"]))
if allowedIP := net.ParseIP(value); allowedIP != nil && allowedIP.Equal(ip) {
return nil
}
if _, block, parseErr := net.ParseCIDR(value); parseErr == nil && block.Contains(ip) {
return nil
}
}
return types.ErrPermissionDenied
}
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]))
+22
View File
@@ -94,6 +94,28 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
}
}
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202", nil)
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "")
if err != nil {
t.Fatal(err)
}
for _, clause := range []string{"CONCAT_WS(' ',u.username,p.nickname,u.phone) LIKE ?", "u.id IN (?,?)"} {
if !strings.Contains(query, clause) || !strings.Contains(countQuery, clause) {
t.Fatalf("user query missing %q: %s", clause, query)
}
}
want := []any{int64(101), int64(202), "%alice%"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
}
for _, field := range []string{"AS avatar", "AS register_type", "im_registration_invite_uses", "im_fileuploads AS avatar"} {
if !strings.Contains(usersInternalQuerySpec().Select+usersInternalQuerySpec().From, field) {
t.Fatalf("user query missing list field %q", field)
}
}
}
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))
@@ -54,6 +54,15 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
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`,
`CREATE TABLE IF NOT EXISTS im_user_ip_whitelists (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
ip_cidr VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_by BIGINT NOT NULL DEFAULT 0,
deleted_at DATETIME(3), deleted_by BIGINT,
PRIMARY KEY(id), UNIQUE INDEX uk_im_user_ip_whitelist(user_id,ip_cidr),
INDEX idx_im_user_ip_whitelist_user(user_id,status), INDEX idx_im_user_ip_whitelist_deleted(deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
@@ -13,11 +13,11 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
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))
if len(exec.statements) != 4 {
t.Fatalf("created %d admin governance tables, want 4", 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"} {
for _, table := range []string{"im_user_login_logs", "im_login_failure_logs", "im_message_admin_actions", "im_user_ip_whitelists"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
+28 -3
View File
@@ -270,12 +270,13 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
func usersInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{
Select: `SELECT CAST(u.id AS CHAR) AS user_id,u.username,u.phone,u.state,u.stateat AS state_at,u.access,u.lastseen AS last_seen,u.useragent AS user_agent,u.public,u.trusted,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
p.nickname,p.public_account,p.gender,p.region,p.avatar_file_id,COALESCE(avatar.location,'') AS avatar,p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`,
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id",
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone)", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
}
}
@@ -404,6 +405,16 @@ func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, sco
if err != nil {
return "", "", nil, 0, 0, err
}
if filter.Kind == "uid_list" {
ids := value.([]any)
placeholders := make([]string, len(ids))
for i := range ids {
placeholders[i] = "?"
}
where = append(where, filter.Column+" IN ("+strings.Join(placeholders, ",")+")")
args = append(args, ids...)
continue
}
op := filter.Op
if op == "" {
op = "="
@@ -430,6 +441,20 @@ func internalAdminFilterValue(_ string, raw, kind string) (any, error) {
return id, nil
case "int":
return strconv.ParseInt(raw, 10, 64)
case "uid_list":
parts := strings.Split(raw, ",")
if len(parts) == 0 || len(parts) > 5000 {
return nil, types.ErrMalformed
}
ids := make([]any, 0, len(parts))
for _, part := range parts {
id, ok := parseInternalAdminDBID(strings.TrimSpace(part))
if !ok {
return nil, types.ErrMalformed
}
ids = append(ids, id)
}
return ids, nil
default:
return raw, nil
}
+83 -2
View File
@@ -18,6 +18,7 @@ import (
"sync"
"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"
@@ -79,7 +80,7 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}()
if req.Method != http.MethodGet {
if req.Method != http.MethodGet && req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
@@ -97,6 +98,10 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
if !ok {
return
}
if req.Method == http.MethodPost && !(len(parts) == 3 && parts[2] == "users") {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
switch {
case len(parts) == 4 && parts[2] == "dashboard" && parts[3] == "overview":
@@ -107,7 +112,9 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
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":
case req.Method == http.MethodPost && len(parts) == 3 && parts[2] == "users":
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
serveInternalAdminResourceList(wrt, req, tenant.ID, "users", "")
case len(parts) == 4 && parts[2] == "users":
serveInternalUserQuery(wrt, req, tenant.ID, parts[3])
@@ -156,6 +163,80 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
}
}
type internalUserCreateRequest struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
Phone string `json:"phone"`
Password string `json:"password"`
}
func serveInternalUserCreate(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalUserCreateRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
body.Username = strings.ToLower(strings.TrimSpace(body.Username))
body.Nickname = strings.TrimSpace(body.Nickname)
body.Phone = strings.TrimSpace(body.Phone)
if body.Username == "" {
var err error
body.Username, err = randomRegistrationUsername()
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
if err := validateRegistrationUsername(body.Username); err != nil || len([]rune(body.Password)) < 3 || len([]byte(body.Password)) > 72 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "用户名或密码格式不正确", nil)
return
}
if body.Phone != "" {
phone, err := normalizeIMPhone(body.Phone, "CN")
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "手机号格式不正确", nil)
return
}
body.Phone = phone
}
authHandler := store.Store.GetLogicalAuthHandler("basic")
if authHandler == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "basic authentication unavailable", nil)
return
}
authCtx := auth.AuthContext{TenantID: tenantID, RemoteAddr: req.RemoteAddr}
secret := []byte(body.Username + ":" + body.Password)
if unique, err := authHandler.IsUnique(authCtx, secret); err != nil || !unique {
writeIMError(wrt, req, http.StatusConflict, 30001, "DUPLICATE_USER", "用户名已存在", nil)
return
}
user := &types.User{
TenantID: tenantID,
State: types.StateOK,
Username: body.Username,
Phone: body.Phone,
Public: map[string]any{"fn": firstNonEmpty(body.Nickname, body.Username)},
}
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) | getDefaultAccess(types.TopicCatGrp, true, false)
user.Access.Anon = getDefaultAccess(types.TopicCatP2P, false, false) | getDefaultAccess(types.TopicCatGrp, false, false)
created, err := store.Users.Create(tenantID, user, nil)
if err != nil {
writeInternalQueryError(wrt, req, err)
return
}
if _, err = authHandler.AddRecord(authCtx, &auth.Rec{TenantID: tenantID, Uid: created.Uid()}, secret); err != nil {
_ = store.Users.Delete(tenantID, created.Uid(), true)
writeInternalQueryError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"user_id": store.DecodeUid(created.Uid()),
"username": created.Username,
"nickname": body.Nickname,
"phone": created.Phone,
})
}
func authorizeInternalAPI(wrt http.ResponseWriter, req *http.Request, capability string) bool {
if !internalAPI.config.Enabled {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
+3
View File
@@ -1147,6 +1147,9 @@ func (s *Session) login(msg *ClientComMessage) {
if err == nil && rec.State != types.StateOK {
err = types.ErrPermissionDenied
}
if err == nil {
err = checkUserLoginIPWhitelist(s.tenantID, rec.Uid, s.remoteAddr)
}
if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)