add d1kt SSO integration: OAuth2 login via WebView + phone binding

This commit is contained in:
2026-08-31 16:50:49 +08:00
parent 3652ebda07
commit daa73e2b98
6 changed files with 454 additions and 0 deletions

View File

@@ -109,6 +109,24 @@ class UserApi internal constructor(
onComplete: (Result<AuthSession>) -> Unit, onComplete: (Result<AuthSession>) -> Unit,
): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) } ): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) }
fun ssoExchange(
accessToken: String,
onComplete: (Result<SsoExchangeResult>) -> 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>) -> 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( fun courses(
token: String, token: String,
onComplete: (Result<List<Course>>) -> Unit, onComplete: (Result<List<Course>>) -> 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,
)

View File

@@ -46,6 +46,7 @@ import android.widget.Toast
import cn.learningpad.oraltrainer.sdk.GestureEvent import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.AuthSession import cn.learningpad.oraltrainer.sdk.AuthSession
import cn.learningpad.oraltrainer.sdk.AuthUser
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback import cn.learningpad.oraltrainer.sdk.ImitationAssessmentCallback
import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult import cn.learningpad.oraltrainer.sdk.ImitationAssessmentResult
import cn.learningpad.oraltrainer.sdk.LoopMode import cn.learningpad.oraltrainer.sdk.LoopMode
@@ -69,6 +70,9 @@ import java.net.URL
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.Locale import java.util.Locale
import org.json.JSONObject import org.json.JSONObject
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.WebResourceRequest
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlin.math.max import kotlin.math.max
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@@ -122,6 +126,10 @@ class MainActivity : Activity() {
private var regPhoneInput: EditText? = null private var regPhoneInput: EditText? = null
private var regStatusText: TextView? = null private var regStatusText: TextView? = null
private var showingRegisterPage = false 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 myContentList: LinearLayout? = null
private var sentenceBoundaryRequestGeneration = 0 private var sentenceBoundaryRequestGeneration = 0
private var catalogVideos: List<TrainingVideoSummary> = emptyList() private var catalogVideos: List<TrainingVideoSummary> = emptyList()
@@ -331,6 +339,8 @@ class MainActivity : Activity() {
setContentView( setContentView(
when { when {
isLoggedIn() -> createContentView() isLoggedIn() -> createContentView()
showingSsoWebView -> createSsoWebView()
showingPhoneBinding -> createPhoneBindingView()
showingRegisterPage -> createRegisterView() showingRegisterPage -> createRegisterView()
else -> createAuthView() else -> createAuthView()
} }
@@ -395,6 +405,23 @@ class MainActivity : Activity() {
} }
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) 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) addView(authStatusText)
} }
} }
@@ -534,6 +561,203 @@ class MainActivity : Activity() {
else sdk.userApi.login(username, password, callback) 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) { private fun saveAuthSession(session: AuthSession) {
authToken = session.token authToken = session.token
authNickname = session.user.nickname authNickname = session.user.nickname
@@ -2222,6 +2446,14 @@ class MainActivity : Activity() {
// 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。 // 朗读评测密钥:填服务器 sentence_api/.env 里的 CLIENT_API_KEY。
const val ASSESSMENT_API_KEY = "fcf60fa10bc1c49e5ddb93d570bc54df5648e187b64096962498d57661c14220" 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 PICK_VIDEO_REQUEST = 1001
const val RECORD_AUDIO_REQUEST = 1002 const val RECORD_AUDIO_REQUEST = 1002
const val PROGRESS_MAX = 1000 const val PROGRESS_MAX = 1000

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* 在第一课堂 MongoDB 中注册口语训练 App 的 OAuth2 客户端。
* 在第一课堂服务器上运行:
* cd /path/to/typing_practiceweb
* MONGODB_URI=<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);
});

View File

@@ -5,6 +5,8 @@ import mimetypes
import shutil import shutil
import subprocess import subprocess
import uuid import uuid
import urllib.request
import urllib.error
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
@@ -41,6 +43,9 @@ from .models import (
SentenceBoundaryDocument, SentenceBoundaryDocument,
SentenceTextUpdate, SentenceTextUpdate,
UserDubShareSummary, UserDubShareSummary,
SsoExchangeRequest,
SsoExchangeResponse,
SsoBindPhoneRequest,
UserPublic, UserPublic,
UserResultSummary, UserResultSummary,
UserAdminListResponse, UserAdminListResponse,
@@ -201,6 +206,68 @@ 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)
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]) @application.get("/api/v1/courses", response_model=List[CourseSummary])
def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]: def list_courses(user: AuthenticatedUser = Depends(current_user)) -> List[CourseSummary]:
return [ return [

View File

@@ -155,6 +155,21 @@ class AuthResponse(BaseModel):
user: UserPublic 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): class UserAdminItem(BaseModel):
id: str id: str
username: str username: str

View File

@@ -156,6 +156,10 @@ class VideoRepository:
} }
if "phone" not in user_columns: if "phone" not in user_columns:
connection.execute("ALTER TABLE users ADD COLUMN phone TEXT") 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)") connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)")
with self._connect() as connection: with self._connect() as connection:
@@ -789,6 +793,43 @@ class VideoRepository:
return None return None
return self.get_user_by_id(user_id) 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]]: def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(