add d1kt SSO integration: OAuth2 login via WebView + phone binding
This commit is contained in:
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user