Untitled

 avatar
unknown
kotlin
a year ago
12 kB
13
Indexable
package iit.trustx.esia_auth.desktop

import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel import javafx.application.Platform import javafx.embed.swing.JFXPanel import javafx.scene.Scene import javafx.scene.web.WebEngine import javafx.scene.web.WebView import java.net.CookieManager as JNetCookieManager import java.net.CookiePolicy import java.net.URI import java.util.concurrent.locks.ReentrantLock import javax.swing.JPanel import kotlin.concurrent.withLock import iit.trustlib.web.HTTPClient import iit.trustlib.web.HTTPInput import iit.trustlib.web.HTTPOutput import iit.trustlib.web.HTTPResult import iit.trustlib.kotlin.valueOrNull import iit.trustlib.helper.tryOrNull import iit.trustaux.helper.decodeFromJson import iit.trustaux.helper.httpHeaderContentType

/**

Полный перенос Android-класса EsiaAuthView в Compose Desktop (JavaFX WebView).

ВНИМАНИЕ: JavaFX WebView НЕ поддерживает перехват сетевых запросов на уровне движка,

как это делал Android WebView через shouldInterceptRequest. Поэтому участки логики,

которые в Android завязаны на проксирование HTTP (через HTTPClient) реализованы

максимально близко с помощью JS-инъекций и бриджа. В местах, где это технически

невозможно, оставлены пометки TODO с аккуратной деградацией поведения. */ class EsiaAuthViewDesktop( private val client: HTTPClient, private val onLog: (String) -> Unit = {} ) { // --- Публичные обработчики/колбэки (аналог полей Android-класса) --- var ebsAuthHandler: ((String) -> Unit)? = null var esiaUnavailableHandler: (() -> Unit)? = null var navigationHandler: ((String) -> Unit)? = null

// --- Служебные поля авторизации --- private val lock = ReentrantLock() private var authorizationUrl: String? = null private var authorizationCompletion: ((String?) -> Unit)? = null private var authorizationUri: URI? = null private var authorizationRedirectUri: URI? = null private var authorizationWasFirstRequest = false private var ebsAuthenticationActive = false

// --- JavaFX --- private var engine: WebEngine? = null private var lastInternalUrl: String? = null

// Установим системный CookieManager для JavaFX HTTP-стека init { val cm = JNetCookieManager() cm.setCookiePolicy(CookiePolicy.ACCEPT_ALL) java.net.CookieHandler.setDefault(cm) }

// --- Публичный API (аналог Android) --- fun beginAuthorization(url: String?, completion: (String?) -> Unit) { authorizationUrl = url authorizationCompletion = completion reloadAuthorization(clearCookies = true) }

// --- Внутреннее управление авторизацией --- private fun completeAuthorization(credentialsUrl: String? = null) { val completion = authorizationCompletion authorizationUrl = null authorizationCompletion = null reloadAuthorization(clearCookies = false) completion?.invoke(credentialsUrl) }

private fun reloadAuthorization(clearCookies: Boolean) = lock.withLock { authorizationUri = null authorizationRedirectUri = null authorizationWasFirstRequest = false ebsAuthenticationActive = false

if (clearCookies) {
     tryOrNull { (java.net.CookieHandler.getDefault() as? JNetCookieManager)?.cookieStore?.removeAll() }
 }

 authorizationUri = authorizationUrl?.let { tryOrNull { URI(it) } }
 authorizationRedirectUri = authorizationUri?.let { it.getQueryRedirectUri() }
 val target = authorizationUri?.toString() ?: return@withLock
 onLog("Begin authorization with:\n$target")
 Platform.runLater { engine?.load(target) }

}

private fun handleEbsAuth(ebsUrl: String) { authorizationUri ?: return val handler = ebsAuthHandler ?: return check(!ebsAuthenticationActive) ebsAuthenticationActive = true handler(ebsUrl) }

// --- JS-инъекции (аналог Android INTERCEPT_BODY_SCRIPT / ASSEMBLE_FORM_BODY_SCRIPT) --- private val INTERCEPT_BODY_SCRIPT = """ (function() { try { if (!window.__bodyHookInstalled) { window.__bodyHookInstalled = true; var _setBody = function(method, url, body) { try { if (window.bodyHandler && window.bodyHandler.setBody) { window.bodyHandler.setBody(method, url, body ? body.toString() : ''); } } catch (e) {} }; var origOpen = XMLHttpRequest.prototype.open; var origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__reqMethod = method; this.__reqUrl = url; return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { try { if (body) _setBody(this.__reqMethod, this.__reqUrl, body); } catch (e) {} return origSend.apply(this, arguments); }; } } catch (e) {} })(); """.trimIndent()

private val ASSEMBLE_FORM_BODY_SCRIPT = """ (function() { if (document.forms.length === 0) { return ''; } let form = document.forms[0].elements; let body = ''; for (let i = 0; i < form.length; ++i) { let item = form[i]; if (item.name === '') { continue; } if (item.name === 'denyAuthorization') { continue; } if (body !== '') { body += '&'; } let value = encodeURIComponent(item.value).replace('%20', '+'); body += item.name + '=' + value; } return body; })(); """.trimIndent()

// --- JS bridge для передачи тел запросов из JS в JVM (упрощённый BodyHandler) --- inner class BodyHandler { private val bodies: MutableMap<String, String> = mutableMapOf() @javafx.beans.DefaultProperty("value") fun setBody(method: String?, url: String?, body: String?) { if (method == null || url == null || body == null) return bodies["${'$'}method ${'$'}url"] = body } fun popBody(method: String, url: String): ByteArray? = bodies.remove("${'$'}method ${'$'}url")?.toByteArray(Charsets.UTF_8) fun clear() = bodies.clear() } private val bodyHandler = BodyHandler()

// --- Привязка к JavaFX WebEngine --- fun attach(engine: WebEngine) { this.engine = engine // userAgent + IDPoint метка engine.userAgent = (engine.userAgent ?: "Mozilla/5.0") + " IDPoint"

// JS bridge
 val win = engine.executeScript("window") as netscape.javascript.JSObject
 win.setMember("bodyHandler", bodyHandler)

 // Навигация/редиректы/ошибки
 engine.locationProperty().addListener { _, oldUrl, newUrl ->
     if (newUrl == null) return@addListener
     onLog("Navigate to: ${'$'}newUrl")
     lock.withLock {
         // Детект завершения авторизации
         val redir = authorizationRedirectUri?.toString()
         if (redir != null && newUrl.startsWith(redir)) {
             onLog("Authorization finish with:\n${'$'}newUrl")
             completeAuthorization(newUrl)
             return@addListener
         }
         // Истечение сессии (старый и новый кейсы)
         if (URI(newUrl).host == LK_GOSUSLUGI_HOST || URI(newUrl).path.isNullOrEmpty() || URI(newUrl).path == "/") {
             onLog("Authorization session expired")
             completeAuthorization(null)
             return@addListener
         }
         // Внешняя навигация
         val authHost = authorizationUri?.host
         if (authHost != null && URI(newUrl).host != authHost) {
             navigationHandler?.invoke(newUrl)
             // Попробуем вернуть предыдущую страницу, чтобы остаться в потоке ЕСИА
             lastInternalUrl?.let { safe -> Platform.runLater { engine.load(safe) } }
             return@addListener
         }
         lastInternalUrl = newUrl
     }
 }

 engine.loadWorker.exceptionProperty().addListener { _, _, ex ->
     if (ex != null) {
         onLog("Error: ${'$'}{ex.message}")
         // Сообщаем о недоступности ЕСИА только на первом запросе (как в Android)
         val first = lock.withLock {
             if (authorizationWasFirstRequest) false else { authorizationWasFirstRequest = true; true }
         }
         if (first) esiaUnavailableHandler?.invoke()
     }
 }

 // В каждый новый документ внедряем перехватчик тел
 engine.documentProperty().addListener { _, _, _ ->
     tryOrNull { engine.executeScript(INTERCEPT_BODY_SCRIPT) }
 }

}

// --- Проксирование запросов (приближение к Android shouldInterceptRequest) --- // В JavaFX нет API для подмены ответа сети. Мы покрываем XHR/Fetch и формы через JS и бридж: // - для POST-форм собираем тело и можем выполнять запрос через HTTPClient вручную. // - при получении redirect (3xx) принудительно выполняем engine.load(redirect). // Пример вызова (можно использовать из JS через window.javaBridgeTransfer): fun transferViaClient(method: String, url: String, extraHeaders: Map<String, String> = emptyMap(), formBodyIfAny: ByteArray? = null): HTTPOutput? { val input = HTTPInput( method = method, url = url, headers = extraHeaders, data = formBodyIfAny ?: bodyHandler.popBody(method, url) ) var result: HTTPResult? = null val latch = java.util.concurrent.CountDownLatch(1) client.transfer(input) { result = it latch.countDown() } latch.await()

val first = lock.withLock {
     if (authorizationWasFirstRequest) false else { authorizationWasFirstRequest = true; true }
 }
 if (first && result?.valueOrNull == null) {
     esiaUnavailableHandler?.invoke()
 }

 return result?.valueOrNull

}

// Вспомогательные private fun URI.getQueryRedirectUri(): URI? = tryOrNull { val q = (this.rawQuery ?: return@tryOrNull null) val pairs = q.split('&').mapNotNull { val i = it.indexOf('='); if (i <= 0) null else it.substring(0, i) to it.substring(i + 1) }.toMap() val ru = pairs["redirect_uri"] ?: return@tryOrNull null URI(java.net.URLDecoder.decode(ru, "UTF-8")) }

private fun HTTPOutput.getEbsAuthRedirectURL(): String? { headers.httpHeaderContentType?.takeIf { it.mime == "application/json" && it.charset == "UTF-8" } ?: return null val responseString = data?.toString(Charsets.UTF_8)?.takeIf { it.contains("ENTER_MFA") } ?: return null val response = tryOrNull { responseString.decodeFromJson() as? Map<*, > } ?: return null if (response["action"] != "ENTER_MFA") return null val details = (response["mfa_details"] as? Map<, >)?.takeIf { it["type"] == "EBS" } ?: return null val ebsDetails = details["ebs_details"] as? Map<, *> ?: return null return ebsDetails["redirect_url"] as? String }

companion object { private const val LK_GOSUSLUGI_HOST = "lk.gosuslugi.ru" } }


// --- Composable-контейнер на весь экран --- @Composable fun EsiaAuthScreen( controller: EsiaAuthViewDesktop, startUrl: String? = null ) { var jfxPanel by remember { mutableStateOf<JFXPanel?>(null) }

SwingPanel(
    modifier = Modifier.fillMaxSize(),
    factory = {
        val panel = JPanel()
        val fxPanel = JFXPanel()
        panel.layout = java.awt.BorderLayout()
        panel.add(fxPanel, java.awt.BorderLayout.CENTER)
        jfxPanel = fxPanel
        panel
    },
    update = {
        val fx = jfxPanel ?: return@SwingPanel
        Platform.runLater {
            val webView = WebView()
            val engine = webView.engine
            controller.attach(engine)
            fx.scene = Scene(webView)
            if (startUrl != null) engine.load(startUrl)
        }
    }
)

}

Editor is loading...
Leave a Comment