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,52 +582,49 @@ 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") {
runOnUiThread {
android.widget.Toast.makeText(this, "未找到登录凭证,请先在页面中登录", android.widget.Toast.LENGTH_SHORT).show()
}
return@evaluateJavascript
}
sendJwtToBackend(jwt)
}
} }
Thread { private fun sendJwtToBackend(jwt: String) {
try { sdk.userApi.ssoExchange(jwt) { result ->
val accessToken = exchangeSsoCodeForToken(code)
sdk.userApi.ssoExchange(accessToken) { result ->
result.fold( result.fold(
onSuccess = { ssoResult -> onSuccess = { ssoResult ->
runOnUiThread {
showingSsoWebView = false showingSsoWebView = false
ssoWebView = null ssoWebView = null
ssoPendingToken = ssoResult.token ssoPendingToken = ssoResult.token
@@ -647,54 +634,18 @@ class MainActivity : Activity() {
} else { } else {
saveAuthSession(AuthSession(ssoResult.token, ssoResult.user)) saveAuthSession(AuthSession(ssoResult.token, ssoResult.user))
} }
}
}, },
onFailure = { error -> onFailure = { error ->
runOnUiThread {
showingSsoWebView = false showingSsoWebView = false
ssoWebView = null ssoWebView = null
setRootView() setRootView()
runOnUiThread {
authStatusText?.text = "SSO 登录失败:${error.message}" authStatusText?.text = "SSO 登录失败:${error.message}"
} }
}, },
) )
} }
} catch (e: Exception) {
runOnUiThread {
showingSsoWebView = false
ssoWebView = null
setRootView()
authStatusText?.text = "SSO 登录失败:${e.message}"
}
}
}.start()
}
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 createPhoneBindingView(): View { private fun createPhoneBindingView(): View {

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,