simplify SSO: read JWT from localStorage, bypass OAuth2 authorize flow entirely
This commit is contained in:
@@ -566,17 +566,6 @@ 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)
|
||||||
@@ -597,52 +586,45 @@ class MainActivity : Activity() {
|
|||||||
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 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 {
|
container.addView(Button(this).apply {
|
||||||
text = "已登录,点击继续授权"
|
text = "已登录,点击继续"
|
||||||
background = rounded(COLOR_ACCENT, 8f)
|
background = rounded(COLOR_ACCENT, 8f)
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
ssoPhase = 1
|
readJwtAndLogin()
|
||||||
ssoWebView?.loadUrl(authorizeUrl)
|
|
||||||
}
|
}
|
||||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(8.dp, 8.dp, 8.dp, 8.dp)
|
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
|
||||||
@@ -652,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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user