feat(sdk-license): client-side package name validation

Package name matching is now done entirely in the SDK before any network call:
- License file flow (tryAutoInitialize): compare licenseFile.packageName with
  context.packageName; refuse initialization if mismatch
- appKey-only flow (checkLicense): fetch GET /api/license/app-info and compare
  configured package names locally before register/verify
- LicenseConfig: add fromLicenseFile flag to distinguish the two flows
- LicenseModels: add AppInfoResponse with matchesPackageName helper
- LicenseApiService: add getAppInfo() endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-23 00:30:00 +08:00
父节点 63da94c670
当前提交 eb27e8f112
共有 4 个文件被更改,包括 48 次插入2 次删除

查看文件

@ -5,4 +5,6 @@ data class LicenseConfig(
val baseUrl: String = "https://auth.dev.xuqinmin.com/",
val deviceName: String? = null,
val cacheWindowMs: Long = 10 * 60 * 1000L, // 10 minutes
/** True when initialized from a license file; package name validated locally before any network call. */
val fromLicenseFile: Boolean = false,
)

查看文件

@ -84,8 +84,9 @@ object LicenseSDK {
/**
* Check license status. This will:
* 1. Return cached OK status if within cache window
* 2. Try to verify existing token
* 3. Register new device if no token exists
* 2. Validate package name (locally for license-file flow; via server for appKey flow)
* 3. Try to verify existing token
* 4. Register new device if no token exists
*
* @return LicenseResult.Success if license is valid, LicenseResult.Error otherwise
*/
@ -100,6 +101,26 @@ object LicenseSDK {
return@withContext LicenseResult.Success("Cached")
}
// appKey-only flow: fetch configured package names from server and validate locally
if (!config.fromLicenseFile) {
val actualPackage = appContext.packageName
try {
val infoResp = apiService.getAppInfo(config.appKey)
val appInfo = infoResp.data
if (appInfo != null) {
val anyConfigured = !appInfo.androidPackageName.isNullOrBlank()
|| !appInfo.iosBundleId.isNullOrBlank()
|| !appInfo.harmonyBundleName.isNullOrBlank()
if (anyConfigured && !appInfo.matchesPackageName(actualPackage)) {
persistStatus(STATUS_DENIED)
return@withContext LicenseResult.Error("Package name not authorized: $actualPackage")
}
}
} catch (_: Exception) {
// If app-info fetch fails due to network, fall through and let register/verify handle it
}
}
val deviceId = getOrCreateDeviceId()
val token = store.token
@ -251,6 +272,13 @@ object LicenseSDK {
val ctx = LicenseContextHolder.appContext ?: return false
val licenseFile = LicenseFileReader.read(ctx) ?: return false
val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: return false
// Validate package name locally before any network call
val configuredPackageName = licenseFile.packageName
if (!configuredPackageName.isNullOrBlank() && configuredPackageName != ctx.packageName) {
return false
}
appContext = ctx
store = LicenseStore(ctx)
if (store.appKey != null && store.appKey != appKey) {
@ -261,6 +289,7 @@ object LicenseSDK {
appKey = appKey,
baseUrl = normalizeBaseUrl(licenseFile.baseUrl ?: DEFAULT_BASE_URL),
deviceName = DeviceInfoProvider.getDeviceName(),
fromLicenseFile = true,
)
apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl)
initialized = true

查看文件

@ -1,12 +1,15 @@
package com.xuqm.sdk.license.api
import com.xuqm.sdk.license.model.ApiResponse
import com.xuqm.sdk.license.model.AppInfoResponse
import com.xuqm.sdk.license.model.RegisterRequest
import com.xuqm.sdk.license.model.RegisterResponse
import com.xuqm.sdk.license.model.VerifyRequest
import com.xuqm.sdk.license.model.VerifyResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface LicenseApiService {
@ -15,4 +18,7 @@ interface LicenseApiService {
@POST("api/license/verify")
suspend fun verify(@Body request: VerifyRequest): ApiResponse<VerifyResponse>
@GET("api/license/app-info")
suspend fun getAppInfo(@Query("appKey") appKey: String): ApiResponse<AppInfoResponse>
}

查看文件

@ -43,3 +43,12 @@ data class LicenseUserInfo(
@SerializedName("email") val email: String? = null,
@SerializedName("phone") val phone: String? = null,
)
data class AppInfoResponse(
@SerializedName("androidPackageName") val androidPackageName: String? = null,
@SerializedName("iosBundleId") val iosBundleId: String? = null,
@SerializedName("harmonyBundleName") val harmonyBundleName: String? = null,
) {
fun matchesPackageName(packageName: String): Boolean =
packageName == androidPackageName || packageName == iosBundleId || packageName == harmonyBundleName
}