Extend Service Extensionでユーザー所有権検証を実装する
注釈:本資料はAI技術を用いて翻訳されています。
概要
デフォルトでは、Extend Service Extensionアプリテンプレートには基本的なユーザー検証メカニズムのみが含まれています。 ただし、特定のデータセットに対してユーザー検証が必要な場合は、コードをカスタマイズすることで簡単に実装できます。
前提条件
Extend Service Extensionアプリテンプレートをクローンしていること。
- C#
- Go
- Java
- Python
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
git clone https://github.com/AccelByte/extend-service-extension-python
ユーザー所有権検証の実装
例として、Extend Service Extensionアプリテンプレートを拡張して、各ギルドデータに所有権情報を組み込むことができます。 ギルドデータの作成時に、現在認証されているユーザーIDを所有者IDとして割り当て、この情報をギルドデータと共に保存します。 その後、ユーザーがギルドデータにアクセスしようとすると、ユーザーIDを検証し、保存されている所有者IDと一致しない場合はアクセスを拒否します。
このメカニズムは、ユーザーアクセストークンペイロードのsubフィールドに保存されているユーザーIDに依存しています。OAuthクライアントアクセストークンにはsubフィールドが含まれていないため、クライアント認証情報を使用する場合、所有権情報は保存されません。
- C#
- Go
- Java
- Python
Model/GuildProgressData.csのNamespaceプロパティの下にOwnerIdプロパティを追加します。
...
[JsonPropertyName("owner_id")]
public string OwnerId { get; set; } = "";
...
Classes/AuthorizationInterceptor.csでアクセストークンペイロードからユーザーIDをgRPCコンテキストのUserStateに追加します。
...
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."));
// 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);
...
Service/MyService.csファイルのCreateOrUpdateGuildProgress実装を変更します。
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
{
//ギルドIDで指定されたギルドデータが存在するかどうかを確認
var guildData = _ABProvider.Sdk.Cloudsave.AdminGameRecord.AdminGetGameRecordHandlerV1Op
.Execute<GuildProgressData>(gpKey, request.Namespace);
if (guildData == null)
throw new Exception("NULL response from cloudsave service.");
//存在する場合、所有者ユーザーIDを確認
if (guildData.Value != null)
{
if (guildData.Value.OwnerId != loginUserId)
throw new Exception("You don't have access to this data.");
}
//ギルドデータが存在し、ログインユーザーに属している。更新を続行
}
catch (HttpResponseException hrx)
{
if (hrx.StatusCode != HttpStatusCode.NotFound)
throw;
//ギルドデータが存在しない。作成を続行
}
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()
});
}
Service/MyService.csファイルのGetGuildProgress実装を変更します。
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.");
//存在する場合、所有者ユーザー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()
});
}
-
pkg/proto/service.protoファイル内のGuildProgressメッセージにprotoフィールドuser_idを追加します。 -
pkg/proto/service.protoファイル内のGuildProgressメッセージにprotoフィールドuser_idを追加します。
message GuildProgress {
string guild_id = 1;
string namespace = 2;
map<string, int32> objectives = 3;
+ string owner_id = 4;
}
make protoを実行してすべてのprotobufファイルを再生成します。
make proto
pkg/pb/service.pb.goファイルにいくつかの変更が表示されるはずです。
// 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 ""
+ }
...
pkg/service/myService.goファイルのMyServiceServerImpl構造体に新しいフィールドを追加する必要もあります。
// 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,
}
}
これにより、ベアラートークンからユーザーIDを抽出するのに役立つ関数にアクセスできるようになります。
また、その値を渡すためにpkg/main.goを変更する必要があります。
// 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)
pkg/service/myService.goファイルのMyServiceServerImpl構造体に2つのユーティリティ関数を追加します。
// 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")
}
pkg/service/myService.goファイルのCreateOrUpdateGuildProgress関数を変更します。
// 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
}
これにより、新しく追加されたフィールドowner_idにリクエスト元のユーザーIDが入力されます。
pkg/service/myService.goファイルのGetGuildProgress関数を変更します。
// 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
}
これにより、ギルドプログレスの元の作成者以外からのリクエストは拒否されます。
-
src/main/java/net/accelbyte/extend/serviceextension/grpc/AuthServerInterceptor.javaを変更してgRPCコンテキストを追加します。 -
src/main/java/net/accelbyte/extend/serviceextension/grpc/AuthServerInterceptor.javaを変更してgRPCコンテキストを追加します。
...
// このインポートを追加
import net.accelbyte.sdk.core.AccessTokenPayload;
...
@Slf4j
@GRpcGlobalInterceptor
@Order(20)
public class AuthServerInterceptor implements ServerInterceptor {
...
private String namespace;
// ログインユーザーIDデータのコンテキストを追加
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");
}
// トークンペイロードからユーザーIDを取得してコンテキストに保存
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 Contexts.interceptCall(context, call, headers, next);
...
}
}
src/main/java/net/accelbyte/extend/serviceextension/storage/GuildData.javaにGuildDataモデルクラスを作成します。
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;
}
src/main/java/net/accelbyte/extend/serviceextension/storage/Storage.javaのStorageインターフェースを変更します。
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;
}
src/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.javaに新しいインポートを追加します。
...
import net.accelbyte.sdk.core.HttpResponseException;
...
src/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.javaのCloudsaveStorage.CloudSaveModelクラスを変更します。
...
// 所有者IDプロパティを追加
@JsonProperty
private String owner_id;
...
// 所有者IDを取得するメソッドを追加
public String getOwnerId() {
return owner_id;
}
...
src/main/java/net/accelbyte/extend/serviceextension/storage/CloudsaveStorage.javaのCloudsaveStorageクラスを変更します。
//所有者IDを含めるようにsaveGuildProgressメソッドを更新
@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();
}
//getGuildDataメソッドを実装
@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();
}
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;
...
src/main/java/net/accelbyte/extend/serviceextension/service/MyService.javaのMyServiceクラスを変更します。
...
//createOrUpdateGuildProgressメソッドを更新
@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 {
//ギルドIDで指定されたギルドデータが存在するかどうかを確認
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.");
//ギルドデータが存在し、ログインユーザーに属している。更新を続行
} catch (HttpResponseException x) {
//ギルドデータが存在しない場合。作成を続行
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();
}
//getGuildProgressメソッドを更新
@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();
}
...
-
proto/app/service.protoファイル内のGuildProgressメッセージにprotoフィールドuser_idを追加します。 -
proto/app/service.protoファイル内のGuildProgressメッセージにprotoフィールドuser_idを追加します。
message GuildProgress {
string guild_id = 1;
string namespace = 2;
map<string, int32> objectives = 3;
+ string owner_id = 4;
}
make protoを実行してすべてのprotobufファイルを再生成します。
make proto
src/app/services/my_service.pyファイルのAsyncServiceクラスに2つのユーティリティ関数を追加します。
# src/app/services/my_service.py
def get_token_from_context(context: Any) -> Optional[str]:
metadata = dict(context.invocation_metadata())
authorization = metadata.get("authorization", None)
if (
authorization
and isinstance(authorization, str)
and authorization.startswith("Bearer ")
):
token = authorization.removeprefix("Bearer ")
return token
return None
def get_user_id_from_token(self, token: str) -> Optional[str]:
# noinspection PyProtectedMember
claims, error = self.token_validator._decode(token=token)
if error:
return None
return claims.get("user_id")
src/app/services/my_service.pyファイルのCreateOrUpdateGuildProgress関数を変更します。
# src/app/services/my_service.py
async def CreateOrUpdateGuildProgress(
self, request: CreateOrUpdateGuildProgressRequest, context: Any
) -> CreateOrUpdateGuildProgressResponse:
if not request.namespace:
await context.abort(code=StatusCode.INVALID_ARGUMENT, details="namespace invalid")
guild_id = request.guild_progress.guild_id.strip()
if not guild_id:
guild_id = self.generate_new_guild_id()
gp_key = self.format_guild_progress_key(guild_id)
gp_value = cs_models.ModelsGameRecordRequest()
gp_value["guild_id"] = guild_id
gp_value["namespace"] = request.guild_progress.namespace
gp_value["objectives"] = dict(request.guild_progress.objectives)
+ token = get_token_from_context(context=context)
+ if token:
+ requester_user_id = get_user_id_from_token(token=token)
+ if requester_user_id:
+ gp_value["owner_id"] = requester_user_id
(
response,
error,
) = await cs_service.admin_post_game_record_handler_v1_async(
body=gp_value,
key=gp_key,
namespace=request.namespace,
sdk=self.sdk,
)
if error:
await context.abort(
code=StatusCode.NOT_FOUND,
details=f"unable to create or update guild progress, error: {error}",
)
result = CreateOrUpdateGuildProgressResponse()
result.guild_progress.guild_id = response.value["guild_id"]
result.guild_progress.namespace = response.value["namespace"]
for k, v in response.value["objectives"].items():
result.guild_progress.objectives[k] = v
return result
これにより、新しく追加されたフィールドowner_idにリクエスト元のユーザーIDが入力されます。
src/app/services/my_service.pyファイルのGetGuildProgress関数を変更します。
# src/app/services/my_service.py
async def GetGuildProgress(
self, request: GetGuildProgressRequest, context: Any
) -> GetGuildProgressResponse:
if not request.namespace:
await context.abort(code=StatusCode.INVALID_ARGUMENT, details="namespace invalid")
gp_key = self.format_guild_progress_key(request.guild_id.strip())
(
response,
error,
) = await cs_service.admin_get_game_record_handler_v1_async(
key=gp_key,
namespace=request.namespace,
sdk=self.sdk,
)
if error:
await context.abort(
code=StatusCode.NOT_FOUND,
details=f"guild progress not found, error: {error}",
)
+ owner_user_id = response.value.get("owner_id", None)
+ if owner_user_id:
+ token = get_token_from_context(context=context)
+ if token:
+ requester_user_id = get_user_id_from_token(token=token)
+ if owner_user_id != requester_user_id:
+ await context.abort(
+ code=StatusCode.PERMISSION_DENIED,
+ details=f"guild progress is owned by {owner_user_id} "
+ f"and it cannot be viewed by requester ({requester_user_id})"
+ )
result = GetGuildProgressResponse()
result.guild_progress.guild_id = response.value["guild_id"]
result.guild_progress.namespace = response.value["namespace"]
for k, v in response.value["objectives"].items():
result.guild_progress.objectives[k] = v
return result
これにより、ギルドプログレスの元の作成者以外からのリクエストは拒否されます。
gRPCリクエスト処理の詳細については、こちらをご覧ください。