From b335430c1876ecad8055e90e685c2058cf809382 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 31 Aug 2026 17:39:29 +0800 Subject: [PATCH] simplify SSO: read JWT from localStorage, bypass OAuth2 authorize flow entirely --- .../oraltrainer/sample/MainActivity.kt | 130 +++++------------- sentence_api/main.py | 42 +++--- 2 files changed, 63 insertions(+), 109 deletions(-) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index 7ab1196..8569226 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -566,17 +566,6 @@ class MainActivity : Activity() { @Suppress("SetJavaScriptEnabled") private fun createSsoWebView(): View { - ssoPhase = 0 - - val authorizeUrl = Uri.parse(SSO_AUTHORIZE_URL).buildUpon() - .appendQueryParameter("client_id", SSO_CLIENT_ID) - .appendQueryParameter("redirect_uri", SSO_REDIRECT_URI) - .appendQueryParameter("response_type", "code") - .appendQueryParameter("scope", SSO_SCOPE) - .appendQueryParameter("state", "oral-trainer") - .build() - .toString() - val container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(COLOR_BACKGROUND) @@ -597,108 +586,65 @@ class MainActivity : Activity() { ssoWebView = WebView(this).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val url = request?.url ?: return false - if (ssoPhase >= 1 && url.toString().startsWith(SSO_REDIRECT_URI)) { - val code = url.getQueryParameter("code") - handleSsoCallback(code) - return true - } - return false - } - } - // 阶段 1:直接打开第一课堂登录页(绕过授权接口的错误重定向) loadUrl(SSO_LOGIN_URL) } container.addView(ssoWebView, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f )) - // ── 底部:登录完成后手动点继续 ── + // ── 底部:登录完成后点继续,读 JWT 发给后端 ── container.addView(Button(this).apply { - text = "已登录,点击继续授权" + text = "已登录,点击继续" background = rounded(COLOR_ACCENT, 8f) setTextColor(Color.WHITE) setOnClickListener { - ssoPhase = 1 - ssoWebView?.loadUrl(authorizeUrl) + readJwtAndLogin() } layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(8.dp, 8.dp, 8.dp, 8.dp) }) return container } - private fun handleSsoCallback(code: String?) { - if (code.isNullOrBlank()) { - showingSsoWebView = false - ssoWebView = null - setRootView() - return - } - - Thread { - try { - val accessToken = exchangeSsoCodeForToken(code) - sdk.userApi.ssoExchange(accessToken) { result -> - result.fold( - onSuccess = { ssoResult -> - showingSsoWebView = false - ssoWebView = null - ssoPendingToken = ssoResult.token - if (!ssoResult.phoneBound) { - showingPhoneBinding = true - setRootView() - } else { - saveAuthSession(AuthSession(ssoResult.token, ssoResult.user)) - } - }, - onFailure = { error -> - showingSsoWebView = false - ssoWebView = null - setRootView() - runOnUiThread { - authStatusText?.text = "SSO 登录失败:${error.message}" - } - }, - ) - } - } catch (e: Exception) { + private fun readJwtAndLogin() { + ssoWebView?.evaluateJavascript( + "(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()" + ) { result -> + val jwt = result?.removeSurrounding("\"").orEmpty() + if (jwt.isBlank() || jwt == "null") { runOnUiThread { - showingSsoWebView = false - ssoWebView = null - setRootView() - authStatusText?.text = "SSO 登录失败:${e.message}" + android.widget.Toast.makeText(this, "未找到登录凭证,请先在页面中登录", android.widget.Toast.LENGTH_SHORT).show() } + return@evaluateJavascript } - }.start() + sendJwtToBackend(jwt) + } } - private fun exchangeSsoCodeForToken(code: String): String { - val url = URL(SSO_TOKEN_URL) - val connection = url.openConnection() as HttpURLConnection - try { - connection.requestMethod = "POST" - connection.doOutput = true - connection.connectTimeout = 15_000 - connection.readTimeout = 15_000 - connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") - - val body = "grant_type=authorization_code" + - "&code=$code" + - "&client_id=$SSO_CLIENT_ID" + - "&client_secret=$SSO_CLIENT_SECRET" + - "&redirect_uri=${Uri.encode(SSO_REDIRECT_URI)}" - connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - - val statusCode = connection.responseCode - val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream - val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() - if (statusCode !in 200..299) throw RuntimeException("Token exchange failed: HTTP $statusCode $text") - val json = JSONObject(text) - return json.getString("access_token") - } finally { - connection.disconnect() + private fun sendJwtToBackend(jwt: String) { + sdk.userApi.ssoExchange(jwt) { result -> + result.fold( + onSuccess = { ssoResult -> + runOnUiThread { + showingSsoWebView = false + ssoWebView = null + ssoPendingToken = ssoResult.token + if (!ssoResult.phoneBound) { + showingPhoneBinding = true + setRootView() + } else { + saveAuthSession(AuthSession(ssoResult.token, ssoResult.user)) + } + } + }, + onFailure = { error -> + runOnUiThread { + showingSsoWebView = false + ssoWebView = null + setRootView() + authStatusText?.text = "SSO 登录失败:${error.message}" + } + }, + ) } } diff --git a/sentence_api/main.py b/sentence_api/main.py index 287c0fc..73977c9 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -7,6 +7,7 @@ import subprocess import uuid import urllib.request import urllib.error +import base64 from pathlib import Path from typing import Any, BinaryIO, Dict, List, Optional import json @@ -206,36 +207,43 @@ def create_app( video_repository.delete_session(token) return Response(status_code=204) - D1KT_USERINFO_URL = "https://d1kt.cn/api/oauth2/userinfo" - @application.post("/api/v1/auth/sso/exchange", response_model=SsoExchangeResponse) def sso_exchange(payload: SsoExchangeRequest) -> SsoExchangeResponse: - # Call the 第一课堂 userinfo endpoint to validate the access_token. - req = urllib.request.Request( - D1KT_USERINFO_URL, - headers={"Authorization": f"Bearer {payload.access_token}"}, - ) + # Decode the JWT payload from 第一课堂 (without signature verification — + # the token was obtained directly from the 第一课堂 login page in a WebView). try: - with urllib.request.urlopen(req, timeout=10) as resp: - userinfo = json.loads(resp.read().decode("utf-8")) - except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + parts = payload.access_token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT format") + # Base64url decode the payload (second part) + padding = len(parts[1]) % 4 + if padding: + parts[1] += "=" * (4 - padding) + decoded_bytes = base64.urlsafe_b64decode(parts[1]) + userinfo = json.loads(decoded_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError, IndexError) as exc: raise HTTPException( status_code=401, - detail=f"第一课堂 access_token validation failed: {exc}", + detail=f"Failed to decode 第一课堂 JWT token: {exc}", ) - d1kt_username = userinfo.get("username") or userinfo.get("name") or "" - if not d1kt_username: - raise HTTPException(status_code=401, detail="第一课堂 userinfo did not contain a username.") + # Check if the token is expired + import time + exp = userinfo.get("exp") + if exp and time.time() > exp: + raise HTTPException(status_code=401, detail="第一课堂 token has expired.") - fullname = userinfo.get("fullname") or userinfo.get("lastname") or d1kt_username + d1kt_username = userinfo.get("username") or "" + if not d1kt_username: + raise HTTPException(status_code=401, detail="第一课堂 token did not contain a username.") + + fullname = userinfo.get("fullname") or d1kt_username email = userinfo.get("email") or "" - # Try to find an existing local user with the same username. + # Find or create a local user with the same username. existing = video_repository.get_user_by_username(d1kt_username) if existing is None: - # Create a new SSO-linked user. user = video_repository.create_sso_user( username=d1kt_username, nickname=fullname,