From daa73e2b981658641b4643fe2e8dbed51757d010 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 31 Aug 2026 16:50:49 +0800 Subject: [PATCH] add d1kt SSO integration: OAuth2 login via WebView + phone binding --- .../cn/learningpad/oraltrainer/sdk/UserApi.kt | 39 +++ .../oraltrainer/sample/MainActivity.kt | 232 ++++++++++++++++++ scripts/register_oral_trainer_oauth_client.js | 60 +++++ sentence_api/main.py | 67 +++++ sentence_api/models.py | 15 ++ sentence_api/repository.py | 41 ++++ 6 files changed, 454 insertions(+) create mode 100644 scripts/register_oral_trainer_oauth_client.js diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt index 8f85044..d6841fc 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt @@ -109,6 +109,24 @@ class UserApi internal constructor( onComplete: (Result) -> Unit, ): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) } + fun ssoExchange( + accessToken: String, + onComplete: (Result) -> Unit, + ): CancellableRequest = submit(onComplete) { + val root = JSONObject().put("access_token", accessToken) + val response = request("api/v1/auth/sso/exchange", body = root.toString(), method = "POST") + parseSsoExchangeResult(JSONObject(response)) + } + + fun ssoBindPhone( + token: String, + phone: String, + onComplete: (Result) -> Unit, + ): CancellableRequest = submitUnit(onComplete, token) { + val root = JSONObject().put("phone", phone) + request("api/v1/auth/sso/bind-phone", token = token, body = root.toString(), method = "POST") + } + fun courses( token: String, onComplete: (Result>) -> Unit, @@ -279,4 +297,25 @@ class UserApi internal constructor( ), ) } + + private fun parseSsoExchangeResult(root: JSONObject): SsoExchangeResult { + val user = root.getJSONObject("user") + return SsoExchangeResult( + token = root.getString("token"), + user = AuthUser( + id = user.getString("id"), + username = user.getString("username"), + nickname = user.getString("nickname"), + ), + phoneBound = root.optBoolean("phone_bound", false), + isNewUser = root.optBoolean("is_new_user", false), + ) + } } + +data class SsoExchangeResult( + val token: String, + val user: AuthUser, + val phoneBound: Boolean, + val isNewUser: Boolean, +) 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 9b53a3b..cbe3317 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 @@ -46,6 +46,7 @@ import android.widget.Toast import cn.learningpad.oraltrainer.sdk.GestureEvent import cn.learningpad.oraltrainer.sdk.GestureKind import cn.learningpad.oraltrainer.sdk.AuthSession +import cn.learningpad.oraltrainer.sdk.AuthUser import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult import cn.learningpad.oraltrainer.sdk.LoopMode @@ -69,6 +70,9 @@ import java.net.URL import java.nio.ByteBuffer import java.util.Locale import org.json.JSONObject +import android.webkit.WebView +import android.webkit.WebViewClient +import android.webkit.WebResourceRequest import androidx.core.content.FileProvider import kotlin.math.max import androidx.media3.common.MediaItem @@ -122,6 +126,10 @@ class MainActivity : Activity() { private var regPhoneInput: EditText? = null private var regStatusText: TextView? = null private var showingRegisterPage = false + private var showingSsoWebView = false + private var showingPhoneBinding = false + private var ssoPendingToken: String? = null + private var ssoWebView: WebView? = null private var myContentList: LinearLayout? = null private var sentenceBoundaryRequestGeneration = 0 private var catalogVideos: List = emptyList() @@ -331,6 +339,8 @@ class MainActivity : Activity() { setContentView( when { isLoggedIn() -> createContentView() + showingSsoWebView -> createSsoWebView() + showingPhoneBinding -> createPhoneBindingView() showingRegisterPage -> createRegisterView() else -> createAuthView() } @@ -395,6 +405,23 @@ class MainActivity : Activity() { } layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) }) + addView(TextView(this@MainActivity).apply { + text = "── 或 ──" + textSize = 13f + setTextColor(COLOR_TEXT_MUTED) + gravity = Gravity.CENTER + setPadding(0, 12.dp, 0, 12.dp) + }) + addView(Button(this@MainActivity).apply { + text = "第一课堂一键登录" + background = rounded(COLOR_BUTTON, 8f) + setTextColor(Color.WHITE) + setOnClickListener { + showingSsoWebView = true + setRootView() + } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) + }) addView(authStatusText) } } @@ -534,6 +561,203 @@ class MainActivity : Activity() { else sdk.userApi.login(username, password, callback) } + // ─── 第一课堂 SSO ──────────────────────────────────────────── + + @Suppress("SetJavaScriptEnabled") + private fun createSsoWebView(): View { + 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) + } + + container.addView(Button(this).apply { + text = "取消登录" + setOnClickListener { + showingSsoWebView = false + ssoWebView = null + setRootView() + } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp) + }) + + 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 (url.scheme == "oraltrainer" && url.host == "oauth") { + val code = url.getQueryParameter("code") + handleSsoCallback(code) + return true + } + return false + } + } + loadUrl(authorizeUrl) + } + container.addView(ssoWebView, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f + )) + 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) { + 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 { + return ScrollView(this).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundColor(COLOR_BACKGROUND) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(28.dp, 60.dp, 28.dp, 28.dp) + + addView(TextView(this@MainActivity).apply { + text = "绑定手机号" + textSize = 28f + typeface = Typeface.DEFAULT_BOLD + setTextColor(Color.WHITE) + }) + addView(TextView(this@MainActivity).apply { + text = "已通过第一课堂登录,请绑定手机号后继续" + textSize = 14f + setTextColor(COLOR_TEXT_MUTED) + setPadding(0, 6.dp, 0, 24.dp) + }) + + addView(fieldLabel("手机号")) + val phoneInput = EditText(this@MainActivity).apply { + hint = "请输入 11 位手机号" + inputType = android.text.InputType.TYPE_CLASS_PHONE + setSingleLine(true) + } + addView(phoneInput) + + val bindStatusText = TextView(this@MainActivity).apply { + setTextColor(COLOR_ACCENT_LIGHT) + textSize = 13f + setPadding(0, 14.dp, 0, 0) + } + + addView(Button(this@MainActivity).apply { + text = "绑定并继续" + background = rounded(COLOR_ACCENT, 8f) + setTextColor(Color.WHITE) + setOnClickListener { + val phone = phoneInput.text?.toString()?.trim().orEmpty() + if (!Regex("^1[3-9]\\d{9}$").matches(phone)) { + bindStatusText.text = "请输入有效的 11 位手机号" + return@setOnClickListener + } + val token = ssoPendingToken + if (token.isNullOrBlank()) { + bindStatusText.text = "登录状态已过期,请重新登录" + return@setOnClickListener + } + bindStatusText.text = "绑定中..." + sdk.userApi.ssoBindPhone(token, phone) { result -> + result.fold( + onSuccess = { + showingPhoneBinding = false + ssoPendingToken = null + saveAuthSession(AuthSession(token, AuthUser("", "", ""))) + setRootView() + loadCatalog() + }, + onFailure = { error -> + bindStatusText.text = "绑定失败:${error.message}" + }, + ) + } + } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 0) + }) + addView(bindStatusText) + }) + } + } + private fun saveAuthSession(session: AuthSession) { authToken = session.token authNickname = session.user.nickname @@ -2222,6 +2446,14 @@ class MainActivity : Activity() { // 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。 const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220" + // 第一课堂 OAuth2 SSO 配置 + const val SSO_AUTHORIZE_URL = "https://d1kt.cn/api/oauth2/authorize" + const val SSO_TOKEN_URL = "https://d1kt.cn/api/oauth2/token" + const val SSO_CLIENT_ID = "oral-trainer-app" + const val SSO_CLIENT_SECRET = "oral-trainer-secret-2024" + const val SSO_REDIRECT_URI = "oraltrainer://oauth/callback" + const val SSO_SCOPE = "openid profile email username" + const val PICK_VIDEO_REQUEST = 1001 const val RECORD_AUDIO_REQUEST = 1002 const val PROGRESS_MAX = 1000 diff --git a/scripts/register_oral_trainer_oauth_client.js b/scripts/register_oral_trainer_oauth_client.js new file mode 100644 index 0000000..9d5e994 --- /dev/null +++ b/scripts/register_oral_trainer_oauth_client.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * 在第一课堂 MongoDB 中注册口语训练 App 的 OAuth2 客户端。 + * 在第一课堂服务器上运行: + * cd /path/to/typing_practiceweb + * MONGODB_URI= node scripts/register_oral_trainer_oauth_client.js + */ +const mongoose = require('mongoose'); + +const CLIENT_ID = 'oral-trainer-app'; +const CLIENT_SECRET = 'oral-trainer-secret-2024'; +const CLIENT_NAME = '口语训练 App'; + +async function main() { + const uri = process.env.MONGODB_URI; + if (!uri) { + console.error('MONGODB_URI is required.'); + process.exit(1); + } + + await mongoose.connect(uri); + + const db = mongoose.connection.db; + const collection = db.collection('oauth2clients'); + + const existing = await collection.findOne({ clientId: CLIENT_ID }); + if (existing) { + console.log(`Client "${CLIENT_ID}" already exists. Updating redirect URIs...`); + await collection.updateOne( + { clientId: CLIENT_ID }, + { $set: { clientSecret: CLIENT_SECRET, name: CLIENT_NAME } } + ); + } else { + console.log(`Creating OAuth2 client "${CLIENT_ID}"...`); + await collection.insertOne({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + name: CLIENT_NAME, + redirectUris: [ + 'oraltrainer://oauth/callback', + ], + grants: ['authorization_code'], + scope: ['openid', 'profile', 'email', 'firstname', 'lastname', 'username'], + createdAt: new Date(), + linkedUsers: [], + }); + } + + console.log('Done. Client config:'); + console.log(` client_id: ${CLIENT_ID}`); + console.log(` client_secret: ${CLIENT_SECRET}`); + console.log(` redirect_uri: oraltrainer://oauth/callback`); + + await mongoose.disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sentence_api/main.py b/sentence_api/main.py index 4961e0a..287c0fc 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -5,6 +5,8 @@ import mimetypes import shutil import subprocess import uuid +import urllib.request +import urllib.error from pathlib import Path from typing import Any, BinaryIO, Dict, List, Optional import json @@ -41,6 +43,9 @@ from .models import ( SentenceBoundaryDocument, SentenceTextUpdate, UserDubShareSummary, + SsoExchangeRequest, + SsoExchangeResponse, + SsoBindPhoneRequest, UserPublic, UserResultSummary, UserAdminListResponse, @@ -201,6 +206,68 @@ 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}"}, + ) + 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: + raise HTTPException( + status_code=401, + detail=f"第一课堂 access_token validation failed: {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.") + + fullname = userinfo.get("fullname") or userinfo.get("lastname") or d1kt_username + email = userinfo.get("email") or "" + + # Try to find an existing 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, + email=email, + sso_provider="d1kt", + ) + if user is None: + raise HTTPException(status_code=409, detail="Failed to create SSO user.") + is_new_user = True + else: + user = existing + is_new_user = False + + phone_bound = bool(user.get("phone_bound")) + token = video_repository.create_session(user["id"]) + return SsoExchangeResponse( + token=token, + user=UserPublic(**user), + phone_bound=phone_bound, + is_new_user=is_new_user, + ) + + @application.post("/api/v1/auth/sso/bind-phone", response_model=UserPublic) + def sso_bind_phone( + payload: SsoBindPhoneRequest, + user: AuthenticatedUser = Depends(current_user), + ) -> UserPublic: + updated_user = video_repository.bind_phone(user.id, payload.phone) + if updated_user is None: + raise HTTPException(status_code=404, detail="User was not found.") + return UserPublic(**updated_user) + @application.get("/api/v1/courses", response_model=List[CourseSummary]) def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]: return [ diff --git a/sentence_api/models.py b/sentence_api/models.py index ea482ef..5236958 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -155,6 +155,21 @@ class AuthResponse(BaseModel): user: UserPublic +class SsoExchangeRequest(BaseModel): + access_token: str = Field(min_length=1, max_length=256) + + +class SsoExchangeResponse(BaseModel): + token: str + user: UserPublic + phone_bound: bool = False + is_new_user: bool = False + + +class SsoBindPhoneRequest(BaseModel): + phone: str = Field(min_length=1, max_length=20, pattern=r"^1[3-9]\d{9}$") + + class UserAdminItem(BaseModel): id: str username: str diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 5b6eff9..2d45759 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -156,6 +156,10 @@ class VideoRepository: } if "phone" not in user_columns: connection.execute("ALTER TABLE users ADD COLUMN phone TEXT") + if "sso_provider" not in user_columns: + connection.execute("ALTER TABLE users ADD COLUMN sso_provider TEXT") + if "phone_bound" not in user_columns: + connection.execute("ALTER TABLE users ADD COLUMN phone_bound INTEGER NOT NULL DEFAULT 0") connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)") with self._connect() as connection: @@ -789,6 +793,43 @@ class VideoRepository: return None return self.get_user_by_id(user_id) + def create_sso_user( + self, + *, + username: str, + nickname: str, + email: str = "", + sso_provider: str = "d1kt", + ) -> Optional[Dict[str, Any]]: + user_id = uuid.uuid4().hex + now = utc_now() + try: + with self._connect() as connection: + connection.execute( + """ + INSERT INTO users (id, username, nickname, phone, sso_provider, phone_bound, password_hash, created_at) + VALUES (?, ?, ?, NULL, ?, 0, '', ?) + """, + ( + user_id, + username.strip().lower(), + (nickname or username).strip()[:80], + sso_provider, + now, + ), + ) + except sqlite3.IntegrityError: + return None + return self.get_user_by_id(user_id) + + def bind_phone(self, user_id: str, phone: str) -> Optional[Dict[str, Any]]: + with self._connect() as connection: + connection.execute( + "UPDATE users SET phone = ?, phone_bound = 1 WHERE id = ?", + (phone.strip()[:20], user_id), + ) + return self.get_user_by_id(user_id) + def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute(