Implement user ownership validation in Extend Service Extension
Overview
By default, Extend Service Extension app template only includes a basic user verification mechanism. However, if user validation is required for a specific set of data, you can easily implement it by customizing your code.
Prerequisites
You have cloned the Extend Service Extension app template.
- C#
- Go
- Java
git clone https://github.com/AccelByte/extend-service-extension-csharp
git clone https://github.com/AccelByte/extend-service-extension-go
git clone https://github.com/AccelByte/extend-service-extension-java
Implement user ownership validation
As an example, Extend Service Extension app template can be extended to incorporate ownership information for each guild data. During guild data creation, it will assign the currently authenticated user ID as the owner ID and store this information along with guild data. Subsequently, when a user attempts to access the guild data, it will verify the user ID and denies access if it does not match with stored owner ID.
This mechanism relies on the user ID stored in the sub
field of the user access token payload. Since OAuth client access tokens do not contain a sub
field, ownership information will not be stored when using client credentials.
- C#
- Go
- Java
- Add
OwnerId
property inModel/GuildProgressData.cs
underNamespace
property.
...
[JsonPropertyName("owner_id")]
public string OwnerId { get; set; } = "";
...
- Add user id from access token payload to gRpc context's
UserState
inClasses/AuthorizationInterceptor.cs
.
...
qPermission = (new Regex(@"\{(namespace|NAMESPACE)\}")).Replace(qPermission, (m) => _ABProvider.Sdk.Namespace);
int actNum = (int)qAction;
bool b = _ABProvider.Sdk.ValidateToken(authParts[1], qPermission, actNum);
if (!b)
throw new RpcException(new Status(StatusCode.PermissionDenied, $"Permission {qPermission} [{qAction}] is required."));
// Add these code below after ValidateToken
var tokenPayload = _ABProvider.Sdk.ParseAccessToken(authParts[1], false);
if (tokenPayload == null)
throw new RpcException(new Status(StatusCode.Unauthenticated, $"Invalid access token payload."));
string userId = "";
if (tokenPayload.Sub != null)
userId = tokenPayload.Sub;
context.UserState.Add("loginUserId", userId);
...
- Modify
CreateOrUpdateGuildProgress
implementation inService/MyService.cs
file.
public override Task<CreateOrUpdateGuildProgressResponse> CreateOrUpdateGuildProgress(CreateOrUpdateGuildProgressRequest request, ServerCallContext context)
{
string actualGuildId = request.GuildProgress.GuildId.Trim();
if (actualGuildId == "")
actualGuildId = Guid.NewGuid().ToString().Replace("-", "");
string gpKey = $"guildProgress_{actualGuildId}";
string loginUserId = "";
if (context.UserState.TryGetValue("loginUserId", out object? tempValue))
{
if (tempValue != null)
loginUserId = tempValue.ToString()!;
}
else
throw new Exception("No login user id data in context.");
try
{
//check whether guild data specified by guild is exists or not
var guildData = _ABProvider.Sdk.Cloudsave.AdminGameRecord.AdminGetGameRecordHandlerV1Op
.Execute<GuildProgressData>(gpKey, request.Namespace);
if (guildData == null)
throw new Exception("NULL response from cloudsave service.");
//if it exists, check owner user id.
if (guildData.Value != null)
{
if (guildData.Value.OwnerId != loginUserId)
throw new Exception("You don't have access to this data.");
}
//Guild data exists and belong to login user. Proceed to update
}
catch (HttpResponseException hrx)
{
if (hrx.StatusCode != HttpStatusCode.NotFound)
throw;
//Guild data does not exists. Proceed to create.
}
var gpValue = GuildProgressData.FromGuildProgressGrpcData(request.GuildProgress);
gpValue.GuildId = actualGuildId;
gpValue.OwnerId = loginUserId;
var response = _ABProvider.Sdk.Cloudsave.AdminGameRecord.AdminPostGameRecordHandlerV1Op
.Execute<GuildProgressData>(gpValue, gpKey, request.Namespace);
if (response == null)
throw new Exception("NULL response from cloudsave service.");
GuildProgressData savedData = response.Value!;
return Task.FromResult(new CreateOrUpdateGuildProgressResponse()
{
GuildProgress = savedData.ToGuildProgressGrpcData()
});
}
- Modify
GetGuildProgress
implementation inService/MyService.cs
file.
public override Task<GetGuildProgressResponse> GetGuildProgress(GetGuildProgressRequest request, ServerCallContext context)
{
string gpKey = $"guildProgress_{request.GuildId.Trim()}";
string loginUserId = "";
if (context.UserState.TryGetValue("loginUserId", out object? tempValue))
{
if (tempValue != null)
loginUserId = tempValue.ToString()!;
}
else
throw new Exception("No login user id data in context.");
var response = _ABProvider.Sdk.Cloudsave.AdminGameRecord.AdminGetGameRecordHandlerV1Op
.Execute<GuildProgressData>(gpKey, request.Namespace);
if (response == null)
throw new Exception("NULL response from cloudsave service.");
//if it exists, check owner user id.
if (response.Value != null)
{
if (response.Value.OwnerId != loginUserId)
throw new Exception("You don't have access to this data.");
}
GuildProgressData savedData = response.Value!;
return Task.FromResult(new GetGuildProgressResponse()
{
GuildProgress = savedData.ToGuildProgressGrpcData()
});
}
- Add a proto field
user_id
in theGuildProgress
message inside yourpkg/proto/service.proto
file.
message GuildProgress {
string guild_id = 1;
string namespace = 2;
map<string, int32> objectives = 3;
+ string owner_id = 4;
}
- Run
make proto
to regenerate all protobuf files.
make proto
You should be able to see some changes to your pkg/pb/service.pb.go
file.
// pkg/pb/service.pb.go
...
type GuildProgress struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
GuildId string `protobuf:"bytes,1,opt,name=guild_id,json=guildId,proto3" json:"guild_id,omitempty"`
Namespace string `protobuf:"bytes,2,opt,name=namespace,proto3" json:"namespace,omitempty"`
Objectives map[string]int32 `protobuf:"bytes,3,rep,name=objectives,proto3" json:"objectives,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
+ OwnerId string `protobuf:"bytes,4,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"`
}
...
+ func (x *GuildProgress) GetOwnerId() string {
+ if x != nil {
+ return x.OwnerId
+ }
+ return ""
+ }
...
- You'll also need to add a new field in the
MyServiceServerImpl
struct in thepkg/service/myService.go
file.
// pkg/service/myService.go
type MyServiceServerImpl struct {
pb.UnimplementedServiceServer
tokenRepo repository.TokenRepository
configRepo repository.ConfigRepository
refreshRepo repository.RefreshTokenRepository
storage storage.Storage
+ oauthService iam.OAuth20Service
}
func NewMyServiceServer(
tokenRepo repository.TokenRepository,
configRepo repository.ConfigRepository,
refreshRepo repository.RefreshTokenRepository,
storage storage.Storage,
+ oauthService iam.OAuth20Service,
) *MyServiceServerImpl {
return &MyServiceServerImpl{
tokenRepo: tokenRepo,
configRepo: configRepo,
refreshRepo: refreshRepo,
storage: storage,
+ oauthService: oauthService,
}
}
This will give you access to a function that will help you extract a user's ID from a bearer token.
You'll also need to modify pkg/main.go
to pass on that value.
// pkg/main.go
oauthService := iam.OAuth20Service{...}
...
// Register Guild Service
- myServiceServer := service.NewMyServiceServer(tokenRepo, configRepo, refreshRepo, cloudSaveStorage)
+ myServiceServer := service.NewMyServiceServer(tokenRepo, configRepo, refreshRepo, cloudSaveStorage, oauthService)
pb.RegisterServiceServer(s, myServiceServer)
- Add in 2 utility functions for the
MyServiceServerImpl
struct in thepkg/service/myService.go
file.
// pkg/service/myService.go
func (g MyServiceServerImpl) getTokenFromContext(ctx context.Context) (string, error) {
if meta, found := metadata.FromIncomingContext(ctx); found {
if authorizationValue, ok := meta["authorization"]; ok && len(authorizationValue) == 1 {
if authorization := authorizationValue[0]; strings.HasPrefix(authorization, "Bearer ") {
if token := strings.TrimPrefix(authorization, "Bearer "); token != "" {
return token, nil
}
}
}
}
return "", fmt.Errorf("unable to get token from context")
}
func (g MyServiceServerImpl) getUserIdFromToken(token string) (string, error) {
claims, err := g.oauthService.ParseAccessTokenToClaims(token, false)
if err != nil {
return "", err
}
if claims.Subject != "" {
return claims.Subject, nil
}
return "", fmt.Errorf("unable to get user ID from token")
}
- Modify the
CreateOrUpdateGuildProgress
function in thepkg/service/myService.go
file.
// pkg/service/myService.go
func (g MyServiceServerImpl) CreateOrUpdateGuildProgress(
ctx context.Context, req *pb.CreateOrUpdateGuildProgressRequest,
) (*pb.CreateOrUpdateGuildProgressResponse, error) {
+ if token, err := g.getTokenFromContext(ctx); err == nil {
+ if requesterUserId, err := g.getUserIdFromToken(token); err == nil {
+ req.GuildProgress.OwnerId = requesterUserId
+ }
+ }
+
// Create or update guild progress in CloudSave
// This assumes we're storing guild progress as a JSON object
namespace := req.Namespace
guildProgressKey := fmt.Sprintf("guildProgress_%s", req.GuildProgress.GuildId)
guildProgressValue := req.GuildProgress
guildProgress, err := g.storage.SaveGuildProgress(namespace, guildProgressKey, guildProgressValue)
if err != nil {
return nil, status.Errorf(codes.Internal, "Error updating guild progress: %v", err)
}
// Return the updated guild progress
return &pb.CreateOrUpdateGuildProgressResponse{GuildProgress: guildProgress}, nil
}
This will fill-in the newly added field owner_id
with the requester's user ID.
- Modify the
GetGuildProgress
function in thepkg/service/myService.go
file.
// pkg/service/myService.go
func (g MyServiceServerImpl) GetGuildProgress(
ctx context.Context, req *pb.GetGuildProgressRequest,
) (*pb.GetGuildProgressResponse, error) {
// Get guild progress in CloudSave
namespace := req.Namespace
guildProgressKey := fmt.Sprintf("guildProgress_%s", req.GuildId)
guildProgress, err := g.storage.GetGuildProgress(namespace, guildProgressKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "Error getting guild progress: %v", err)
}
+ ownerUserId := guildProgress.OwnerId
+ if ownerUserId != "" {
+ if token, err := g.getTokenFromContext(ctx); err == nil {
+ if requesterUserId, err := g.getUserIdFromToken(token); err == nil {
+ if ownerUserId != requesterUserId {
+ return nil, status.Errorf(
+ codes.PermissionDenied,
+ "guild progress is owned by %s and it cannot be viewed by requester (%s)",
+ ownerUserId,
+ requesterUserId,
+ )
+ }
+ }
+ }
+ }
+
return &pb.GetGuildProgressResponse{
GuildProgress: guildProgress,
}, nil
}
This will reject any requests for guild progresses not coming from the original creator of the guild progress.
- Modify
src/main/java/net/accelbyte/extend/serviceextension/grpc/AuthServerInterceptor.java
to add grpc context.
...
// add this import
import net.accelbyte.sdk.core.AccessTokenPayload;
...
@Slf4j
@GRpcGlobalInterceptor
@Order(20)
public class AuthServerInterceptor implements ServerInterceptor {
...
private String namespace;
// add context for logged in user id data
public static final Context.Key<String> LOGIN_USER_ID = Context.key("login_user_id");
...
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
...
if (!sdk.validateToken(authContext, permission)) {
throw new Exception("Auth token validation failed");
}
// get user if from tooen payload and store it in context
final AccessTokenPayload payload = sdk.parseAccessToken(authToken, false);
String loginUserId = "";
if (payload.getSub() != null)
loginUserId = payload.getSub().trim();
Context context = Context.current()
.withValue(LOGIN_USER_ID, loginUserId);
// return call with context
return Contexts.interceptCall(context, call, headers, next);
...
}
}
- Create
GuildData
model class insrc/main/java/net/accelbyte/extend/serviceextension/storage/GuildData.java
.
package net.accelbyte.extend.serviceextension.storage;
import lombok.Getter;
import lombok.Setter;
import lombok.Builder;
import net.accelbyte.extend.serviceextension.GuildProgress;
@Getter
@Setter
@Builder
public class GuildData {
private GuildProgress guildProgress;
private String ownerId;
}
- Modify
Storage
interface insrc/main/java/net/accelbyte/extend/serviceextension/storage/Storage.java
.
package net.accelbyte.extend.serviceextension.storage;
import net.accelbyte.sdk.core.HttpResponseException;
import net.accelbyte.extend.serviceextension.GuildProgress;
public interface Storage {
GuildProgress getGuildProgress(String namespace, String key) throws Exception;
GuildProgress saveGuildProgress(String namespace, String key, GuildProgress value, String ownerId) throws Exception;
GuildData getGuildData(String namespace, String key) throws Exception, HttpResponseException;
}
- Add new import in
src/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.java
...
import net.accelbyte.sdk.core.HttpResponseException;
...
- Modify
CloudsaveStorage.CloudSaveModel
class insrc/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.java
...
// add owner id property
@JsonProperty
private String owner_id;
...
// add method to get owner id
public String getOwnerId() {
return owner_id;
}
...
- Modify
CloudsaveStorage
class insrc/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.java
//update saveGuildProgress method to include owner id
@Override
public GuildProgress saveGuildProgress(String namespace, String key, GuildProgress value, String ownerId) throws Exception {
CloudSaveModel cloudModel = new CloudSaveModel(value, ownerId);
AdminPostGameRecordHandlerV1 input = AdminPostGameRecordHandlerV1.builder()
.namespace(namespace)
.key(key)
.body(cloudModel)
.build();
ModelsGameRecordAdminResponse response = csStorage.adminPostGameRecordHandlerV1(input);
CloudSaveModel model = mapper.convertValue(response.getValue(), CloudSaveModel.class);
return model.toGuildProgress();
}
//implement getGuildData method
@Override
public GuildData getGuildData(String namespace, String key) throws Exception, HttpResponseException {
AdminGetGameRecordHandlerV1 input = AdminGetGameRecordHandlerV1.builder()
.namespace(namespace)
.key(key)
.build();
ModelsGameRecordAdminResponse response = csStorage.adminGetGameRecordHandlerV1(input);
CloudSaveModel model = mapper.convertValue(response.getValue(), CloudSaveModel.class);
return GuildData.builder()
.guildProgress(model.toGuildProgress())
.ownerId(model.getOwnerId())
.build();
}
- Add new imports in
src/main/java/net/accelbyte/extend/serviceextension/service/MyService.java
...
import net.accelbyte.sdk.core.HttpResponseException;
import net.accelbyte.extend.serviceextension.grpc.AuthServerInterceptor;
import net.accelbyte.extend.serviceextension.storage.GuildData;
...
- Modify
MyService
class insrc/main/java/net/accelbyte/extend/serviceextension/service/MyService.java
...
//update createOrUpdateGuildProgress method
@Override
public void createOrUpdateGuildProgress(
CreateOrUpdateGuildProgressRequest request, StreamObserver<CreateOrUpdateGuildProgressResponse> responseObserver
) {
String guildProgressKey = String.format("guildProgress_%s", request.getGuildProgress().getGuildId());
final String loginUserId = (String)AuthServerInterceptor.LOGIN_USER_ID.get();
try {
//check whether guild data specified by guild is exists or not
GuildData guildData = storage.getGuildData(namespace, guildProgressKey);
final String ownerId = guildData.getOwnerId();
if (!ownerId.equals(loginUserId))
throw new Exception("You don't have access to this data.");
//Guild data exists and belong to login user. Proceed to update
} catch (HttpResponseException x) {
//If guild data does not exists. Proceed to create.
if (x.getHttpCode() != 404) {
throw new IllegalArgumentException(x);
}
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
GuildProgress guildProgressValue = request.getGuildProgress();
CreateOrUpdateGuildProgressResponse response;
try {
GuildProgress result = storage.saveGuildProgress(namespace, guildProgressKey, guildProgressValue, loginUserId);
response = CreateOrUpdateGuildProgressResponse.newBuilder()
.setGuildProgress(result)
.build();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
responseObserver.onNext(response);
responseObserver.onCompleted();
}
//update getGuildProgress method
@Override
public void getGuildProgress(
GetGuildProgressRequest request, StreamObserver<GetGuildProgressResponse> responseObserver
) {
String guildProgressKey = String.format("guildProgress_%s", request.getGuildId());
final String loginUserId = (String)AuthServerInterceptor.LOGIN_USER_ID.get();
GetGuildProgressResponse response;
try {
GuildData guildData = storage.getGuildData(namespace, guildProgressKey);
final String ownerId = guildData.getOwnerId();
if (!ownerId.equals(loginUserId))
throw new Exception("You don't have access to this data.");
response = GetGuildProgressResponse.newBuilder()
.setGuildProgress(guildData.getGuildProgress())
.build();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
responseObserver.onNext(response);
responseObserver.onCompleted();
}
...
You could find more information about gRPC request handling here.