checkpoint before checking out feature/account-managed-gateway
This commit is contained in:
@@ -28,8 +28,9 @@ import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.account.AccountRepository
|
||||
import com.osglab.account.features.account.AccountOperations
|
||||
import com.osglab.account.features.account.AccountReauthenticator
|
||||
import com.osglab.account.features.account.AccountRepository
|
||||
import com.osglab.account.features.account.AccountService
|
||||
import com.osglab.account.features.account.AppleAccountReauthenticator
|
||||
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
||||
@@ -103,12 +104,18 @@ import com.osglab.account.features.integrity.integrityRoutes
|
||||
import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||
import com.osglab.account.features.referrals.domain.ReferralException
|
||||
import com.osglab.account.features.referrals.routes.referralRoutes
|
||||
import com.osglab.account.features.referrals.services.ReferralOperations
|
||||
import com.osglab.account.features.referrals.services.ReferralService
|
||||
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
||||
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
||||
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.routes.storeKitRoutes
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
||||
@@ -288,6 +295,7 @@ fun Application.module() {
|
||||
accountRoutes(koin.get())
|
||||
creditRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), koin.get())
|
||||
storeKitRoutes(koin.get(), koin.get())
|
||||
}
|
||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||
configureGatewayRoutes(
|
||||
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
single<CreditOperations> { get<CreditService>() }
|
||||
single<StoreKitTransactionVerifier> {
|
||||
if (config.storeKit.enabled) {
|
||||
AppleStoreKitTransactionVerifier(
|
||||
bundleId = config.storeKit.bundleId,
|
||||
appAppleId = requireNotNull(config.storeKit.appAppleId),
|
||||
)
|
||||
} else {
|
||||
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
|
||||
}
|
||||
}
|
||||
single {
|
||||
StoreKitService(
|
||||
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
|
||||
verifier = get(),
|
||||
transactions = get(),
|
||||
)
|
||||
}
|
||||
single<TrialCreditGranter> {
|
||||
TrialCreditGranter { accountId ->
|
||||
get<CreditService>().grantSignupTrial(
|
||||
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
||||
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
||||
single<AccountProvisioner> {
|
||||
AccountProvisioner { accountId, deviceCheckToken ->
|
||||
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (deviceCheckToken != null && !granted) {
|
||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
java.time.Instant.now(),
|
||||
)
|
||||
}
|
||||
get<AccountService>().seedDisplayName(accountId, displayName)
|
||||
try {
|
||||
get<ReferralOperations>().getOrCreateCode(accountId)
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (_: ReferralException) {
|
||||
// Referral eligibility must not make account sign-in unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
single {
|
||||
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
reauthenticator = get(),
|
||||
)
|
||||
}
|
||||
single<AccountOperations> { get<AccountService>() }
|
||||
|
||||
single<UserRegistrationTimeProvider> {
|
||||
UserRegistrationTimeProvider { accountId ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.osglab.account.config
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import java.net.URI
|
||||
import java.util.Base64
|
||||
@@ -16,6 +17,7 @@ data class AppConfig(
|
||||
val antiAbuse: AntiAbuseConfig,
|
||||
val apple: AppleConfig,
|
||||
val credits: CreditsConfig,
|
||||
val storeKit: StoreKitConfig = StoreKitConfig(),
|
||||
val providers: ProvidersConfig,
|
||||
val integrity: IntegrityConfig,
|
||||
val admin: AdminConfig = AdminConfig(),
|
||||
@@ -76,10 +78,32 @@ data class AppConfig(
|
||||
)
|
||||
val credits = CreditsConfig(
|
||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
|
||||
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
||||
)
|
||||
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
|
||||
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
|
||||
raw.toLongOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
|
||||
}
|
||||
val storeKit = StoreKitConfig(
|
||||
enabled = storeKitEnabled,
|
||||
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
|
||||
appAppleId = storeKitAppAppleId,
|
||||
products = config.storeKitProducts("app.storeKit.products"),
|
||||
)
|
||||
if (storeKitEnabled) {
|
||||
require(storeKit.bundleId == apple.clientId) {
|
||||
"app.storeKit.bundleId must match app.apple.clientId"
|
||||
}
|
||||
require(storeKit.appAppleId != null) {
|
||||
"app.storeKit.appAppleId is required when StoreKit is enabled"
|
||||
}
|
||||
require(storeKit.products.isNotEmpty()) {
|
||||
"app.storeKit.products is required when StoreKit is enabled"
|
||||
}
|
||||
}
|
||||
val providers = ProvidersConfig(
|
||||
volcengine = VolcengineConfig(
|
||||
endpoint = config.valueOrDefault(
|
||||
@@ -294,6 +318,7 @@ data class AppConfig(
|
||||
antiAbuse = antiAbuse,
|
||||
apple = apple,
|
||||
credits = credits,
|
||||
storeKit = storeKit,
|
||||
providers = providers,
|
||||
integrity = integrity,
|
||||
admin = admin,
|
||||
@@ -359,6 +384,13 @@ data class CreditsConfig(
|
||||
val referralBindingDays: Long,
|
||||
)
|
||||
|
||||
data class StoreKitConfig(
|
||||
val enabled: Boolean = false,
|
||||
val bundleId: String = "com.osgkeyboard.ios",
|
||||
val appAppleId: Long? = null,
|
||||
val products: List<StoreKitProduct> = emptyList(),
|
||||
)
|
||||
|
||||
data class ProvidersConfig(
|
||||
val volcengine: VolcengineConfig,
|
||||
val deepSeek: DeepSeekConfig,
|
||||
@@ -492,6 +524,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
|
||||
?: throw ConfigValidationException("$path must be a positive integer")
|
||||
} ?: default
|
||||
|
||||
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
|
||||
val raw = optionalValue(path) ?: return emptyList()
|
||||
val products = raw.split(',').map { entry ->
|
||||
val parts = entry.split(':', limit = 2).map(String::trim)
|
||||
if (parts.size != 2) {
|
||||
throw ConfigValidationException("$path must use productId:credits entries")
|
||||
}
|
||||
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("$path credits must be positive integers")
|
||||
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
|
||||
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
|
||||
}
|
||||
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
|
||||
throw ConfigValidationException("$path contains duplicate product IDs")
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||
required(path).let {
|
||||
when (it.lowercase()) {
|
||||
|
||||
@@ -26,11 +26,13 @@ data class AccountRecord(
|
||||
val identityFingerprint: String,
|
||||
val antiAbuseRestricted: Boolean,
|
||||
val encryptedAppleRefreshToken: String?,
|
||||
val encryptedDisplayName: String? = null,
|
||||
val createdAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
||||
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
||||
"encryptedDisplayName=[REDACTED], " +
|
||||
"createdAt=$createdAt)"
|
||||
}
|
||||
|
||||
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object AccountProfilesTable : Table("account_profiles") {
|
||||
val accountId = varchar("account_id", 36)
|
||||
val encryptedDisplayName = text("encrypted_display_name")
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(accountId)
|
||||
}
|
||||
|
||||
interface AccountRepository {
|
||||
suspend fun findById(accountId: UUID): AccountRecord?
|
||||
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
|
||||
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
||||
val encryptedDisplayName = AccountProfilesTable.selectAll()
|
||||
.where { AccountProfilesTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AccountProfilesTable.encryptedDisplayName)
|
||||
row.let {
|
||||
AccountRecord(
|
||||
id = UUID.fromString(it[AccountsTable.id]),
|
||||
identityFingerprint = fingerprint,
|
||||
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
||||
encryptedAppleRefreshToken = encryptedRefreshToken,
|
||||
encryptedDisplayName = encryptedDisplayName,
|
||||
createdAt = it[AccountsTable.createdAt],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun seedDisplayNameIfAbsent(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
AccountProfilesTable.update({
|
||||
AccountProfilesTable.accountId eq accountId.toString()
|
||||
}) {
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.patch
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
|
||||
data class AccountResponse(
|
||||
val id: String,
|
||||
val createdAtEpochSeconds: Long,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateAccountProfileRequest(val displayName: String)
|
||||
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val identityToken: String,
|
||||
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
|
||||
}
|
||||
|
||||
class AccountRoutes(
|
||||
private val accountService: AccountService,
|
||||
private val accountService: AccountOperations,
|
||||
) {
|
||||
fun register(parent: Route) {
|
||||
with(parent) {
|
||||
@@ -49,14 +54,17 @@ class AccountRoutes(
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val account = accountService.get(principal.userId)
|
||||
call.respond(
|
||||
ApiResponse(
|
||||
data = AccountResponse(
|
||||
id = account.id.toString(),
|
||||
createdAtEpochSeconds = account.createdAt.epochSecond,
|
||||
),
|
||||
),
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
patch {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val request = call.receive<UpdateAccountProfileRequest>()
|
||||
val account = accountService.updateDisplayName(
|
||||
principal.userId,
|
||||
request.displayName,
|
||||
)
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
delete {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
@@ -73,5 +81,11 @@ class AccountRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.accountRoutes(accountService: AccountService) =
|
||||
fun Route.accountRoutes(accountService: AccountOperations) =
|
||||
AccountRoutes(accountService).register(this)
|
||||
|
||||
private fun AccountView.toResponse() = AccountResponse(
|
||||
id = id.toString(),
|
||||
createdAtEpochSeconds = createdAt.epochSecond,
|
||||
displayName = displayName,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.account
|
||||
|
||||
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
||||
import com.osglab.account.common.errors.InvalidRequestException
|
||||
import com.osglab.account.common.errors.UnauthorizedException
|
||||
import com.osglab.account.common.security.FieldDecryptionException
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.text.Normalizer
|
||||
import java.util.UUID
|
||||
|
||||
data class AccountView(
|
||||
val id: UUID,
|
||||
val createdAt: Instant,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
data class AppleReauthenticationProof(
|
||||
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
|
||||
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
||||
}
|
||||
|
||||
interface AccountOperations {
|
||||
suspend fun get(accountId: UUID): AccountView
|
||||
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
|
||||
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
|
||||
}
|
||||
|
||||
class AppleAccountReauthenticator(
|
||||
private val identityVerifier: AppleIdentityTokenVerifier,
|
||||
private val appleTokenClient: AppleTokenClient,
|
||||
@@ -94,13 +104,34 @@ class AccountService(
|
||||
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
||||
private val reauthenticator: AccountReauthenticator,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun get(accountId: UUID): AccountView {
|
||||
) : AccountOperations {
|
||||
override suspend fun get(accountId: UUID): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
return AccountView(account.id, account.createdAt)
|
||||
return account.toView()
|
||||
}
|
||||
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
|
||||
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
|
||||
val account = repository.findById(accountId) ?: return
|
||||
repository.seedDisplayNameIfAbsent(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
val displayName = normalizedDisplayName(candidate)
|
||||
repository.updateDisplayName(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
return requireNotNull(repository.findById(accountId)).toView()
|
||||
}
|
||||
|
||||
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
val account = repository.findById(accountId) ?: return
|
||||
val now = clock.instant()
|
||||
val currentRefreshToken = reauthenticator.verify(account, proof)
|
||||
@@ -126,6 +157,14 @@ class AccountService(
|
||||
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountRecord.toView(): AccountView = AccountView(
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
displayName = encryptedDisplayName?.let {
|
||||
fieldEncryptor.decrypt(it, accountProfileContext(id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
class AppleRevocationOutboxProcessor(
|
||||
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
|
||||
}
|
||||
|
||||
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
||||
|
||||
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
|
||||
|
||||
private fun normalizedDisplayNameOrNull(candidate: String): String? =
|
||||
runCatching { normalizedDisplayName(candidate) }.getOrNull()
|
||||
|
||||
private fun normalizedDisplayName(candidate: String): String {
|
||||
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
|
||||
.replace(WHITESPACE_REGEX, " ")
|
||||
if (
|
||||
normalized.isBlank() ||
|
||||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
|
||||
normalized.any { it.isISOControl() }
|
||||
) {
|
||||
throw InvalidRequestException("Display name is invalid")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
|
||||
|
||||
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
|
||||
val identityToken: String,
|
||||
val authorizationCode: String,
|
||||
val nonce: String,
|
||||
val displayName: String? = null,
|
||||
val deviceCheckToken: String? = null,
|
||||
val appAttest: AppAttestRequest? = null,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
|
||||
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
|
||||
"nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
|
||||
"appAttest=[REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -73,6 +75,7 @@ class AuthRoutes(
|
||||
identityToken = request.identityToken,
|
||||
authorizationCode = request.authorizationCode,
|
||||
nonce = request.nonce,
|
||||
displayName = request.displayName,
|
||||
integrityEvidence = IntegrityEvidence(
|
||||
deviceCheckToken = request.deviceCheckToken,
|
||||
appAttest = request.appAttest?.let {
|
||||
|
||||
@@ -34,7 +34,7 @@ data class SessionTokens(
|
||||
}
|
||||
|
||||
fun interface AccountProvisioner {
|
||||
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
|
||||
suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
|
||||
}
|
||||
|
||||
class SessionService(
|
||||
@@ -46,7 +46,7 @@ class SessionService(
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val identityFingerprint: IdentityFingerprint,
|
||||
private val sessionConfig: SessionConfig,
|
||||
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
|
||||
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
|
||||
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
@@ -55,6 +55,7 @@ class SessionService(
|
||||
authorizationCode: String,
|
||||
nonce: String,
|
||||
integrityEvidence: IntegrityEvidence,
|
||||
displayName: String? = null,
|
||||
): SessionTokens {
|
||||
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
|
||||
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
|
||||
@@ -90,6 +91,7 @@ class SessionService(
|
||||
accountProvisioner.provision(
|
||||
account.id,
|
||||
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
|
||||
displayName,
|
||||
)
|
||||
return createSession(account.id, now)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ data class CreditAccount(
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
data class CreditAccountSummary(
|
||||
val account: CreditAccount,
|
||||
val lifetimeUsed: Long,
|
||||
)
|
||||
|
||||
data class LedgerEntry(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.osglab.account.features.credits.models
|
||||
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditAccountSummary
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.CreditReservation
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
|
||||
data class CreditAccountDto(
|
||||
val userId: String,
|
||||
val balance: Long,
|
||||
val lifetimeUsed: Long,
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(account: CreditAccount) = CreditAccountDto(
|
||||
userId = account.userId.toString(),
|
||||
balance = account.balance,
|
||||
updatedAt = account.updatedAt.toString(),
|
||||
fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
|
||||
userId = summary.account.userId.toString(),
|
||||
balance = summary.account.balance,
|
||||
lifetimeUsed = summary.lifetimeUsed,
|
||||
updatedAt = summary.account.updatedAt.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@@ -28,6 +29,8 @@ interface CreditsRepository {
|
||||
|
||||
fun insertUsageRecord(record: CreditUsageRecord)
|
||||
|
||||
fun lifetimeUsedCredits(userId: UUID): Long
|
||||
|
||||
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
|
||||
|
||||
fun lockReservation(id: UUID): CreditReservation?
|
||||
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
|
||||
val credits: CreditsRepository
|
||||
val referrals: ReferralsRepository
|
||||
val adminCreditGrants: AdminCreditGrantRepository
|
||||
val storeKit: StoreKitRepository
|
||||
}
|
||||
|
||||
interface BillingTransactionRunner {
|
||||
|
||||
+23
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
||||
import com.osglab.account.features.referrals.domain.ReferralCode
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.repositories.ExposedStoreKitRepository
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
@@ -27,6 +29,7 @@ import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
|
||||
override val credits: CreditsRepository = ExposedCreditsRepository
|
||||
override val referrals: ReferralsRepository = ExposedReferralsRepository
|
||||
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
|
||||
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
|
||||
}
|
||||
|
||||
private object ExposedCreditsRepository : CreditsRepository {
|
||||
@@ -282,6 +286,25 @@ private object ExposedCreditsRepository : CreditsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
override fun lifetimeUsedCredits(userId: UUID): Long {
|
||||
val chargedTotal = CreditUsageRecords.chargedCredits.sum()
|
||||
val charged = CreditUsageRecords
|
||||
.select(chargedTotal)
|
||||
.where { CreditUsageRecords.userId eq userId.toString() }
|
||||
.single()[chargedTotal] ?: 0
|
||||
val refundTotal = CreditLedger.amountDelta.sum()
|
||||
val refunded = CreditLedger
|
||||
.select(refundTotal)
|
||||
.where {
|
||||
(CreditLedger.userId eq userId.toString()) and
|
||||
(CreditLedger.entryType eq LedgerEntryType.USAGE_REFUND)
|
||||
}
|
||||
.single()[refundTotal] ?: 0
|
||||
return Math.subtractExact(charged, refunded).also {
|
||||
check(it >= 0) { "Refunded credits exceed settled usage" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findReservationByReserveKey(
|
||||
userId: UUID,
|
||||
idempotencyKey: String,
|
||||
|
||||
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
|
||||
parent.route("/v1/credits") {
|
||||
get("/balance") {
|
||||
call.creditCall(authenticatedUser) { userId ->
|
||||
CreditAccountDto.fromDomain(service.getAccount(userId))
|
||||
CreditAccountDto.fromDomain(service.getAccountSummary(userId))
|
||||
}
|
||||
}
|
||||
get("/ledger") {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditAccountSummary
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
|
||||
interface CreditOperations {
|
||||
suspend fun getAccount(userId: UUID): CreditAccount
|
||||
|
||||
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
|
||||
|
||||
suspend fun listEffectiveRates(): List<CreditRateVersion>
|
||||
|
||||
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
|
||||
@@ -107,6 +110,15 @@ class CreditService(
|
||||
unit.credits.lockAccount(userId)
|
||||
}
|
||||
|
||||
override suspend fun getAccountSummary(userId: UUID): CreditAccountSummary =
|
||||
transactions.inTransaction { unit ->
|
||||
unit.credits.createAccountIfAbsent(userId, clock.instant())
|
||||
CreditAccountSummary(
|
||||
account = unit.credits.lockAccount(userId),
|
||||
lifetimeUsed = unit.credits.lifetimeUsedCredits(userId),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
|
||||
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Clock
|
||||
@@ -283,10 +284,13 @@ class AppAttestService(
|
||||
)
|
||||
IntegrityVerification.Verified
|
||||
} catch (exception: AppAttestRejectedException) {
|
||||
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
|
||||
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
|
||||
} catch (exception: InvalidRequestException) {
|
||||
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
|
||||
IntegrityVerification.Rejected(exception.message)
|
||||
} catch (exception: AppAttestUnavailableException) {
|
||||
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
|
||||
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
@@ -392,6 +396,7 @@ class AppAttestService(
|
||||
const val SHA256_BYTES = 32
|
||||
const val MAX_ATTESTATION_BYTES = 256 * 1024
|
||||
const val MAX_ASSERTION_BYTES = 64 * 1024
|
||||
val APP_ATTEST_LOG = LoggerFactory.getLogger(AppAttestService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,11 +251,11 @@ class LibraryAppAttestCrypto(
|
||||
} catch (exception: Exception) {
|
||||
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
|
||||
}
|
||||
val signedBytes = authenticatorDataBytes + clientDataHash
|
||||
val nonce = sha256(authenticatorDataBytes + clientDataHash)
|
||||
val verified = try {
|
||||
Signature.getInstance("SHA256withECDSA").run {
|
||||
initVerify(key)
|
||||
update(signedBytes)
|
||||
update(nonce)
|
||||
verify(signatureBytes)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
|
||||
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
|
||||
val flags = buffer.get().toInt() and 0xff
|
||||
val count = buffer.int.toLong() and UINT32_MASK
|
||||
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
|
||||
(flags and FLAG_EXTENSION_DATA) != 0
|
||||
) {
|
||||
// Production App Attest assertions can set AT while still using
|
||||
// Apple's fixed 37-byte assertion profile. Exact-length validation
|
||||
// above ensures no attested credential bytes are appended.
|
||||
if ((flags and FLAG_EXTENSION_DATA) != 0) {
|
||||
throw AppAttestRejectedException("App Attest assertion flags are invalid")
|
||||
}
|
||||
return AssertionAuthenticatorData(rpHash, flags, count)
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.security.KeyFactory
|
||||
import java.security.interfaces.ECPrivateKey
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
|
||||
|
||||
private companion object {
|
||||
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
|
||||
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
|
||||
const val DEFAULT_TIMEOUT_MILLIS = 15_000L
|
||||
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
|
||||
val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
|
||||
} catch (exception: DeviceCheckRejectedException) {
|
||||
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
|
||||
} catch (exception: DeviceCheckUnavailableException) {
|
||||
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
|
||||
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOG = LoggerFactory.getLogger(RemoteDeviceCheckVerifier::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
open class DeviceCheckException(message: String, cause: Throwable? = null) :
|
||||
|
||||
@@ -177,13 +177,15 @@ class ReferralService(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProfile(userId: UUID): ReferralProfile =
|
||||
transactions.inTransaction { unit ->
|
||||
override suspend fun getProfile(userId: UUID): ReferralProfile {
|
||||
val activeCode = getOrCreateCode(userId)
|
||||
return transactions.inTransaction { unit ->
|
||||
ReferralProfile(
|
||||
code = unit.referrals.findCodeByOwner(userId),
|
||||
code = activeCode,
|
||||
binding = unit.referrals.findBinding(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
|
||||
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.osglab.account.features.storekit.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class StoreKitEnvironment {
|
||||
SANDBOX,
|
||||
PRODUCTION,
|
||||
}
|
||||
|
||||
data class StoreKitProduct(
|
||||
val productId: String,
|
||||
val credits: Long,
|
||||
) {
|
||||
init {
|
||||
require(PRODUCT_ID.matches(productId)) { "StoreKit product ID is invalid" }
|
||||
require(credits > 0) { "StoreKit product credits must be positive" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PRODUCT_ID = Regex("[A-Za-z0-9._-]{3,128}")
|
||||
}
|
||||
}
|
||||
|
||||
data class VerifiedStoreKitTransaction(
|
||||
val transactionId: String,
|
||||
val originalTransactionId: String,
|
||||
val appAccountToken: UUID,
|
||||
val productId: String,
|
||||
val environment: StoreKitEnvironment,
|
||||
val purchasedAt: Instant,
|
||||
val signedAt: Instant,
|
||||
val revokedAt: Instant?,
|
||||
)
|
||||
|
||||
data class StoreKitCreditPurchase(
|
||||
val id: UUID,
|
||||
val transactionId: String,
|
||||
val originalTransactionId: String,
|
||||
val userId: UUID,
|
||||
val appAccountToken: UUID,
|
||||
val productId: String,
|
||||
val environment: StoreKitEnvironment,
|
||||
val creditsGranted: Long,
|
||||
val ledgerEntryId: UUID,
|
||||
val signedTransactionSha256: String,
|
||||
val purchasedAt: Instant,
|
||||
val signedAt: Instant,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
data class StoreKitPurchaseResult(
|
||||
val purchase: StoreKitCreditPurchase,
|
||||
val balanceAfter: Long,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
sealed class StoreKitException(message: String) : RuntimeException(message)
|
||||
|
||||
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
|
||||
|
||||
class InvalidStoreKitRequest(message: String) : StoreKitException(message)
|
||||
|
||||
class StoreKitVerificationFailed : StoreKitException("The App Store transaction could not be verified")
|
||||
|
||||
class StoreKitPurchaseConflict : StoreKitException("The App Store transaction conflicts with an existing purchase")
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.osglab.account.features.storekit.models
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class StoreKitProductDto(
|
||||
val productId: String,
|
||||
val credits: Long,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(product: StoreKitProduct): StoreKitProductDto =
|
||||
StoreKitProductDto(product.productId, product.credits)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoreKitSubmitRequest(
|
||||
val signedTransaction: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StoreKitPurchaseResponse(
|
||||
val transactionId: String,
|
||||
val productId: String,
|
||||
val creditsGranted: Long,
|
||||
val balanceAfter: Long,
|
||||
val replayed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(result: StoreKitPurchaseResult): StoreKitPurchaseResponse =
|
||||
StoreKitPurchaseResponse(
|
||||
transactionId = result.purchase.transactionId,
|
||||
productId = result.purchase.productId,
|
||||
creditsGranted = result.purchase.creditsGranted,
|
||||
balanceAfter = result.balanceAfter,
|
||||
replayed = result.replayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.osglab.account.features.storekit.repositories
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import java.util.UUID
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
|
||||
interface StoreKitRepository {
|
||||
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
|
||||
|
||||
fun insert(purchase: StoreKitCreditPurchase)
|
||||
}
|
||||
|
||||
object ExposedStoreKitRepository : StoreKitRepository {
|
||||
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
|
||||
StoreKitCreditPurchases
|
||||
.selectAll()
|
||||
.where { StoreKitCreditPurchases.transactionId eq transactionId }
|
||||
.singleOrNull()
|
||||
?.toStoreKitCreditPurchase()
|
||||
|
||||
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||
StoreKitCreditPurchases.insert {
|
||||
it[id] = purchase.id.toString()
|
||||
it[transactionId] = purchase.transactionId
|
||||
it[originalTransactionId] = purchase.originalTransactionId
|
||||
it[userId] = purchase.userId.toString()
|
||||
it[appAccountToken] = purchase.appAccountToken.toString()
|
||||
it[productId] = purchase.productId
|
||||
it[environment] = purchase.environment
|
||||
it[creditsGranted] = purchase.creditsGranted
|
||||
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
|
||||
it[signedTransactionSha256] = purchase.signedTransactionSha256
|
||||
it[purchasedAt] = purchase.purchasedAt
|
||||
it[signedAt] = purchase.signedAt
|
||||
it[createdAt] = purchase.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
|
||||
val id = varchar("id", 36)
|
||||
val transactionId = varchar("transaction_id", 64)
|
||||
val originalTransactionId = varchar("original_transaction_id", 64)
|
||||
val userId = varchar("user_id", 36)
|
||||
val appAccountToken = varchar("app_account_token", 36)
|
||||
val productId = varchar("product_id", 128)
|
||||
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
|
||||
val creditsGranted = long("credits_granted")
|
||||
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
|
||||
val purchasedAt = timestamp("purchased_at")
|
||||
val signedAt = timestamp("signed_at")
|
||||
val createdAt = timestamp("created_at")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
|
||||
StoreKitCreditPurchase(
|
||||
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
|
||||
transactionId = this[StoreKitCreditPurchases.transactionId],
|
||||
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
|
||||
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
|
||||
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
|
||||
productId = this[StoreKitCreditPurchases.productId],
|
||||
environment = this[StoreKitCreditPurchases.environment],
|
||||
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
|
||||
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
|
||||
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
|
||||
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
|
||||
signedAt = this[StoreKitCreditPurchases.signedAt],
|
||||
createdAt = this[StoreKitCreditPurchases.createdAt],
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.osglab.account.features.storekit.routes
|
||||
|
||||
import com.osglab.account.common.api.ApiError
|
||||
import com.osglab.account.common.api.ApiErrorResponse
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.models.StoreKitProductDto
|
||||
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
|
||||
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
|
||||
fun Route.storeKitRoutes(
|
||||
service: StoreKitService,
|
||||
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||
) {
|
||||
route("/v1/storekit") {
|
||||
get("/products") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@get
|
||||
}
|
||||
call.respond(service.products().map(StoreKitProductDto::fromDomain))
|
||||
}
|
||||
post("/transactions") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
val request = call.receive<StoreKitSubmitRequest>()
|
||||
try {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
StoreKitPurchaseResponse.fromDomain(
|
||||
service.submit(userId, request.signedTransaction)
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitUnavailable) {
|
||||
call.respond(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
ApiErrorResponse(
|
||||
ApiError("external_service_unavailable", "Credit purchases are unavailable")
|
||||
),
|
||||
)
|
||||
} catch (_: InvalidStoreKitRequest) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||
)
|
||||
} catch (_: StoreKitVerificationFailed) {
|
||||
call.respond(
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
ApiErrorResponse(
|
||||
ApiError("transaction_invalid", "The App Store transaction is invalid")
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitPurchaseConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
} catch (_: CreditConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.osglab.account.features.storekit.services
|
||||
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class StoreKitService(
|
||||
products: List<StoreKitProduct>,
|
||||
private val verifier: StoreKitTransactionVerifier,
|
||||
private val transactions: BillingTransactionRunner,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
private val newId: () -> UUID = UUID::randomUUID,
|
||||
) {
|
||||
private val productsById = products.associateBy(StoreKitProduct::productId)
|
||||
|
||||
init {
|
||||
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
|
||||
}
|
||||
|
||||
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
|
||||
|
||||
suspend fun submit(
|
||||
userId: UUID,
|
||||
signedTransaction: String,
|
||||
): StoreKitPurchaseResult {
|
||||
if (productsById.isEmpty()) {
|
||||
throw StoreKitUnavailable()
|
||||
}
|
||||
if (
|
||||
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
|
||||
signedTransaction != signedTransaction.trim()
|
||||
) {
|
||||
throw InvalidStoreKitRequest("signedTransaction is invalid")
|
||||
}
|
||||
val verified = verifier.verify(signedTransaction)
|
||||
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
|
||||
validateTransaction(userId, verified)
|
||||
val digest = signedTransaction.sha256Hex()
|
||||
val idempotencyKey = "storekit:${verified.transactionId}"
|
||||
|
||||
return transactions.inTransaction { unit ->
|
||||
val now = clock.instant()
|
||||
unit.credits.createAccountIfAbsent(userId, now)
|
||||
val account = unit.credits.lockAccount(userId)
|
||||
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
|
||||
requireReplayMatches(existing, verified, product)
|
||||
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
|
||||
?: throw StoreKitPurchaseConflict()
|
||||
if (
|
||||
ledger.id != existing.ledgerEntryId ||
|
||||
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
|
||||
ledger.amountDelta != existing.creditsGranted ||
|
||||
ledger.referenceId != existing.id
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
|
||||
}
|
||||
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
|
||||
val balanceAfter = try {
|
||||
Math.addExact(account.balance, product.credits)
|
||||
} catch (_: ArithmeticException) {
|
||||
throw CreditConflict("StoreKit credit balance overflow")
|
||||
}
|
||||
val purchaseId = newId()
|
||||
val ledgerEntryId = newId()
|
||||
val purchase = StoreKitCreditPurchase(
|
||||
id = purchaseId,
|
||||
transactionId = verified.transactionId,
|
||||
originalTransactionId = verified.originalTransactionId,
|
||||
userId = userId,
|
||||
appAccountToken = verified.appAccountToken,
|
||||
productId = product.productId,
|
||||
environment = verified.environment,
|
||||
creditsGranted = product.credits,
|
||||
ledgerEntryId = ledgerEntryId,
|
||||
signedTransactionSha256 = digest,
|
||||
purchasedAt = verified.purchasedAt,
|
||||
signedAt = verified.signedAt,
|
||||
createdAt = now,
|
||||
)
|
||||
unit.credits.updateAccountBalance(userId, balanceAfter, now)
|
||||
unit.credits.insertLedgerEntry(
|
||||
LedgerEntry(
|
||||
id = ledgerEntryId,
|
||||
userId = userId,
|
||||
type = LedgerEntryType.STOREKIT_PURCHASE,
|
||||
amountDelta = product.credits,
|
||||
balanceAfter = balanceAfter,
|
||||
idempotencyKey = idempotencyKey,
|
||||
referenceId = purchaseId,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
unit.storeKit.insert(purchase)
|
||||
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateTransaction(
|
||||
userId: UUID,
|
||||
transaction: VerifiedStoreKitTransaction,
|
||||
) {
|
||||
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
val now = clock.instant()
|
||||
if (
|
||||
transaction.purchasedAt > transaction.signedAt ||
|
||||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireReplayMatches(
|
||||
existing: StoreKitCreditPurchase,
|
||||
verified: VerifiedStoreKitTransaction,
|
||||
product: StoreKitProduct,
|
||||
) {
|
||||
if (
|
||||
existing.userId != verified.appAccountToken ||
|
||||
existing.originalTransactionId != verified.originalTransactionId ||
|
||||
existing.productId != verified.productId ||
|
||||
existing.environment != verified.environment ||
|
||||
existing.creditsGranted != product.credits ||
|
||||
existing.purchasedAt != verified.purchasedAt
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
|
||||
private companion object {
|
||||
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
|
||||
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
|
||||
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.osglab.account.features.storekit.verification
|
||||
|
||||
import com.apple.itunes.storekit.model.Environment
|
||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
|
||||
import com.apple.itunes.storekit.model.Type
|
||||
import com.apple.itunes.storekit.verification.SignedDataVerifier
|
||||
import com.apple.itunes.storekit.verification.VerificationException
|
||||
import com.apple.itunes.storekit.verification.VerificationStatus
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
fun interface StoreKitTransactionVerifier {
|
||||
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
|
||||
}
|
||||
|
||||
class AppleStoreKitTransactionVerifier(
|
||||
bundleId: String,
|
||||
appAppleId: Long,
|
||||
rootCertificateLoader: (String) -> InputStream? = {
|
||||
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
|
||||
},
|
||||
enableOnlineChecks: Boolean = true,
|
||||
) : StoreKitTransactionVerifier {
|
||||
private val production: SignedDataVerifier
|
||||
private val sandbox: SignedDataVerifier
|
||||
|
||||
init {
|
||||
val certificateBytes = ROOT_CERTIFICATES.map { path ->
|
||||
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
|
||||
?: error("Missing Apple root certificate: $path")
|
||||
}
|
||||
fun verifier(environment: Environment): SignedDataVerifier {
|
||||
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
|
||||
return SignedDataVerifier(
|
||||
streams,
|
||||
bundleId,
|
||||
if (environment == Environment.PRODUCTION) appAppleId else null,
|
||||
environment,
|
||||
enableOnlineChecks,
|
||||
).also { streams.forEach(InputStream::close) }
|
||||
}
|
||||
production = verifier(Environment.PRODUCTION)
|
||||
sandbox = verifier(Environment.SANDBOX)
|
||||
}
|
||||
|
||||
override suspend fun verify(
|
||||
signedTransaction: String
|
||||
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
|
||||
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
var retryableFailure = false
|
||||
val payload = try {
|
||||
production.verifyAndDecodeTransaction(signedTransaction)
|
||||
} catch (exception: VerificationException) {
|
||||
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||
null
|
||||
} ?: try {
|
||||
sandbox.verifyAndDecodeTransaction(signedTransaction)
|
||||
} catch (exception: VerificationException) {
|
||||
retryableFailure = retryableFailure ||
|
||||
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||
null
|
||||
}
|
||||
if (payload == null && retryableFailure) {
|
||||
throw StoreKitUnavailable()
|
||||
}
|
||||
if (payload == null) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
payload.toVerifiedTransaction()
|
||||
}
|
||||
|
||||
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
|
||||
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
|
||||
val product = productId?.takeIf { it.length in 3..128 }
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||
if (type != Type.CONSUMABLE || quantity != 1) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
val verifiedEnvironment = when (environment) {
|
||||
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
|
||||
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
|
||||
else -> throw StoreKitVerificationFailed()
|
||||
}
|
||||
return VerifiedStoreKitTransaction(
|
||||
transactionId = transaction,
|
||||
originalTransactionId = original,
|
||||
appAccountToken = accountToken,
|
||||
productId = product,
|
||||
environment = verifiedEnvironment,
|
||||
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
|
||||
signedAt = Instant.ofEpochMilli(signedMillis),
|
||||
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_JWS_LENGTH = 100
|
||||
const val MAX_JWS_LENGTH = 32_768
|
||||
val TRANSACTION_ID = Regex("[0-9]{1,64}")
|
||||
val ROOT_CERTIFICATES = listOf(
|
||||
"/apple-pki/AppleRootCA-G2.cer",
|
||||
"/apple-pki/AppleRootCA-G3.cer",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user