Skip to content

Commit 2acca2a

Browse files
feat: add WithoutBannedTopics (#92)
* feat: add WithoutBannedTopics * chore: add namespace check and iam-go-sdk version
1 parent 71e8073 commit 2acca2a

File tree

5 files changed

+321
-3
lines changed

5 files changed

+321
-3
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.13
44

55
require (
66
github.com/AccelByte/go-jose v2.1.4+incompatible
7-
github.com/AccelByte/iam-go-sdk/v2 v2.1.1
7+
github.com/AccelByte/iam-go-sdk/v2 v2.6.0
88
github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3
99
github.com/AccelByte/public-source-ip v1.0.1
1010
github.com/DataDog/datadog-go v4.3.0+incompatible // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ github.com/AccelByte/go-restful-plugins/v3 v3.2.1 h1:My/jP+wxJM+0adg1vJBja11/+tM
88
github.com/AccelByte/go-restful-plugins/v3 v3.2.1/go.mod h1:XkhxnbfR/0z5lj2xI2SVbSuF5PhCi39xVYmVODwFf7k=
99
github.com/AccelByte/iam-go-sdk v1.1.2 h1:LhmzKaXAsedI4BVLVmYtDTIsDcc+d51vP2CZ9aVowkI=
1010
github.com/AccelByte/iam-go-sdk v1.1.2/go.mod h1:M1Eplqpph/Msxm7XKgZRI+cYBCCChFdgPiVdKYupwq8=
11-
github.com/AccelByte/iam-go-sdk/v2 v2.1.1 h1:I/C8eElxYXdIg5toISv5dkwYc+o11Jp9OZ30MAccrSw=
12-
github.com/AccelByte/iam-go-sdk/v2 v2.1.1/go.mod h1:F9Hq3G4WB7eFUNUh14LwgzCeVjHZEyjCwNrDK6nkTA8=
11+
github.com/AccelByte/iam-go-sdk/v2 v2.6.0 h1:2n2sqsMqWpoHR8/FU2cK384wtVTdNhMWd/oQkKXzX2M=
12+
github.com/AccelByte/iam-go-sdk/v2 v2.6.0/go.mod h1:wjlMl03Aq5wkSbwBHp03c1P73lfPBqYDyQ83iXAZ1uk=
1313
github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3 h1:OWLHzjwh7A17GYxuQ7CJGn7yanFcohkEw0txL9MS2ME=
1414
github.com/AccelByte/ic-go-sdk v0.0.0-20231219062429-d0005bcafcc3/go.mod h1:W2iKULTZwd3vVDcdaQ5iQKhYwzjgNd1lLA7kZmTh6WQ=
1515
github.com/AccelByte/public-source-ip v1.0.0/go.mod h1:L7zIgt3UaXkGH7NoKFCbxPdWZOVwn+d6uW/5yYRXtnQ=

pkg/auth/iam/event.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const (
4545
TokenIsNotUserToken = 20022
4646
InvalidRefererHeader = 20023
4747
SubdomainMismatch = 20030
48+
UserBanned = 20040
4849
)
4950

5051
var ErrorCodeMapping = map[int]string{
@@ -64,4 +65,5 @@ var ErrorCodeMapping = map[int]string{
6465
InvalidRefererHeader: "invalid referer header",
6566
SubdomainMismatch: "subdomain mismatch",
6667
TokenIsExpired: "token is expired",
68+
UserBanned: "user banned",
6769
}

pkg/auth/iam/iam.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"os"
2424
"strconv"
2525
"strings"
26+
"time"
2627

2728
"github.com/AccelByte/go-restful-plugins/v4/pkg/auth/util"
2829
"github.com/AccelByte/go-restful-plugins/v4/pkg/constant"
@@ -38,6 +39,9 @@ const (
3839
accessTokenCookieKey = "access_token"
3940
tokenFromCookie = "cookie"
4041
tokenFromHeader = "header"
42+
43+
MatchmakingBanTopic = "MATCHMAKING"
44+
ChatBanTopic = "CHAT"
4145
)
4246

4347
var DevStackTraceable bool
@@ -410,6 +414,54 @@ func WithValidScope(scope string) FilterOption {
410414
}
411415
}
412416

417+
// WithoutBannedTopics returns a FilterOption that enforces topic-specific bans found in a user's JWT claims.
418+
//
419+
// The returned FilterOption checks the provided claims for any active bans whose name appears in the
420+
// bannedTopics slice. If claims is nil or bannedTopics is empty the filter is a no-op and returns nil.
421+
//
422+
// Parameters:
423+
// - bannedTopics: A slice of strings representing the topics to check for bans
424+
//
425+
// Returns:
426+
// - FilterOption: A function that implements the ban checking logic
427+
//
428+
// Behavior:
429+
// - Constructs a set from bannedTopics for efficient lookup.
430+
// - Iterates over claims.Bans and, for each ban whose name is present in that set, checks:
431+
// - whether ban.TargetedNamespace matches claims.Namespace (case-insensitive), and
432+
// - whether the current UTC time is before ban.EndDate (i.e. the ban is still active).
433+
// - If both conditions are met, the filter returns a forbidden error (http.StatusForbidden) with a message
434+
// indicating the ban type and its expiry time.
435+
// - If no matching active ban is found, the filter returns nil and allows the request to proceed.
436+
//
437+
// The function evaluates ban expiry using time.Now().UTC() and formats the ban end date using RFC3339
438+
// in the returned error message.
439+
//
440+
// Note: This filter only covers bans that target the "game" namespace; bans in publisher/studio namespaces are not evaluated.
441+
func WithoutBannedTopics(bannedTopics []string) FilterOption {
442+
return func(req *restful.Request, iamClient iam.Client, claims *iam.JWTClaims) error {
443+
if claims == nil || len(bannedTopics) == 0 {
444+
return nil
445+
}
446+
bannedTopicMaps := map[string]bool{}
447+
for _, v := range bannedTopics {
448+
bannedTopicMaps[strings.ToUpper(v)] = true
449+
}
450+
451+
for _, b := range claims.Bans {
452+
if _, ok := bannedTopicMaps[strings.ToUpper(b.Ban)]; !ok {
453+
continue
454+
}
455+
if strings.EqualFold(b.TargetedNamespace, claims.Namespace) && time.Now().UTC().Before(b.EndDate) {
456+
return respondError(http.StatusForbidden, ForbiddenAccess,
457+
fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", b.Ban, b.EndDate.Format(time.RFC3339)))
458+
}
459+
}
460+
461+
return nil
462+
}
463+
}
464+
413465
func validateSubdomainAgainstNamespace(host string, namespace string, excludedNamespaces []string) bool {
414466
part := strings.Split(host, ".")
415467
if len(part) < 3 {

pkg/auth/iam/iam_test.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
package iam
1616

1717
import (
18+
"fmt"
1819
"net/http"
1920
"net/url"
2021
"os"
2122
"testing"
23+
"time"
2224

2325
"github.com/AccelByte/go-restful-plugins/v4/pkg/constant"
2426
"github.com/AccelByte/iam-go-sdk/v2"
@@ -1067,3 +1069,265 @@ func TestFilterInitializationOptionsFromEnv_SubdomainValidationExcludedNamespace
10671069
options = FilterInitializationOptionsFromEnv()
10681070
assert.Empty(t, options.SubdomainValidationExcludedNamespaces)
10691071
}
1072+
1073+
func TestWithoutBannedTopics(t *testing.T) {
1074+
timeNow := time.Now().UTC()
1075+
futureBanTime := timeNow.Add(24 * time.Hour)
1076+
pastBanTime := timeNow.Add(-24 * time.Hour)
1077+
gameNamespace, publisherNamespace := "game", "publisher"
1078+
1079+
testCases := []struct {
1080+
name string
1081+
bannedTopics []string
1082+
claims *iam.JWTClaims
1083+
wantErr bool
1084+
errMessage restful.ServiceError
1085+
}{
1086+
{
1087+
name: "nil claims should pass",
1088+
bannedTopics: []string{ChatBanTopic, MatchmakingBanTopic},
1089+
claims: nil,
1090+
wantErr: false,
1091+
},
1092+
{
1093+
name: "empty banned topics should pass",
1094+
bannedTopics: []string{},
1095+
claims: &iam.JWTClaims{
1096+
Namespace: gameNamespace,
1097+
UnionNamespace: publisherNamespace,
1098+
Bans: []iam.JWTBan{
1099+
{Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1100+
},
1101+
},
1102+
wantErr: false,
1103+
},
1104+
{
1105+
name: "non-matching ban topic should pass",
1106+
bannedTopics: []string{MatchmakingBanTopic},
1107+
claims: &iam.JWTClaims{
1108+
Namespace: gameNamespace,
1109+
UnionNamespace: publisherNamespace,
1110+
Bans: []iam.JWTBan{
1111+
{Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1112+
},
1113+
},
1114+
wantErr: false,
1115+
},
1116+
{
1117+
name: "expired ban should pass",
1118+
bannedTopics: []string{ChatBanTopic},
1119+
claims: &iam.JWTClaims{
1120+
Namespace: gameNamespace,
1121+
UnionNamespace: publisherNamespace,
1122+
Bans: []iam.JWTBan{
1123+
{Ban: ChatBanTopic, EndDate: pastBanTime, TargetedNamespace: gameNamespace},
1124+
},
1125+
},
1126+
wantErr: false,
1127+
},
1128+
{
1129+
name: "active matching ban should fail",
1130+
bannedTopics: []string{MatchmakingBanTopic},
1131+
claims: &iam.JWTClaims{
1132+
Namespace: gameNamespace,
1133+
UnionNamespace: publisherNamespace,
1134+
Bans: []iam.JWTBan{
1135+
{Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1136+
},
1137+
},
1138+
wantErr: true,
1139+
errMessage: respondError(http.StatusForbidden, ForbiddenAccess,
1140+
fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", MatchmakingBanTopic, futureBanTime.Format(time.RFC3339))),
1141+
},
1142+
{
1143+
name: "multiple bans with one active matching should fail",
1144+
bannedTopics: []string{ChatBanTopic},
1145+
claims: &iam.JWTClaims{
1146+
Namespace: gameNamespace,
1147+
UnionNamespace: publisherNamespace,
1148+
Bans: []iam.JWTBan{
1149+
{Ban: "OTHER", EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1150+
{Ban: ChatBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1151+
},
1152+
},
1153+
wantErr: true,
1154+
errMessage: respondError(http.StatusForbidden, ForbiddenAccess,
1155+
fmt.Sprintf("access forbidden: user is banned due to %s ban until %s", ChatBanTopic, futureBanTime.Format(time.RFC3339))),
1156+
},
1157+
{
1158+
name: "active ban present, but bannedTopics does not match ban, should success",
1159+
bannedTopics: []string{ChatBanTopic},
1160+
claims: &iam.JWTClaims{
1161+
Namespace: gameNamespace,
1162+
UnionNamespace: publisherNamespace,
1163+
Bans: []iam.JWTBan{
1164+
{Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1165+
},
1166+
},
1167+
wantErr: false,
1168+
},
1169+
{
1170+
name: "active ban present, but bannedTopics is empty, should succeed",
1171+
bannedTopics: []string{""},
1172+
claims: &iam.JWTClaims{
1173+
Namespace: gameNamespace,
1174+
UnionNamespace: publisherNamespace,
1175+
Bans: []iam.JWTBan{
1176+
{Ban: MatchmakingBanTopic, EndDate: futureBanTime, TargetedNamespace: gameNamespace},
1177+
},
1178+
},
1179+
wantErr: false,
1180+
},
1181+
}
1182+
1183+
for _, tc := range testCases {
1184+
t.Run(tc.name, func(t *testing.T) {
1185+
filterOpt := WithoutBannedTopics(tc.bannedTopics)
1186+
err := filterOpt(&restful.Request{}, nil, tc.claims)
1187+
1188+
if tc.wantErr {
1189+
assert.Error(t, err)
1190+
svcErr, ok := err.(restful.ServiceError)
1191+
assert.True(t, ok)
1192+
assert.Equal(t, http.StatusForbidden, svcErr.Code)
1193+
assert.Equal(t, tc.errMessage.Message, svcErr.Message)
1194+
} else {
1195+
assert.NoError(t, err)
1196+
}
1197+
})
1198+
}
1199+
}
1200+
1201+
// nolint:paralleltest
1202+
func TestWithoutBannedTopics_TargetedNamespaceAndExpiry(t *testing.T) {
1203+
now := time.Now().UTC()
1204+
future := now.Add(24 * time.Hour)
1205+
// EndDate equal to now (not in future)
1206+
nowEnd := now
1207+
1208+
testCases := []struct {
1209+
name string
1210+
claims *iam.JWTClaims
1211+
banned []string
1212+
wantErr bool
1213+
wantErrMsg string
1214+
}{
1215+
{
1216+
name: "ban targets studio namespace -> allow chat on game namespace",
1217+
claims: &iam.JWTClaims{
1218+
Namespace: "game",
1219+
UnionNamespace: "publisher",
1220+
Bans: []iam.JWTBan{
1221+
{Ban: ChatBanTopic, TargetedNamespace: "publisher", EndDate: future},
1222+
},
1223+
},
1224+
banned: []string{ChatBanTopic},
1225+
wantErr: false,
1226+
},
1227+
{
1228+
name: "ban targets different namespace -> allow",
1229+
claims: &iam.JWTClaims{
1230+
Namespace: "publisher",
1231+
UnionNamespace: "publisher",
1232+
Bans: []iam.JWTBan{
1233+
{Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: future},
1234+
},
1235+
},
1236+
banned: []string{ChatBanTopic},
1237+
wantErr: false,
1238+
},
1239+
{
1240+
name: "ban targets same namespace -> forbid",
1241+
claims: &iam.JWTClaims{
1242+
Namespace: "game",
1243+
Bans: []iam.JWTBan{
1244+
{Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: future},
1245+
},
1246+
},
1247+
banned: []string{ChatBanTopic},
1248+
wantErr: true,
1249+
},
1250+
{
1251+
name: "targeted namespace case-insensitive match -> forbid",
1252+
claims: &iam.JWTClaims{
1253+
Namespace: "game",
1254+
Bans: []iam.JWTBan{
1255+
{Ban: MatchmakingBanTopic, TargetedNamespace: "GAME", EndDate: future},
1256+
},
1257+
},
1258+
banned: []string{MatchmakingBanTopic},
1259+
wantErr: true,
1260+
},
1261+
{
1262+
name: "ban end date equal to now -> allow (not before)",
1263+
claims: &iam.JWTClaims{
1264+
Namespace: "game",
1265+
Bans: []iam.JWTBan{
1266+
{Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: nowEnd},
1267+
},
1268+
},
1269+
banned: []string{ChatBanTopic},
1270+
wantErr: false,
1271+
},
1272+
{
1273+
name: "ban end date equal to now -> allow (not before)",
1274+
claims: &iam.JWTClaims{
1275+
Namespace: "game",
1276+
Bans: []iam.JWTBan{
1277+
{Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: nowEnd},
1278+
},
1279+
},
1280+
banned: []string{ChatBanTopic},
1281+
wantErr: false,
1282+
},
1283+
}
1284+
1285+
for _, tc := range testCases {
1286+
t.Run(tc.name, func(t *testing.T) {
1287+
opt := WithoutBannedTopics(tc.banned)
1288+
err := opt(&restful.Request{}, nil, tc.claims)
1289+
if tc.wantErr {
1290+
assert.Error(t, err)
1291+
svcErr, ok := err.(restful.ServiceError)
1292+
assert.True(t, ok)
1293+
assert.Equal(t, http.StatusForbidden, svcErr.Code)
1294+
// message should match respondError output for ForbiddenAccess
1295+
expected := respondError(http.StatusForbidden, ForbiddenAccess,
1296+
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)))
1297+
assert.Equal(t, expected.Message, svcErr.Message)
1298+
} else {
1299+
assert.NoError(t, err)
1300+
}
1301+
})
1302+
}
1303+
}
1304+
1305+
// nolint:paralleltest
1306+
func TestWithoutBannedTopics_BannedTopicCaseSensitivity(t *testing.T) {
1307+
now := time.Now().UTC().Add(24 * time.Hour)
1308+
1309+
claims := &iam.JWTClaims{
1310+
Namespace: "game",
1311+
Bans: []iam.JWTBan{
1312+
{Ban: ChatBanTopic, TargetedNamespace: "game", EndDate: now},
1313+
},
1314+
}
1315+
1316+
t.Run("bannedTopics lower-case should match uppercase ban", func(t *testing.T) {
1317+
opt := WithoutBannedTopics([]string{"chat"})
1318+
err := opt(&restful.Request{}, nil, claims)
1319+
assert.Error(t, err)
1320+
svcErr, ok := err.(restful.ServiceError)
1321+
assert.True(t, ok)
1322+
assert.Equal(t, http.StatusForbidden, svcErr.Code)
1323+
})
1324+
1325+
t.Run("bannedTopics pascalCase should match uppercase ban", func(t *testing.T) {
1326+
opt := WithoutBannedTopics([]string{"Chat"})
1327+
err := opt(&restful.Request{}, nil, claims)
1328+
assert.Error(t, err)
1329+
svcErr, ok := err.(restful.ServiceError)
1330+
assert.True(t, ok)
1331+
assert.Equal(t, http.StatusForbidden, svcErr.Code)
1332+
})
1333+
}

0 commit comments

Comments
 (0)