Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions pkg/auth/iam/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const (
TokenIsNotUserToken = 20022
InvalidRefererHeader = 20023
SubdomainMismatch = 20030
UserBanned = 20040
)

var ErrorCodeMapping = map[int]string{
Expand All @@ -64,4 +65,5 @@ var ErrorCodeMapping = map[int]string{
InvalidRefererHeader: "invalid referer header",
SubdomainMismatch: "subdomain mismatch",
TokenIsExpired: "token is expired",
UserBanned: "user banned",
}
52 changes: 52 additions & 0 deletions pkg/auth/iam/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -38,6 +39,9 @@ const (
accessTokenCookieKey = "access_token"
tokenFromCookie = "cookie"
tokenFromHeader = "header"

MatchmakingBanTopic = "MATCHMAKING"
ChatBanTopic = "CHAT"
)

var DevStackTraceable bool
Expand Down Expand Up @@ -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 {
Expand Down
264 changes: 264 additions & 0 deletions pkg/auth/iam/iam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}