构建基于WebAuthn与Weaviate多租户隔离的Android原生向量搜索体验


为企业级SaaS产品构建搜索功能,传统的关键字匹配早已捉襟见肘。用户期望的是能理解语义的智能搜索。向量搜索是答案,但将其引入一个多租户(Multi-Tenant)环境,挑战便陡然升级。每个租户的数据必须被严格隔离,任何情况下都不能发生数据串通。同时,传统的密码认证体系正成为安全短板,频繁的数据泄露事件警示我们必须转向更安全的方案。

我们的目标是构建一个端到端的解决方案:在Android端,使用Jetpack Compose提供流畅的原生体验;在认证层,采用WebAuthn实现无密码登录;在后端,利用Weaviate向量数据库强大的多租户特性,确保数据隔离,并由一个轻量级的Ktor服务作为安全网关。这不只是一个功能的堆砌,而是一次关于安全、隔离与现代用户体验的架构实践。

第一步:Weaviate的多租户配置与数据隔离

Weaviate对多租户的原生支持是本次技术选型的关键。它允许我们在单个实例中为不同租户管理数据,并通过一个HTTP头 X-Tenant 来实现请求级别的隔离。这种设计远比为每个租户部署一个独立实例要经济和高效。

首先,是我们的docker-compose.yml配置。这里没有太多魔法,但需要确保启用了身份验证,即便在开发环境中,这也是个好习惯。

# docker-compose.yml
version: '3.4'
services:
  weaviate:
    image: cr.weaviate.io/semitechnologies/weaviate:1.23.7
    ports:
      - "8080:8080"
      - "50051:50051"
    restart: on-failure:0
    environment:
      QUERY_DEFAULTS_LIMIT: 25
      AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false'
      AUTHENTICATION_APIKEY_ENABLED: 'true'
      AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'my-secret-api-key' # 生产环境应使用更安全的管理方式
      AUTHENTICATION_APIKEY_USERS: 'user@example.com'
      DEFAULT_VECTORIZER_MODULE: 'text2vec-openai' # 也可以使用开源模型
      ENABLE_MODULES: 'text2vec-openai,generative-openai'
      OPENAI_APIKEY: 'YOUR_OPENAI_API_KEY' # 替换为你的OpenAI key
      CLUSTER_HOSTNAME: 'node1'
      PERSISTENCE_DATA_PATH: './data'

接下来是定义数据结构(Schema)。核心在于multiTenancyConfig的设置,一旦将enabled设为true,这个Collection就进入了多租户模式。

// 使用 Weaviate Kotlin 客户端定义 Schema
import io.weaviate.client.WeaviateClient
import io.weaviate.client.base.Result
import io.weaviate.client.v1.schema.model.Property
import io.weaviate.client.v1.schema.model.WeaviateClass
import io.weaviate.client.v1.schema.model.MultiTenancyConfig

suspend fun createMultiTenantSchema(client: WeaviateClient) {
    val documentClass = WeaviateClass.builder()
        .className("PrivateDocument")
        .description("A class to store tenant-specific documents")
        .vectorizer("text2vec-openai")
        .multiTenancyConfig(
            MultiTenancyConfig.builder()
                .enabled(true)
                .build()
        )
        .properties(
            listOf(
                Property.builder()
                    .name("content")
                    .dataType(listOf("text"))
                    .build(),
                Property.builder()
                    .name("source")
                    .dataType(listOf("string"))
                    .build()
            )
        )
        .build()

    val result: Result<Boolean> = client.schema().classCreator().withClass(documentClass).run()
    if (result.hasErrors()) {
        // 在真实项目中,这里应该是结构化日志
        println("Schema creation error: ${result.error.messages}")
        return
    }
    println("Schema 'PrivateDocument' created successfully.")
}

Schema创建后,我们必须显式地创建租户。租户只是一个字符串标识符,但在我们的架构中,它将与用户认证信息强绑定。

import io.weaviate.client.v1.schema.model.Tenant

suspend fun addTenants(client: WeaviateClient) {
    val tenants = listOf(
        Tenant.builder().name("tenant-a").build(),
        Tenant.builder().name("tenant-b").build()
    )
    
    val result = client.schema().tenantsCreator()
        .withClassName("PrivateDocument")
        .withTenants(tenants.toTypedArray())
        .run()

    if (result.hasErrors()) {
        println("Tenant creation error: ${result.error.messages}")
    } else {
        println("Tenants 'tenant-a' and 'tenant-b' added.")
    }
}

现在,所有的数据操作(增、删、查、改)都必须指定租户。例如,为tenant-a添加一些文档:

import io.weaviate.client.v1.data.model.WeaviateObject

suspend fun addDataForTenant(client: WeaviateClient, tenantId: String) {
    val doc1 = WeaviateObject.builder()
        .className("PrivateDocument")
        .tenant(tenantId) // 关键:指定租户
        .properties(mapOf("content" to "Project Alpha internal memo regarding Q3 strategy.", "source" to "memo-q3.doc"))
        .build()
    
    val doc2 = WeaviateObject.builder()
        .className("PrivateDocument")
        .tenant(tenantId)
        .properties(mapOf("content" to "Financial forecast for the upcoming fiscal year for Alpha team.", "source" to "forecast-alpha.xls"))
        .build()

    val result = client.data().creator()
        .withObjects(doc1, doc2)
        .run()
    
    if (result.hasErrors()) {
        println("Data import error for $tenantId: ${result.error.messages}")
    } else {
        println("Data imported successfully for $tenantId.")
    }
}

查询时也是同理。如果我们不提供X-Tenant头,查询会失败。如果提供了X-Tenant: tenant-a,则只会搜索到属于tenant-a的文档。这就是物理隔离的保证。

// 伪代码,展示了客户端如何构造请求
fun search(query: String, tenantId: String): SearchResults {
    // Weaviate client 内部会将 tenantId 转化为 X-Tenant header
    val result = weaviateClient.graphQL()
        .get()
        .withClassName("PrivateDocument")
        .withTenant(tenantId) // 指定租户
        .withFields(...)
        .withNearText(...)
        .run()
    // ... 处理结果
}

这里的坑在于,多租户模式一旦开启,就不可关闭。在设计Schema时必须深思熟虑。

第二步:Ktor后端作为认证与安全代理

直接让移动端App连接Weaviate是极不安全的。我们需要一个中间层来处理认证,并根据认证结果代理请求到Weaviate。Ktor因其轻量级和对协程的良好支持,成为Kotlin技术栈下的理想选择。

整个流程的核心是:

  1. 用户通过Android App发起WebAuthn注册/登录流程。
  2. Ktor后端处理WebAuthn的挑战和验证。
  3. 验证成功后,Ktor生成一个包含tenant_iduser_id的JWT。
  4. App后续的所有请求都携带此JWT。
  5. Ktor的一个受保护的/search端点会验证JWT,提取tenant_id,然后将请求安全地代理给Weaviate,并附上X-Tenant头。
sequenceDiagram
    participant App as Jetpack Compose App
    participant Backend as Ktor Backend
    participant WeaviateDB as Weaviate
    
    App->>+Backend: 1. 发起WebAuthn登录
    Backend-->>-App: 2. 返回登录挑战 (Challenge)
    App->>+Backend: 3. 使用生物识别签名挑战
    Backend-->>-App: 4. 验证签名, 登录成功, 颁发JWT (含tenant_id)
    
    App->>+Backend: 5. 发起搜索请求 (携带JWT)
    Backend->>Backend: 6. 验证JWT, 提取tenant_id
    Backend->>+WeaviateDB: 7. 代理搜索请求 (附带 X-Tenant header)
    WeaviateDB-->>-Backend: 8. 返回隔离的搜索结果
    Backend-->>-App: 9. 返回结果给App

我们将重点放在Ktor后端的WebAuthn和安全代理实现上。这里我们使用一个流行的Java WebAuthn库。

1. 依赖配置 (build.gradle.kts)

dependencies {
    // Ktor
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
    implementation("io.ktor:ktor-server-auth:$ktor_version")
    implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
    
    // WebAuthn Library
    implementation("com.yubico:webauthn-server-core:2.0.0")

    // 日志
    implementation("ch.qos.logback:logback-classic:$logback_version")
}

2. WebAuthn 核心逻辑

这部分代码相当复杂,因为它涉及到密码学操作和状态管理。在真实项目中,userRepositorycredentialRepository会和数据库交互。

// WebAuthnServer.kt
import com.yubico.webauthn.*
import com.yubico.webauthn.data.*
// ... 其他 imports

class WebAuthnServer(
    private val relyingParty: RelyingParty,
    private val userRepository: UserRepository, // 你的用户存储
    private val credentialRepository: CredentialRepository // 你的凭证存储
) {
    // 开始注册
    fun startRegistration(username: String, tenantId: String): RegistrationResult {
        val user = userRepository.findByUsername(username)
            ?: userRepository.createUser(username, tenantId) // 如果不存在则创建
        
        val registration = relyingParty.startRegistration(
            StartRegistrationOptions.builder()
                .user(user.toUserIdentity())
                .build()
        )
        // 关键:将 challenge 暂存起来,与用户关联,用于后续验证
        // 在生产环境中,这应该存入Redis并设置TTL
        userRepository.updateRegistrationChallenge(user.id, registration.getChallenge())
        
        return RegistrationResult(
            success = true,
            publicKeyCredentialCreationOptions = registration
        )
    }

    // 完成注册
    fun finishRegistration(responseJson: String, username: String): FinishRegistrationResult {
        val user = userRepository.findByUsername(username) ?: throw Exception("User not found")
        val challenge = user.registrationChallenge ?: throw Exception("No registration challenge found")
        
        val response = PublicKeyCredential.parseRegistrationResponseJson(responseJson)
        
        val registrationResult = relyingParty.finishRegistration(
            FinishRegistrationOptions.builder()
                .request(
                    PublicKeyCredentialCreationOptions.builder()
                        .challenge(challenge)
                        .build()
                )
                .response(response)
                .build()
        )

        if (registrationResult.isSuccess) {
            credentialRepository.addCredential(
                userId = user.id,
                keyId = registrationResult.keyId.id,
                publicKey = registrationResult.publicKeyCose,
                // ... 其他元数据
            )
            // 清除 challenge
            userRepository.clearRegistrationChallenge(user.id)
            return FinishRegistrationResult(success = true)
        }
        return FinishRegistrationResult(success = false, message = "Registration failed")
    }
    
    // ... 登录逻辑 (startLogin/finishLogin) 类似
}

3. Ktor 路由和JWT颁发

// Application.kt
fun Application.module() {
    // ... 插件安装 (ContentNegotiation, Authentication)
    
    // 配置JWT
    val secret = environment.config.property("jwt.secret").getString()
    val issuer = environment.config.property("jwt.issuer").getString()
    val audience = environment.config.property("jwt.audience").getString()
    
    install(Authentication) {
        jwt("auth-jwt") {
            // ... JWT verifier 配置
            verifier(
                JWT
                    .require(Algorithm.HMAC256(secret))
                    .withAudience(audience)
                    .withIssuer(issuer)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != "") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
        }
    }

    routing {
        // ... WebAuthn 注册和登录路由
        post("/login/finish") {
            val loginRequest = call.receive<LoginRequest>()
            // ... 调用 webAuthnServer.finishLogin() ...
            
            if (loginSuccessful) {
                val user = userRepository.findByUsername(loginRequest.username)!!
                val token = JWT.create()
                    .withAudience(audience)
                    .withIssuer(issuer)
                    .withClaim("username", user.username)
                    .withClaim("tenant_id", user.tenantId) // 核心:将 tenantId 放入JWT
                    .withExpiresAt(Date(System.currentTimeMillis() + 60000 * 60 * 24)) // 24小时有效期
                    .sign(Algorithm.HMAC256(secret))
                call.respond(mapOf("token" to token))
            } else {
                call.respond(HttpStatusCode.Unauthorized)
            }
        }

        // 受保护的搜索代理端点
        authenticate("auth-jwt") {
            post("/search") {
                val principal = call.principal<JWTPrincipal>()!!
                val tenantId = principal.payload.getClaim("tenant_id").asString()
                val searchQuery = call.receive<SearchQuery>()
                
                // 在真实项目中,这里应使用 Weaviate 的 Kotlin 客户端
                // 为演示清晰,我们使用 Ktor client 模拟
                val weaviateResponse = httpClient.post("http://weaviate:8080/v1/graphql") {
                    header("Authorization", "Bearer my-secret-api-key")
                    header("X-Tenant", tenantId) // 关键:注入租户ID
                    contentType(ContentType.Application.Json)
                    setBody(buildGraphQLQuery(searchQuery))
                }
                
                call.respond(weaviateResponse.status, weaviateResponse.bodyAsText())
            }
        }
    }
}

这个代理端点是整个架构安全的核心。它将身份验证(JWT)和数据访问策略(X-Tenant头)解耦,客户端对Weaviate的存在一无所知,所有请求都必须经过这个安全网关。

第三步:Jetpack Compose客户端实现

Android端需要处理两件事:复杂的WebAuthn流程和与后端API的安全通信。

1. WebAuthn 客户端逻辑

Google的androidx.credentials库极大地简化了Passkey(WebAuthn)的客户端实现。

// AuthViewModel.kt
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetPublicKeyCredentialOption

class AuthViewModel(
    private val credentialManager: CredentialManager,
    private val authRepository: AuthRepository, // 封装了对Ktor后端的API调用
    private val application: Application
) : AndroidViewModel(application) {

    // ... LiveData/StateFlow for UI state ...

    suspend fun register(username: String) {
        try {
            // 1. 从后端获取 challenge
            val options = authRepository.startRegistration(username)
            
            // 2. 使用 CredentialManager 创建请求
            val request = CreatePublicKeyCredentialRequest(options.publicKeyCredentialCreationOptionsJson)
            
            // 3. 弹出系统UI,让用户进行生物识别
            val result = credentialManager.createCredential(
                context = application.applicationContext,
                request = request
            )
            
            // 4. 将结果发回后端完成注册
            authRepository.finishRegistration(username, result.data.getString("REGISTRATION_RESPONSE"))
            
            // ... 更新UI状态 ...
            
        } catch (e: Exception) {
            // 错误处理,例如用户取消了对话框
            Log.e("AuthViewModel", "Registration failed", e)
        }
    }
    
    suspend fun login() {
        try {
            // 1. 从后端获取登录的 challenge
            val options = authRepository.startLogin()
            
            // 2. 创建请求
            val request = GetPublicKeyCredentialOption(options.publicKeyCredentialRequestOptionsJson)
            
            // 3. 弹出系统UI
            val result = credentialManager.getCredential(
                context = application.applicationContext,
                request = request
            )
            
            // 4. 将签名后的结果发回后端,换取JWT
            val token = authRepository.finishLogin(result.data.getString("AUTHENTICATION_RESPONSE"))
            
            // 5. 持久化存储 JWT (e.g., EncryptedSharedPreferences)
            // ...
            
        } catch (e: Exception) {
            Log.e("AuthViewModel", "Login failed", e)
        }
    }
}

2. 安全的API通信

我们使用Retrofit和OkHttp Interceptor来自动为需要认证的请求添加Authorization头。

// AuthInterceptor.kt
class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenManager.getToken()
        val request = if (token != null) {
            chain.request().newBuilder()
                .header("Authorization", "Bearer $token")
                .build()
        } else {
            chain.request()
        }
        return chain.proceed(request)
    }
}

// ApiClient.kt
object ApiClient {
    private fun provideOkHttpClient(): OkHttpClient {
        val logging = HttpLoggingInterceptor()
        logging.level = HttpLoggingInterceptor.Level.BODY

        return OkHttpClient.Builder()
            .addInterceptor(logging)
            .addInterceptor(AuthInterceptor(TokenManager.getInstance())) // 注入拦截器
            .build()
    }
    
    // ... Retrofit builder ...
}

3. 搜索界面UI

Compose UI部分相对直接。一个TextField用于输入,一个LazyColumn用于展示结果,ViewModel通过StateFlow驱动UI更新。

// SearchScreen.kt
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val searchQuery by viewModel.searchQuery.collectAsState()
    val searchResults by viewModel.searchResults.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { viewModel.onQueryChanged(it) },
            label = { Text("Enter semantic query...") },
            modifier = Modifier.fillMaxWidth()
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        if (isLoading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
        } else {
            LazyColumn(modifier = Modifier.weight(1.0f)) {
                items(searchResults) { result ->
                    SearchResultItem(result)
                }
            }
        }
    }
}

@Composable
fun SearchResultItem(result: SearchResult) {
    Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(text = result.content, style = MaterialTheme.typography.bodyLarge)
            Text(text = "Source: ${result.source}", style = MaterialTheme.typography.bodySmall)
        }
    }
}

// SearchViewModel.kt
class SearchViewModel(private val searchRepository: SearchRepository) : ViewModel() {
    // ... StateFlow for query, results, loading state ...

    fun onQueryChanged(newQuery: String) {
        _searchQuery.value = newQuery
        // 使用debounce避免频繁请求
        viewModelScope.launch {
            delay(300) // Debounce
            if (newQuery.isNotBlank()) {
                _isLoading.value = true
                try {
                    val results = searchRepository.performSearch(newQuery)
                    _searchResults.value = results
                } catch (e: Exception) {
                    // 处理API错误
                    _searchResults.value = emptyList()
                } finally {
                    _isLoading.value = false
                }
            } else {
                _searchResults.value = emptyList()
            }
        }
    }
}

这个方案实现了端到端的安全链路。用户通过设备硬件保护的密钥进行身份验证,获得的JWT包含了其租户身份,后端服务强制执行基于此身份的数据访问策略,最终在原生UI上呈现出隔离且相关的搜索结果。

方案的局限性与未来迭代

尽管这套架构在安全性和隔离性上表现稳健,但它并非没有权衡。

首先,Ktor代理层是一个潜在的性能瓶颈和单点故障。在生产环境中,应将其部署为可水平扩展的服务集群,并置于API网关之后,由网关处理TLS终止、速率限制等通用功能。

其次,WebAuthn的用户体验虽然安全,但其凭证恢复机制仍是一个挑战。如果用户更换设备且未在云端(如Google密码管家)同步密钥,凭证将丢失。需要设计备用的账户恢复流程,例如通过邮箱验证来重新绑定新设备,但这又可能引入新的安全风险,需要谨慎权衡。

再者,Weaviate的多租户功能虽然强大,但在租户数量达到数万甚至更高量级时,元数据的管理和性能可能会成为问题。此时可能需要考虑更复杂的策略,例如将租户分片到不同的Weaviate集群,这会显著增加运维的复杂性。

最后,当前的向量化模型是全局共享的。对于某些专业领域的租户,通用模型可能无法提供最佳的搜索相关性。未来的一个优化方向是探索为特定租户或租户群体微调或替换专用的嵌入模型,以实现更高的搜索质量。


  目录