Compare commits
2 Commits
3652ebda07
...
85a5e76d6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a5e76d6f | |||
| daa73e2b98 |
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.toString().startsWith(SSO_REDIRECT_URI)) {
|
||||||
|
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 = "https://videoservice.d1kt.cn/api/v1/auth/sso/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
|
||||||
|
|||||||
60
scripts/register_oral_trainer_oauth_client.js
Normal file
60
scripts/register_oral_trainer_oauth_client.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user