feat: 完善用户治理与登录白名单
This commit is contained in:
@@ -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';
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user