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 baseUrl: String = "https://auth.dev.xuqinmin.com/",
val deviceName: String? = null, val deviceName: String? = null,
val cacheWindowMs: Long = 10 * 60 * 1000L, // 10 minutes 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: * Check license status. This will:
* 1. Return cached OK status if within cache window * 1. Return cached OK status if within cache window
* 2. Try to verify existing token * 2. Validate package name (locally for license-file flow; via server for appKey flow)
* 3. Register new device if no token exists * 3. Try to verify existing token
* 4. Register new device if no token exists
* *
* @return LicenseResult.Success if license is valid, LicenseResult.Error otherwise * @return LicenseResult.Success if license is valid, LicenseResult.Error otherwise
*/ */
@ -100,6 +101,26 @@ object LicenseSDK {
return@withContext LicenseResult.Success("Cached") 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 deviceId = getOrCreateDeviceId()
val token = store.token val token = store.token
@ -251,6 +272,13 @@ object LicenseSDK {
val ctx = LicenseContextHolder.appContext ?: return false val ctx = LicenseContextHolder.appContext ?: return false
val licenseFile = LicenseFileReader.read(ctx) ?: return false val licenseFile = LicenseFileReader.read(ctx) ?: return false
val appKey = licenseFile.appKey.takeIf { it.isNotBlank() } ?: 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 appContext = ctx
store = LicenseStore(ctx) store = LicenseStore(ctx)
if (store.appKey != null && store.appKey != appKey) { if (store.appKey != null && store.appKey != appKey) {
@ -261,6 +289,7 @@ object LicenseSDK {
appKey = appKey, appKey = appKey,
baseUrl = normalizeBaseUrl(licenseFile.baseUrl ?: DEFAULT_BASE_URL), baseUrl = normalizeBaseUrl(licenseFile.baseUrl ?: DEFAULT_BASE_URL),
deviceName = DeviceInfoProvider.getDeviceName(), deviceName = DeviceInfoProvider.getDeviceName(),
fromLicenseFile = true,
) )
apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl) apiService = LicenseHttpClient.create(LicenseApiService::class.java, config.baseUrl)
initialized = true initialized = true

查看文件

@ -1,12 +1,15 @@
package com.xuqm.sdk.license.api package com.xuqm.sdk.license.api
import com.xuqm.sdk.license.model.ApiResponse 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.RegisterRequest
import com.xuqm.sdk.license.model.RegisterResponse import com.xuqm.sdk.license.model.RegisterResponse
import com.xuqm.sdk.license.model.VerifyRequest import com.xuqm.sdk.license.model.VerifyRequest
import com.xuqm.sdk.license.model.VerifyResponse import com.xuqm.sdk.license.model.VerifyResponse
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query
interface LicenseApiService { interface LicenseApiService {
@ -15,4 +18,7 @@ interface LicenseApiService {
@POST("api/license/verify") @POST("api/license/verify")
suspend fun verify(@Body request: VerifyRequest): ApiResponse<VerifyResponse> 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("email") val email: String? = null,
@SerializedName("phone") val phone: 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
}