diff --git a/docs/sql/IM-用户登录IP白名单SQL.sql b/docs/sql/IM-用户登录IP白名单SQL.sql new file mode 100644 index 0000000..b1e52b4 --- /dev/null +++ b/docs/sql/IM-用户登录IP白名单SQL.sql @@ -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'; diff --git a/server/auth_audit.go b/server/auth_audit.go index 56b4c37..d236584 100644 --- a/server/auth_audit.go +++ b/server/auth_audit.go @@ -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])) diff --git a/server/auth_audit_test.go b/server/auth_audit_test.go index 928d252..c6a1c2c 100644 --- a/server/auth_audit_test.go +++ b/server/auth_audit_test.go @@ -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)) diff --git a/server/db/mysql/admin_governance_schema.go b/server/db/mysql/admin_governance_schema.go index 8c07882..f05b503 100644 --- a/server/db/mysql/admin_governance_schema.go +++ b/server/db/mysql/admin_governance_schema.go @@ -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 { diff --git a/server/db/mysql/admin_governance_schema_test.go b/server/db/mysql/admin_governance_schema_test.go index e2075a0..d02e1c5 100644 --- a/server/db/mysql/admin_governance_schema_test.go +++ b/server/db/mysql/admin_governance_schema_test.go @@ -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) } diff --git a/server/internal_admin_query_resources.go b/server/internal_admin_query_resources.go index b9aa00e..f167a77 100644 --- a/server/internal_admin_query_resources.go +++ b/server/internal_admin_query_resources.go @@ -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 } diff --git a/server/internal_api.go b/server/internal_api.go index 7905444..ffe53e3 100644 --- a/server/internal_api.go +++ b/server/internal_api.go @@ -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) diff --git a/server/session.go b/server/session.go index f7ced8c..593d616 100644 --- a/server/session.go +++ b/server/session.go @@ -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)