Files
im_server/server/im_security_policy_runtime.go
T

331 lines
8.9 KiB
Go

package main
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
imReasonFriendAddDisabled = "FRIEND_ADD_DISABLED"
imReasonMessageCurfew = "MESSAGE_CURFEW_ACTIVE"
)
type imPolicyDecision struct {
Allowed bool
Reason string
Message string
}
func imAllowDecision() imPolicyDecision {
return imPolicyDecision{Allowed: true}
}
func imDenyDecision(reason, message string) imPolicyDecision {
return imPolicyDecision{Allowed: false, Reason: reason, Message: strings.TrimSpace(message)}
}
func imTenantCodeForPolicy(tenantID types.TenantID) (string, error) {
tenant, err := store.Tenants.GetByID(tenantID)
if err != nil || tenant == nil {
return "", err
}
return strings.TrimSpace(tenant.Code), nil
}
func imSecurityPolicy(tenantID types.TenantID, tenantCode string) (*types.SecurityPolicy, error) {
tenantCode = strings.TrimSpace(tenantCode)
if tenantCode == "" {
code, err := imTenantCodeForPolicy(tenantID)
if err != nil {
return nil, err
}
tenantCode = code
}
if tenantCode == "" {
return nil, types.ErrNotFound
}
policy, err := store.ModerationConfig.SecurityPolicy(tenantCode)
if err != nil {
return nil, err
}
return policy, nil
}
func imFriendAddPolicyDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, source string) imPolicyDecision {
policy, err := imSecurityPolicy(tenantID, tenantCode)
if err != nil {
if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("friend add policy lookup failed: tenant=%d code=%s user=%s err=%v", tenantID, tenantCode, actor.UserId(), err)
}
return imAllowDecision()
}
if policy != nil && !policy.GlobalFriendEnabled {
entry := imNormalizeFriendAddEntry(source)
if imFriendEntryBlocked(policy.FriendDisabledPolicy, entry) {
return imDenyDecision(imReasonFriendAddDisabled, imPolicyString(policy.FriendDisabledPolicy, "message", "当前时段暂不允许添加好友"))
}
}
profile, err := store.Profiles.Get(tenantID, actor)
if err != nil {
logs.Warn.Printf("friend add user policy lookup failed: tenant=%d user=%s err=%v", tenantID, actor.UserId(), err)
return imAllowDecision()
}
if profile != nil && !profile.FriendAddEnabled {
message := strings.TrimSpace(profile.FriendAddDisabledReason)
if message == "" {
message = "该用户暂不允许发起加好友"
}
return imDenyDecision(imReasonFriendAddDisabled, message)
}
return imAllowDecision()
}
func imFriendGlobalDisabledDecision(tenantID types.TenantID, tenantCode, source string) imPolicyDecision {
policy, err := imSecurityPolicy(tenantID, tenantCode)
if err != nil {
if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("friend global policy lookup failed: tenant=%d code=%s source=%s err=%v", tenantID, tenantCode, source, err)
}
return imAllowDecision()
}
if policy != nil && !policy.GlobalFriendEnabled && imFriendEntryBlocked(policy.FriendDisabledPolicy, imNormalizeFriendAddEntry(source)) {
return imDenyDecision(imReasonFriendAddDisabled, imPolicyString(policy.FriendDisabledPolicy, "message", "当前时段暂不允许添加好友"))
}
return imAllowDecision()
}
func imNormalizeFriendAddEntry(source string) string {
switch strings.TrimSpace(source) {
case "qr":
return "qrcode"
case "group":
return "group_member"
case "profile", "profile_card":
return "profile_card"
case "invite", "invite_auto_add":
return "invite_auto_add"
case "recommend", "recommendation":
return "recommendation"
default:
return "search"
}
}
func imFriendEntryBlocked(policy map[string]any, entry string) bool {
if len(policy) == 0 {
return true
}
raw, ok := policy["blocked_entries"]
if !ok {
return true
}
entries := imAnyStringSlice(raw)
if len(entries) == 0 {
return true
}
entry = strings.TrimSpace(entry)
for _, item := range entries {
if strings.TrimSpace(item) == entry {
return true
}
}
return false
}
func imMessageCurfewDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, topic string, cat types.TopicCat) imPolicyDecision {
if cat != types.TopicCatP2P {
return imAllowDecision()
}
policy, err := imSecurityPolicy(tenantID, tenantCode)
if err != nil {
if !errors.Is(err, types.ErrUnsupported) && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Printf("message curfew policy lookup failed: tenant=%d code=%s user=%s topic=%s err=%v", tenantID, tenantCode, actor.UserId(), topic, err)
}
return imAllowDecision()
}
if policy == nil || !policy.MessageCurfewEnabled {
return imAllowDecision()
}
scope := "private_message"
if cat == types.TopicCatGrp {
scope = "group_message"
}
for _, rule := range policy.CurfewRules {
if rule == nil || rule.Status != 1 || !imCurfewRuleScopeMatches(rule.Scope, scope) {
continue
}
if imCurfewWhitelisted(rule, actor, topic, cat) {
continue
}
if imCurfewRuleActive(rule, time.Now()) {
message := strings.TrimSpace(rule.Message)
if message == "" {
message = "当前时段暂不能发送消息"
}
return imDenyDecision(imReasonMessageCurfew, message)
}
}
return imAllowDecision()
}
func imCurfewRuleScopeMatches(ruleScope, scope string) bool {
ruleScope = strings.TrimSpace(ruleScope)
return ruleScope == "" || ruleScope == "all" || ruleScope == scope
}
func imCurfewWhitelisted(rule *types.MessageCurfewRule, actor types.Uid, topic string, cat types.TopicCat) bool {
uidValues := map[string]bool{
actor.UserId(): true,
actor.String(): true,
}
if raw := store.DecodeUid(actor); raw > 0 {
uidValues[strconv.FormatInt(raw, 10)] = true
}
for _, item := range rule.WhitelistUserIds {
if uidValues[strings.TrimSpace(item)] {
return true
}
}
if cat == types.TopicCatGrp {
for _, item := range rule.WhitelistGroupIds {
if strings.TrimSpace(item) == topic {
return true
}
}
}
return false
}
func imCurfewRuleActive(rule *types.MessageCurfewRule, now time.Time) bool {
loc := time.Local
if tz := strings.TrimSpace(rule.Timezone); tz != "" {
if loaded, err := time.LoadLocation(tz); err == nil {
loc = loaded
}
}
localNow := now.In(loc)
if !imCurfewDateMatches(rule, localNow) {
return false
}
start, okStart := imParseClock(rule.StartTime)
end, okEnd := imParseClock(rule.EndTime)
if !okStart || !okEnd {
return false
}
minute := localNow.Hour()*60 + localNow.Minute()
if start == end {
return true
}
if start < end {
return minute >= start && minute < end
}
return minute >= start || minute < end
}
func imCurfewDateMatches(rule *types.MessageCurfewRule, now time.Time) bool {
switch strings.TrimSpace(rule.DateType) {
case "", "daily":
return true
case "weekday":
return now.Weekday() >= time.Monday && now.Weekday() <= time.Friday
case "weekend":
return now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
case "custom":
date := now.Format("2006-01-02")
for _, item := range rule.CustomDates {
if strings.TrimSpace(item) == date {
return true
}
}
return false
default:
if len(rule.Weekdays) == 0 {
return false
}
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
for _, item := range rule.Weekdays {
if item == weekday {
return true
}
}
return false
}
}
func imParseClock(value string) (int, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
parts := strings.Split(value, ":")
if len(parts) < 2 {
return 0, false
}
hour, errHour := strconv.Atoi(parts[0])
minute, errMinute := strconv.Atoi(parts[1])
if errHour != nil || errMinute != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, false
}
return hour*60 + minute, true
}
func imPolicyString(policy map[string]any, key, fallback string) string {
if value := strings.TrimSpace(fmt.Sprint(policy[key])); value != "" && value != "<nil>" {
return value
}
return fallback
}
func imAnyStringSlice(raw any) []string {
switch value := raw.(type) {
case []string:
return value
case []any:
out := make([]string, 0, len(value))
for _, item := range value {
if text := strings.TrimSpace(fmt.Sprint(item)); text != "" && text != "<nil>" {
out = append(out, text)
}
}
return out
default:
return nil
}
}
func writeIMPolicyDecision(wrt http.ResponseWriter, req *http.Request, decision imPolicyDecision) {
message := strings.TrimSpace(decision.Message)
if message == "" {
message = "permission denied"
}
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = imReasonPermissionDenied
}
writeIMError(wrt, req, http.StatusForbidden, 30005, reason, message, map[string]any{
"reason": reason,
"message": message,
})
}
func imCurfewPolicyReply(msg *ClientComMessage, decision imPolicyDecision) *ServerComMessage {
reply := ErrPolicyReply(msg, types.TimeNow())
reply.Ctrl.Text = "message curfew active"
reply.Ctrl.Params = map[string]any{
"reason": decision.Reason,
"message": decision.Message,
}
return reply
}