Skip to content

Commit 33ac64a

Browse files
committed
feat(app): Improve SSL certificate trust management function
1 parent 3d26ace commit 33ac64a

4 files changed

Lines changed: 151 additions & 46 deletions

File tree

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/App.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
3636
import androidx.compose.ui.text.font.FontWeight
3737
import androidx.compose.ui.unit.dp
3838
import androidx.compose.ui.unit.sp
39+
import co.touchlab.kermit.Logger
3940
import coil3.ImageLoader
4041
import coil3.compose.setSingletonImageLoaderFactory
4142
import coil3.disk.DiskCache
@@ -52,6 +53,7 @@ import com.jankinwu.fntv.client.icons.CategoryIcon
5253
import com.jankinwu.fntv.client.icons.Heart
5354
import com.jankinwu.fntv.client.icons.Home
5455
import com.jankinwu.fntv.client.icons.MediaLibrary
56+
import com.jankinwu.fntv.client.manager.LoginStateManager
5557
import com.jankinwu.fntv.client.manager.PlayerResourceManager
5658
import com.jankinwu.fntv.client.manager.UpdateStatus
5759
import com.jankinwu.fntv.client.ui.component.common.ComponentItem
@@ -98,6 +100,8 @@ import javax.net.ssl.SSLContext
98100
import javax.net.ssl.TrustManager
99101
import javax.net.ssl.X509TrustManager
100102

103+
private val logger = Logger.withTag("App")
104+
101105
val components = mutableStateListOf<ComponentItem>()
102106

103107
// 刷新状态数据类
@@ -143,6 +147,10 @@ fun App(
143147
SslTrustDialogHost(
144148
onAllow = {
145149
refreshManager.requestRefresh(refreshState.onRefresh)
150+
},
151+
onReject = {
152+
logger.i("Reject SSL trust")
153+
LoginStateManager.updateLoginStatus(false)
146154
}
147155
)
148156
}

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/data/network/impl/FnOfficialApiImpl.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import com.jankinwu.fntv.client.data.network.fnOfficialClient
5959
import com.jankinwu.fntv.client.data.network.fnOfficialInsecureClient
6060
import com.jankinwu.fntv.client.data.network.impl.FnApiHelper.genAuthxForOfficial
6161
import com.jankinwu.fntv.client.data.store.AccountDataCache
62+
import com.jankinwu.fntv.client.data.store.SslTrustDecision
6263
import com.jankinwu.fntv.client.data.store.SslTrustManager
6364
import io.ktor.client.request.HttpRequestBuilder
6465
import io.ktor.client.request.delete
@@ -556,12 +557,18 @@ class FnOfficialApiImpl : FnOfficialApi {
556557
throw e
557558
}
558559
logger.w { "SSL trust prompt triggered, request: $requestTag, host: $host, error: ${e.message}" }
559-
val allow = SslTrustManager.requestTrust(host)
560-
if (!allow) {
560+
when (SslTrustManager.requestTrust(host)) {
561+
SslTrustDecision.Reject -> {
561562
logger.e { "用户拒绝了证书信任, request: $requestTag, host: $host" }
562563
throw e
563564
}
564-
SslTrustManager.addHostToWhitelist(host)
565+
SslTrustDecision.AllowTemporary -> {
566+
SslTrustManager.addHostToTemporaryWhitelist(host)
567+
}
568+
SslTrustDecision.AllowPersist -> {
569+
SslTrustManager.addHostToWhitelist(host)
570+
}
571+
}
565572
block(fnOfficialInsecureClient)
566573
}
567574
}

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/data/store/AppSettingsStore.kt

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,26 @@ object AppSettingsStore {
126126
set(value) = settings.set(scopedKey("kcef_initialized_version"), value)
127127
}
128128

129+
enum class SslTrustDecision {
130+
Reject,
131+
AllowTemporary,
132+
AllowPersist
133+
}
134+
129135
data class SslTrustPrompt(
130136
val host: String,
131-
val deferred: CompletableDeferred<Boolean>
137+
val deferred: CompletableDeferred<SslTrustDecision>
132138
)
133139

134140
object SslTrustManager {
135141
private val promptMutex = Mutex()
136142
private val _pendingPrompt = MutableStateFlow<SslTrustPrompt?>(null)
137143
val pendingPrompt: StateFlow<SslTrustPrompt?> = _pendingPrompt.asStateFlow()
144+
private val temporaryWhitelist = mutableSetOf<String>()
138145

139146
fun isHostWhitelisted(host: String): Boolean {
140147
val key = normalizeHost(host)
141-
return key.isNotBlank() && AppSettingsStore.sslIgnoreHostWhitelist.contains(key)
148+
return key.isNotBlank() && (temporaryWhitelist.contains(key) || AppSettingsStore.sslIgnoreHostWhitelist.contains(key))
142149
}
143150

144151
fun addHostToWhitelist(host: String) {
@@ -147,17 +154,23 @@ object SslTrustManager {
147154
AppSettingsStore.sslIgnoreHostWhitelist = AppSettingsStore.sslIgnoreHostWhitelist + key
148155
}
149156

150-
suspend fun requestTrust(host: String): Boolean {
157+
fun addHostToTemporaryWhitelist(host: String) {
158+
val key = normalizeHost(host)
159+
if (key.isBlank()) return
160+
temporaryWhitelist.add(key)
161+
}
162+
163+
suspend fun requestTrust(host: String): SslTrustDecision {
151164
val key = normalizeHost(host)
152-
if (key.isBlank()) return false
165+
if (key.isBlank()) return SslTrustDecision.Reject
153166
while (true) {
154167
val prompt = promptMutex.withLock {
155168
val existing = _pendingPrompt.value
156169
if (existing != null && !existing.deferred.isCompleted) {
157170
// Reuse active prompt to avoid duplicate dialogs
158171
return@withLock existing
159172
}
160-
val deferred = CompletableDeferred<Boolean>()
173+
val deferred = CompletableDeferred<SslTrustDecision>()
161174
val next = SslTrustPrompt(key, deferred)
162175
// Publish new prompt for current host
163176
_pendingPrompt.value = next
@@ -176,10 +189,10 @@ object SslTrustManager {
176189
}
177190
}
178191

179-
fun resolvePrompt(allow: Boolean) {
192+
fun resolvePrompt(decision: SslTrustDecision) {
180193
val prompt = _pendingPrompt.value ?: return
181194
if (!prompt.deferred.isCompleted) {
182-
prompt.deferred.complete(allow)
195+
prompt.deferred.complete(decision)
183196
}
184197
_pendingPrompt.value = null
185198
}

composeApp/src/commonMain/kotlin/com/jankinwu/fntv/client/ui/component/common/dialog/Dialog.kt

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp
4141
import androidx.compose.ui.window.Dialog
4242
import com.jankinwu.fntv.client.data.constants.Colors
4343
import com.jankinwu.fntv.client.data.constants.Constants
44+
import com.jankinwu.fntv.client.data.store.SslTrustDecision
4445
import com.jankinwu.fntv.client.data.store.SslTrustManager
4546
import com.jankinwu.fntv.client.icons.SkipLink
4647
import com.jankinwu.fntv.client.icons.Warning
@@ -179,7 +180,9 @@ fun CustomConfirmDialog(
179180
dismissButtonText: String = "取消",
180181
onDismissClick: () -> Unit = {},
181182
confirmButtonText: String,
182-
onConfirmClick: () -> Unit = {}
183+
onConfirmClick: () -> Unit = {},
184+
extraButtonText: String? = null,
185+
onExtraClick: () -> Unit = {}
183186
) {
184187
val store = LocalStore.current
185188
Dialog(onDismissRequest = onDismissRequest) {
@@ -256,6 +259,21 @@ fun CustomConfirmDialog(
256259
) {
257260
Text(confirmButtonText, color = primaryTextColor)
258261
}
262+
if (extraButtonText != null) {
263+
Button(
264+
onClick = {
265+
onExtraClick()
266+
onDismissRequest()
267+
},
268+
shape = RoundedCornerShape(8.dp),
269+
colors = ButtonDefaults.buttonColors(
270+
containerColor = confirmButtonColor
271+
),
272+
modifier = Modifier.weight(1f)
273+
) {
274+
Text(extraButtonText, color = primaryTextColor)
275+
}
276+
}
259277
}
260278
}
261279
}
@@ -269,7 +287,9 @@ fun CustomContentDialog(
269287
content: @Composable () -> Unit,
270288
primaryButtonText: String,
271289
secondaryButtonText: String? = null,
290+
tertiaryButtonText: String? = null,
272291
onButtonClick: (ContentDialogButton) -> Unit,
292+
onTertiaryClick: () -> Unit = {},
273293
size: DialogSize = DialogSize.Standard,
274294
isWarning: Boolean = false
275295
) {
@@ -318,31 +338,77 @@ fun CustomContentDialog(
318338
.padding(horizontal = 25.dp, vertical = 8.dp),
319339
Alignment.CenterEnd
320340
) {
321-
Row(
322-
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
323-
verticalAlignment = Alignment.CenterVertically,
324-
modifier = Modifier.fillMaxWidth()
325-
) {
326-
if (secondaryButtonText != null) Button(
327-
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
328-
onClick = { onButtonClick(ContentDialogButton.Secondary) },
341+
if (tertiaryButtonText != null) {
342+
Row(
343+
horizontalArrangement = Arrangement.SpaceBetween,
344+
verticalAlignment = Alignment.CenterVertically,
345+
modifier = Modifier.fillMaxWidth()
329346
) {
330-
Text(
331-
secondaryButtonText,
332-
style = LocalTypography.current.bodyStrong,
333-
color = FluentTheme.colors.text.text.primary
334-
)
347+
if (secondaryButtonText != null) Button(
348+
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
349+
onClick = { onButtonClick(ContentDialogButton.Secondary) },
350+
) {
351+
Text(
352+
secondaryButtonText,
353+
style = LocalTypography.current.bodyStrong,
354+
color = FluentTheme.colors.text.text.primary
355+
)
356+
}
357+
Row(
358+
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
359+
verticalAlignment = Alignment.CenterVertically,
360+
modifier = Modifier.fillMaxWidth()
361+
) {
362+
Button(
363+
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
364+
onClick = { onButtonClick(ContentDialogButton.Secondary) },
365+
) {
366+
Text(
367+
tertiaryButtonText,
368+
style = LocalTypography.current.bodyStrong,
369+
color = FluentTheme.colors.text.text.primary
370+
)
371+
}
372+
AccentButton(
373+
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
374+
onClick = { onButtonClick(ContentDialogButton.Primary) },
375+
buttonColors = if (isWarning) customDangerButtonColors() else customAccentButtonColors()
376+
) {
377+
Text(
378+
primaryButtonText,
379+
style = LocalTypography.current.bodyStrong,
380+
color = FluentTheme.colors.text.text.primary
381+
)
382+
}
383+
}
335384
}
336-
AccentButton(
337-
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
338-
onClick = { onButtonClick(ContentDialogButton.Primary) },
339-
buttonColors = if (isWarning) customDangerButtonColors() else customAccentButtonColors()
385+
} else {
386+
Row(
387+
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
388+
verticalAlignment = Alignment.CenterVertically,
389+
modifier = Modifier.fillMaxWidth()
340390
) {
341-
Text(
342-
primaryButtonText,
343-
style = LocalTypography.current.bodyStrong,
344-
color = FluentTheme.colors.text.text.primary
345-
)
391+
if (secondaryButtonText != null) Button(
392+
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
393+
onClick = { onButtonClick(ContentDialogButton.Secondary) },
394+
) {
395+
Text(
396+
secondaryButtonText,
397+
style = LocalTypography.current.bodyStrong,
398+
color = FluentTheme.colors.text.text.primary
399+
)
400+
}
401+
AccentButton(
402+
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
403+
onClick = { onButtonClick(ContentDialogButton.Primary) },
404+
buttonColors = if (isWarning) customDangerButtonColors() else customAccentButtonColors()
405+
) {
406+
Text(
407+
primaryButtonText,
408+
style = LocalTypography.current.bodyStrong,
409+
color = FluentTheme.colors.text.text.primary
410+
)
411+
}
346412
}
347413
}
348414
}
@@ -357,22 +423,33 @@ fun SslTrustDialogHost(
357423
) {
358424
val prompt by SslTrustManager.pendingPrompt.collectAsState()
359425
val host = prompt?.host ?: return
360-
CustomConfirmDialog(
361-
onDismissRequest = {
362-
SslTrustManager.resolvePrompt(false)
363-
onReject?.invoke(host)
364-
},
426+
CustomContentDialog(
365427
title = "证书校验失败",
366-
contentText = "$host 的证书校验失败,可能存在安全风险,是否继续连接?",
367-
dismissButtonText = "取消",
368-
onDismissClick = {
369-
SslTrustManager.resolvePrompt(false)
370-
onReject?.invoke(host)
428+
visible = true,
429+
primaryButtonText = "忽略风险,继续访问",
430+
secondaryButtonText = "取消访问",
431+
tertiaryButtonText = "将此 ip 或域名加入白名单",
432+
size = DialogSize.Max,
433+
isWarning = true,
434+
onButtonClick = { button ->
435+
when (button) {
436+
ContentDialogButton.Primary -> {
437+
SslTrustManager.resolvePrompt(SslTrustDecision.AllowTemporary)
438+
onAllow?.invoke(host)
439+
}
440+
ContentDialogButton.Secondary -> {
441+
SslTrustManager.resolvePrompt(SslTrustDecision.Reject)
442+
onReject?.invoke(host)
443+
}
444+
else -> Unit
445+
}
371446
},
372-
confirmButtonText = "忽略风险",
373-
onConfirmClick = {
374-
SslTrustManager.resolvePrompt(true)
447+
onTertiaryClick = {
448+
SslTrustManager.resolvePrompt(SslTrustDecision.AllowPersist)
375449
onAllow?.invoke(host)
450+
},
451+
content = {
452+
Text("$host」的证书校验不通过,可能是证书过期、域名不匹配或自签名证书。继续连接将绕过安全保护,是否继续访问?")
376453
}
377454
)
378455
}

0 commit comments

Comments
 (0)