diff --git a/go.mod b/go.mod index 7655d6b..d2e91a5 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.13 require ( github.com/AccelByte/go-jose v2.1.4+incompatible - github.com/AccelByte/iam-go-sdk/v2 v2.1.1 + github.com/AccelByte/iam-go-sdk/v2 v2.6.0 github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3 github.com/AccelByte/public-source-ip v1.0.1 github.com/DataDog/datadog-go v4.3.0+incompatible // indirect diff --git a/go.sum b/go.sum index bffa5e3..2b4868a 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/AccelByte/go-restful-plugins/v3 v3.2.1 h1:My/jP+wxJM+0adg1vJBja11/+tM github.com/AccelByte/go-restful-plugins/v3 v3.2.1/go.mod h1:XkhxnbfR/0z5lj2xI2SVbSuF5PhCi39xVYmVODwFf7k= github.com/AccelByte/iam-go-sdk v1.1.2 h1:LhmzKaXAsedI4BVLVmYtDTIsDcc+d51vP2CZ9aVowkI= github.com/AccelByte/iam-go-sdk v1.1.2/go.mod h1:M1Eplqpph/Msxm7XKgZRI+cYBCCChFdgPiVdKYupwq8= -github.com/AccelByte/iam-go-sdk/v2 v2.1.1 h1:I/C8eElxYXdIg5toISv5dkwYc+o11Jp9OZ30MAccrSw= -github.com/AccelByte/iam-go-sdk/v2 v2.1.1/go.mod h1:F9Hq3G4WB7eFUNUh14LwgzCeVjHZEyjCwNrDK6nkTA8= +github.com/AccelByte/iam-go-sdk/v2 v2.6.0 h1:2n2sqsMqWpoHR8/FU2cK384wtVTdNhMWd/oQkKXzX2M= +github.com/AccelByte/iam-go-sdk/v2 v2.6.0/go.mod h1:wjlMl03Aq5wkSbwBHp03c1P73lfPBqYDyQ83iXAZ1uk= github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3 h1:OWLHzjwh7A17GYxuQ7CJGn7yanFcohkEw0txL9MS2ME= github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3/go.mod h1:W2iKULTZwd3vVDcdaQ5iQKhYwzjgNd1lLA7kZmTh6WQ= github.com/AccelByte/public-source-ip v1.0.0/go.mod h1:L7zIgt3UaXkGH7NoKFCbxPdWZOVwn+d6uW/5yYRXtnQ= diff --git a/pkg/auth/iam/event.go b/pkg/auth/iam/event.go index 85e7fc8..54b16aa 100644 --- a/pkg/auth/iam/event.go +++ b/pkg/auth/iam/event.go @@ -45,6 +45,7 @@ const ( TokenIsNotUserToken = 20022 InvalidRefererHeader = 20023 SubdomainMismatch = 20030 + UserBanned = 20040 ) var ErrorCodeMapping = map[int]string{ @@ -64,4 +65,5 @@ var ErrorCodeMapping = map[int]string{ InvalidRefererHeader: "invalid referer header", SubdomainMismatch: "subdomain mismatch", TokenIsExpired: "token is expired", + UserBanned: "user banned", } diff --git a/pkg/auth/iam/iam.go b/pkg/auth/iam/iam.go index d5d15e8..63214cb 100644 --- a/pkg/auth/iam/iam.go +++ b/pkg/auth/iam/iam.go @@ -23,6 +23,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/AccelByte/go-restful-plugins/v4/pkg/auth/util" "github.com/AccelByte/go-restful-plugins/v4/pkg/constant" @@ -38,6 +39,9 @@ const ( accessTokenCookieKey = "access_token" tokenFromCookie = "cookie" tokenFromHeader = "header" + + MatchmakingBanTopic = "MATCHMAKING" + ChatBanTopic = "CHAT" ) var DevStackTraceable bool @@ -410,6 +414,54 @@ func WithValidScope(scope string) FilterOption { } } +// WithoutBannedTopics returns a FilterOption that enforces topic-specific bans found in a user's JWT claims. +// +// The returned FilterOption checks the provided claims for any active bans whose name appears in the +// bannedTopics slice. If claims is nil or bannedTopics is empty the filter is a no-op and returns nil. +// +// Parameters: +// - bannedTopics: A slice of strings representing the topics to check for bans +// +// Returns: +// - FilterOption: A function that implements the ban checking logic +// +// Behavior: +// - Constructs a set from bannedTopics for efficient lookup. +// - Iterates over claims.Bans and, for each ban whose name is present in that set, checks: +// - whether ban.TargetedNamespace matches claims.Namespace (case-insensitive), and +// - whether the current UTC time is before ban.EndDate (i.e. the ban is still active). +// - If both conditions are met, the filter returns a forbidden error (http.StatusForbidden) with a message +// indicating the ban type and its expiry time. +// - If no matching active ban is found, the filter returns nil and allows the request to proceed. +// +// The function evaluates ban expiry using time.Now().UTC() and formats the ban end date using RFC3339 +// in the returned error message. +// +// Note: This filter only covers bans that target the "game" namespace; bans in publisher/studio namespaces are not evaluated. +func WithoutBannedTopics(bannedTopics []string) FilterOption { + return func(req *restful.Request, iamClient iam.Client, claims *iam.JWTClaims) error { + if claims == nil || len(bannedTopics) == 0 { + return nil + } + bannedTopicMaps := map[string]bool{} + for _, v := range bannedTopics { + bannedTopicMaps[strings.ToUpper(v)] = true + } + + for _, b := range claims.Bans { + if _, ok := bannedTopicMaps[strings.ToUpper(b.Ban)]; !ok { + continue + } + if strings.EqualFold(b.TargetedNamespace, claims.Namespace) && time.Now().UTC().Before(b.EndDate) { + return respondError(http.StatusForbidden, ForbiddenAccess, + fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", b.Ban, b.EndDate.Format(time.RFC3339))) + } + } + + return nil + } +} + func validateSubdomainAgainstNamespace(host string, namespace string, excludedNamespaces []string) bool { part := strings.Split(host, ".") if len(part) < 3 { diff --git a/pkg/auth/iam/iam_test.go b/pkg/auth/iam/iam_test.go index a966b4d..3d9da34 100644 --- a/pkg/auth/iam/iam_test.go +++ b/pkg/auth/iam/iam_test.go @@ -15,10 +15,12 @@ package iam import ( + "fmt" "net/http" "net/url" "os" "testing" + "time" "github.com/AccelByte/go-restful-plugins/v4/pkg/constant" "github.com/AccelByte/iam-go-sdk/v2" @@ -1067,3 +1069,265 @@ func TestFilterInitializationOptionsFromEnv_SubdomainValidationExcludedNamespace options = FilterInitializationOptionsFromEnv() assert.Empty(t, options.SubdomainValidationExcludedNamespaces) } + +func TestWithoutBannedTopics(t *testing.T) { + timeNow := time.Now().UTC() + futureBanTime := timeNow.Add(24 * time.Hour) + pastBanTime := timeNow.Add(-24 * time.Hour) + gameNamespace, publisherNamespace := "game", "publisher" + + testCases := []struct { + name string + bannedTopics []string + claims *iam.JWTClaims + wantErr bool + errMessage restful.ServiceError + }{ + { + name: "nil claims should pass", + bannedTopics: []string{ChatBanTopic, MatchmakingBanTopic}, + claims: nil, + wantErr: false, + }, + { + name: "empty banned topics should pass", + bannedTopics: []string{}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: false, + }, + { + name: "non-matching ban topic should pass", + bannedTopics: []string{MatchmakingBanTopic}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: false, + }, + { + name: "expired ban should pass", + bannedTopics: []string{ChatBanTopic}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, EndDate: pastBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: false, + }, + { + name: "active matching ban should fail", + bannedTopics: []string{MatchmakingBanTopic}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: true, + errMessage: respondError(http.StatusForbidden, ForbiddenAccess, + fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", MatchmakingBanTopic, futureBanTime.Format(time.RFC3339))), + }, + { + name: "multiple bans with one active matching should fail", + bannedTopics: []string{ChatBanTopic}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: "OTHER", EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + {Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: true, + errMessage: respondError(http.StatusForbidden, ForbiddenAccess, + fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", ChatBanTopic, futureBanTime.Format(time.RFC3339))), + }, + { + name: "active ban present, but bannedTopics does not match ban, should success", + bannedTopics: []string{ChatBanTopic}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: false, + }, + { + name: "active ban present, but bannedTopics is empty, should succeed", + bannedTopics: []string{""}, + claims: &iam.JWTClaims{ + Namespace: gameNamespace, + UnionNamespace: publisherNamespace, + Bans: []iam.JWTBan{ + {Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace}, + }, + }, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + filterOpt := WithoutBannedTopics(tc.bannedTopics) + err := filterOpt(&restful.Request{}, nil, tc.claims) + + if tc.wantErr { + assert.Error(t, err) + svcErr, ok := err.(restful.ServiceError) + assert.True(t, ok) + assert.Equal(t, http.StatusForbidden, svcErr.Code) + assert.Equal(t, tc.errMessage.Message, svcErr.Message) + } else { + assert.NoError(t, err) + } + }) + } +} + +// nolint:paralleltest +func TestWithoutBannedTopics_TargetedNamespaceAndExpiry(t *testing.T) { + now := time.Now().UTC() + future := now.Add(24 * time.Hour) + // EndDate equal to now (not in future) + nowEnd := now + + testCases := []struct { + name string + claims *iam.JWTClaims + banned []string + wantErr bool + wantErrMsg string + }{ + { + name: "ban targets studio namespace -> allow chat on game namespace", + claims: &iam.JWTClaims{ + Namespace: "game", + UnionNamespace: "publisher", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "publisher", EndDate: future}, + }, + }, + banned: []string{ChatBanTopic}, + wantErr: false, + }, + { + name: "ban targets different namespace -> allow", + claims: &iam.JWTClaims{ + Namespace: "publisher", + UnionNamespace: "publisher", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: future}, + }, + }, + banned: []string{ChatBanTopic}, + wantErr: false, + }, + { + name: "ban targets same namespace -> forbid", + claims: &iam.JWTClaims{ + Namespace: "game", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: future}, + }, + }, + banned: []string{ChatBanTopic}, + wantErr: true, + }, + { + name: "targeted namespace case-insensitive match -> forbid", + claims: &iam.JWTClaims{ + Namespace: "game", + Bans: []iam.JWTBan{ + {Ban: MatchmakingBanTopic, TargetedNamespace: "GAME", EndDate: future}, + }, + }, + banned: []string{MatchmakingBanTopic}, + wantErr: true, + }, + { + name: "ban end date equal to now -> allow (not before)", + claims: &iam.JWTClaims{ + Namespace: "game", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: nowEnd}, + }, + }, + banned: []string{ChatBanTopic}, + wantErr: false, + }, + { + name: "ban end date equal to now -> allow (not before)", + claims: &iam.JWTClaims{ + Namespace: "game", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: nowEnd}, + }, + }, + banned: []string{ChatBanTopic}, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opt := WithoutBannedTopics(tc.banned) + err := opt(&restful.Request{}, nil, tc.claims) + if tc.wantErr { + assert.Error(t, err) + svcErr, ok := err.(restful.ServiceError) + assert.True(t, ok) + assert.Equal(t, http.StatusForbidden, svcErr.Code) + // message should match respondError output for ForbiddenAccess + expected := respondError(http.StatusForbidden, ForbiddenAccess, + fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", tc.claims.Bans[0].Ban, tc.claims.Bans[0].EndDate.Format(time.RFC3339))) + assert.Equal(t, expected.Message, svcErr.Message) + } else { + assert.NoError(t, err) + } + }) + } +} + +// nolint:paralleltest +func TestWithoutBannedTopics_BannedTopicCaseSensitivity(t *testing.T) { + now := time.Now().UTC().Add(24 * time.Hour) + + claims := &iam.JWTClaims{ + Namespace: "game", + Bans: []iam.JWTBan{ + {Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: now}, + }, + } + + t.Run("bannedTopics lower-case should match uppercase ban", func(t *testing.T) { + opt := WithoutBannedTopics([]string{"chat"}) + err := opt(&restful.Request{}, nil, claims) + assert.Error(t, err) + svcErr, ok := err.(restful.ServiceError) + assert.True(t, ok) + assert.Equal(t, http.StatusForbidden, svcErr.Code) + }) + + t.Run("bannedTopics pascalCase should match uppercase ban", func(t *testing.T) { + opt := WithoutBannedTopics([]string{"Chat"}) + err := opt(&restful.Request{}, nil, claims) + assert.Error(t, err) + svcErr, ok := err.(restful.ServiceError) + assert.True(t, ok) + assert.Equal(t, http.StatusForbidden, svcErr.Code) + }) +}