Compare commits

...

2 Commits

2 changed files with 73 additions and 114 deletions

View File

@@ -566,22 +566,12 @@ class MainActivity : Activity() {
@Suppress("SetJavaScriptEnabled") @Suppress("SetJavaScriptEnabled")
private fun createSsoWebView(): View { 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 { val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setBackgroundColor(COLOR_BACKGROUND) setBackgroundColor(COLOR_BACKGROUND)
} }
// ── 顶部:取消按钮 ──
container.addView(Button(this).apply { container.addView(Button(this).apply {
text = "取消登录" text = "取消登录"
setOnClickListener { setOnClickListener {
@@ -592,108 +582,69 @@ class MainActivity : Activity() {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp)
}) })
// ── 中间:WebView ──
ssoWebView = WebView(this).apply { ssoWebView = WebView(this).apply {
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
settings.domStorageEnabled = true settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (ssoPhase == 0 && url != null && !url.contains("/login")) {
// 用户已登录(离开了登录页),现在加载授权页
ssoPhase = 1
view?.post { view.loadUrl(authorizeUrl) }
}
}
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) loadUrl(SSO_LOGIN_URL)
} }
container.addView(ssoWebView, LinearLayout.LayoutParams( container.addView(ssoWebView, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
)) ))
// ── 底部:登录完成后点继续,读 JWT 发给后端 ──
container.addView(Button(this).apply {
text = "已登录,点击继续"
background = rounded(COLOR_ACCENT, 8f)
setTextColor(Color.WHITE)
setOnClickListener {
readJwtAndLogin()
}
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(8.dp, 8.dp, 8.dp, 8.dp)
})
return container return container
} }
private fun handleSsoCallback(code: String?) { private fun readJwtAndLogin() {
if (code.isNullOrBlank()) { ssoWebView?.evaluateJavascript(
showingSsoWebView = false "(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()"
ssoWebView = null ) { result ->
setRootView() val jwt = result?.removeSurrounding("\"").orEmpty()
return if (jwt.isBlank() || jwt == "null") {
}
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) {
runOnUiThread { runOnUiThread {
showingSsoWebView = false android.widget.Toast.makeText(this, "未找到登录凭证,请先在页面中登录", android.widget.Toast.LENGTH_SHORT).show()
ssoWebView = null
setRootView()
authStatusText?.text = "SSO 登录失败:${e.message}"
} }
return@evaluateJavascript
} }
}.start() sendJwtToBackend(jwt)
}
} }
private fun exchangeSsoCodeForToken(code: String): String { private fun sendJwtToBackend(jwt: String) {
val url = URL(SSO_TOKEN_URL) sdk.userApi.ssoExchange(jwt) { result ->
val connection = url.openConnection() as HttpURLConnection result.fold(
try { onSuccess = { ssoResult ->
connection.requestMethod = "POST" runOnUiThread {
connection.doOutput = true showingSsoWebView = false
connection.connectTimeout = 15_000 ssoWebView = null
connection.readTimeout = 15_000 ssoPendingToken = ssoResult.token
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") if (!ssoResult.phoneBound) {
showingPhoneBinding = true
val body = "grant_type=authorization_code" + setRootView()
"&code=$code" + } else {
"&client_id=$SSO_CLIENT_ID" + saveAuthSession(AuthSession(ssoResult.token, ssoResult.user))
"&client_secret=$SSO_CLIENT_SECRET" + }
"&redirect_uri=${Uri.encode(SSO_REDIRECT_URI)}" }
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } },
onFailure = { error ->
val statusCode = connection.responseCode runOnUiThread {
val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream showingSsoWebView = false
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() ssoWebView = null
if (statusCode !in 200..299) throw RuntimeException("Token exchange failed: HTTP $statusCode $text") setRootView()
val json = JSONObject(text) authStatusText?.text = "SSO 登录失败:${error.message}"
return json.getString("access_token") }
} finally { },
connection.disconnect() )
} }
} }

View File

@@ -7,6 +7,7 @@ import subprocess
import uuid import uuid
import urllib.request import urllib.request
import urllib.error import urllib.error
import base64
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, List, Optional from typing import Any, BinaryIO, Dict, List, Optional
import json import json
@@ -206,36 +207,43 @@ def create_app(
video_repository.delete_session(token) video_repository.delete_session(token)
return Response(status_code=204) return Response(status_code=204)
D1KT_USERINFO_URL = "https://d1kt.cn/api/oauth2/userinfo"
@application.post("/api/v1/auth/sso/exchange", response_model=SsoExchangeResponse) @application.post("/api/v1/auth/sso/exchange", response_model=SsoExchangeResponse)
def sso_exchange(payload: SsoExchangeRequest) -> SsoExchangeResponse: def sso_exchange(payload: SsoExchangeRequest) -> SsoExchangeResponse:
# Call the 第一课堂 userinfo endpoint to validate the access_token. # Decode the JWT payload from 第一课堂 (without signature verification —
req = urllib.request.Request( # the token was obtained directly from the 第一课堂 login page in a WebView).
D1KT_USERINFO_URL,
headers={"Authorization": f"Bearer {payload.access_token}"},
)
try: try:
with urllib.request.urlopen(req, timeout=10) as resp: parts = payload.access_token.split(".")
userinfo = json.loads(resp.read().decode("utf-8")) if len(parts) != 3:
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: 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( raise HTTPException(
status_code=401, 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 "" # Check if the token is expired
if not d1kt_username: import time
raise HTTPException(status_code=401, detail="第一课堂 userinfo did not contain a username.") 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 "" 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) existing = video_repository.get_user_by_username(d1kt_username)
if existing is None: if existing is None:
# Create a new SSO-linked user.
user = video_repository.create_sso_user( user = video_repository.create_sso_user(
username=d1kt_username, username=d1kt_username,
nickname=fullname, nickname=fullname,