|
@@ -30,6 +30,249 @@ logger = logging.getLogger(__name__)
|
|
|
router = APIRouter(prefix="", tags=["SSO接入"])
|
|
router = APIRouter(prefix="", tags=["SSO接入"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _sso_exchange_code(code: str, db: AsyncSession, request: Request) -> dict:
|
|
|
|
|
+ """
|
|
|
|
|
+ SSO 授权码交换流程(核心逻辑)
|
|
|
|
|
+
|
|
|
|
|
+ 用 code 向外部 SSO 换 token → 获取用户信息 → 同步本地 DB → 签发本地 JWT
|
|
|
|
|
+ 返回 { token, refresh_token, user }
|
|
|
|
|
+ """
|
|
|
|
|
+ sso_base = config_handler.get("sso", "SSO_BASE_URL", "")
|
|
|
|
|
+ client_id = config_handler.get("sso", "CLIENT_ID", "")
|
|
|
|
|
+ client_secret = config_handler.get("sso", "CLIENT_SECRET", "")
|
|
|
|
|
+ redirect_uri = config_handler.get("sso", "REDIRECT_URI", "")
|
|
|
|
|
+
|
|
|
|
|
+ logger.info("========== [SSO 码交换] 开始 ==========")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] code 前10位: {code[:10]}...")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] SSO_BASE_URL: {sso_base}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] CLIENT_ID: {client_id[:10]}...")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] REDIRECT_URI: {redirect_uri}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 1: 用 code 换取外部 SSO 的 access_token
|
|
|
|
|
+ token_url = f"{sso_base}/oauth/token"
|
|
|
|
|
+ token_data = {
|
|
|
|
|
+ "grant_type": "authorization_code",
|
|
|
|
|
+ "code": code,
|
|
|
|
|
+ "redirect_uri": redirect_uri,
|
|
|
|
|
+ "client_id": client_id,
|
|
|
|
|
+ "client_secret": client_secret
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 1: POST {token_url}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] token_data keys: {list(token_data.keys())}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] grant_type: {token_data['grant_type']}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] code: {token_data['code']}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] redirect_uri: {token_data['redirect_uri']}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] client_id: {token_data['client_id']}")
|
|
|
|
|
+
|
|
|
|
|
+ async with httpx.AsyncClient() as client:
|
|
|
|
|
+ token_response = await client.post(token_url, data=token_data)
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token POST 响应状态码: {token_response.status_code}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token POST 响应内容: {token_response.text[:500]}")
|
|
|
|
|
+
|
|
|
|
|
+ if token_response.status_code != 200:
|
|
|
|
|
+ # 尝试 GET 方式(兼容非标准实现)
|
|
|
|
|
+ logger.warning(f"[SSO 码交换] POST 失败,尝试 GET 方式...")
|
|
|
|
|
+ token_response_get = await client.get(token_url, params=token_data)
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token GET 响应状态码: {token_response_get.status_code}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token GET 响应内容: {token_response_get.text[:500]}")
|
|
|
|
|
+ if token_response_get.status_code == 200:
|
|
|
|
|
+ token_response = token_response_get
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.error(f"[SSO 码交换] GET 方式也失败了")
|
|
|
|
|
+ raise Exception(f"获取令牌失败: {token_response.text}")
|
|
|
|
|
+
|
|
|
|
|
+ token_result = token_response.json()
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token 响应 JSON keys: {list(token_result.keys())}")
|
|
|
|
|
+ sso_access_token = token_result.get("access_token")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] access_token 存在: {bool(sso_access_token)}")
|
|
|
|
|
+ if sso_access_token:
|
|
|
|
|
+ logger.info(f"[SSO 码交换] access_token 前20位: {sso_access_token[:20]}...")
|
|
|
|
|
+ if not sso_access_token:
|
|
|
|
|
+ raise Exception(f"无效的令牌响应: {token_result}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 2: 用 access_token 获取用户信息
|
|
|
|
|
+ userinfo_url = f"{sso_base}/oauth/userinfo"
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 2: GET {userinfo_url}")
|
|
|
|
|
+ async with httpx.AsyncClient() as client:
|
|
|
|
|
+ userinfo_response = await client.get(
|
|
|
|
|
+ userinfo_url,
|
|
|
|
|
+ headers={"Authorization": f"Bearer {sso_access_token}"}
|
|
|
|
|
+ )
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Userinfo 响应状态码: {userinfo_response.status_code}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Userinfo 响应内容: {userinfo_response.text[:500]}")
|
|
|
|
|
+
|
|
|
|
|
+ if userinfo_response.status_code != 200:
|
|
|
|
|
+ raise Exception(f"获取用户信息失败: {userinfo_response.text}")
|
|
|
|
|
+
|
|
|
|
|
+ userinfo = userinfo_response.json()
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Userinfo JSON: {userinfo}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 3: 在本地查找或创建用户
|
|
|
|
|
+ external_user_id = str(userinfo.get("sub", userinfo.get("id", "")))
|
|
|
|
|
+ username = userinfo.get("username") or userinfo.get("login") or external_user_id
|
|
|
|
|
+ email = userinfo.get("email") or f"{username}@placeholder.local"
|
|
|
|
|
+ real_name = userinfo.get("name") or userinfo.get("real_name") or username
|
|
|
|
|
+ # 角色信息(如果 SSO 返回了 roles)
|
|
|
|
|
+ sso_roles = userinfo.get("roles", [])
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 3: 用户信息解析")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] external_user_id: {external_user_id}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] username: {username}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] email: {email}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] real_name: {real_name}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] sso_roles: {sso_roles}")
|
|
|
|
|
+
|
|
|
|
|
+ # 先按 username 查找
|
|
|
|
|
+ stmt = select(User).where(
|
|
|
|
|
+ and_(User.username == username, User.is_deleted == False)
|
|
|
|
|
+ )
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ user = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if user:
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 按 username 找到用户: {user.id}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 按 username 未找到用户,尝试按 email 查找: {email}")
|
|
|
|
|
+ # 再按 email 查找
|
|
|
|
|
+ stmt = select(User).where(
|
|
|
|
|
+ and_(User.email == email, User.is_deleted == False)
|
|
|
|
|
+ )
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ user = result.scalar_one_or_none()
|
|
|
|
|
+ if user:
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 按 email 找到用户: {user.id}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 按 email 也未找到用户,将创建新用户")
|
|
|
|
|
+
|
|
|
|
|
+ if user:
|
|
|
|
|
+ # 更新用户最后登录时间
|
|
|
|
|
+ user.last_login_at = datetime.utcnow()
|
|
|
|
|
+ user.last_login_ip = request.client.host if request.client else None
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 更新现有用户 {username}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 创建新用户
|
|
|
|
|
+ user_id = str(uuid.uuid4())
|
|
|
|
|
+ user = User(
|
|
|
|
|
+ id=user_id,
|
|
|
|
|
+ username=username,
|
|
|
|
|
+ email=email,
|
|
|
|
|
+ password_hash=hash_password(generate_random_string(32)),
|
|
|
|
|
+ is_active=True,
|
|
|
|
|
+ is_superuser=False,
|
|
|
|
|
+ last_login_at=datetime.utcnow(),
|
|
|
|
|
+ last_login_ip=request.client.host if request.client else None
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(user)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 创建新用户 {username}, id={user_id}")
|
|
|
|
|
+
|
|
|
|
|
+ # 尝试分配默认角色
|
|
|
|
|
+ try:
|
|
|
|
|
+ stmt = select(Role).where(
|
|
|
|
|
+ and_(Role.code == "user", Role.is_active == True)
|
|
|
|
|
+ )
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ default_role = result.scalar_one_or_none()
|
|
|
|
|
+ if default_role:
|
|
|
|
|
+ user_role = UserRole(
|
|
|
|
|
+ user_id=user.id,
|
|
|
|
|
+ role_id=default_role.id
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(user_role)
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 分配默认角色: {default_role.code} ({default_role.id})")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.warning(f"[SSO 码交换] 未找到默认角色 'user'")
|
|
|
|
|
+ except Exception as role_err:
|
|
|
|
|
+ logger.warning(f"[SSO 码交换] 分配默认角色失败: {role_err}")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 4: 根据 SSO 返回的角色同步本地角色
|
|
|
|
|
+ if sso_roles:
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 查询用户当前角色
|
|
|
|
|
+ stmt = select(UserRole).where(UserRole.user_id == user.id)
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ current_user_roles = result.scalars().all()
|
|
|
|
|
+ current_role_ids = {ur.role_id for ur in current_user_roles}
|
|
|
|
|
+
|
|
|
|
|
+ # 查询 SSO 角色对应的本地角色
|
|
|
|
|
+ stmt = select(Role).where(
|
|
|
|
|
+ and_(Role.code.in_(sso_roles), Role.is_active == True)
|
|
|
|
|
+ )
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ sso_local_roles = result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ # 添加新角色
|
|
|
|
|
+ for role in sso_local_roles:
|
|
|
|
|
+ if role.id not in current_role_ids:
|
|
|
|
|
+ user_role = UserRole(user_id=user.id, role_id=role.id)
|
|
|
|
|
+ db.add(user_role)
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"SSO角色同步: user={username}, roles={sso_roles}")
|
|
|
|
|
+ except Exception as role_err:
|
|
|
|
|
+ logger.warning(f"SSO角色同步失败: {role_err}")
|
|
|
|
|
+
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ # Step 5: 生成本地 JWT
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 5: 生成本地 JWT")
|
|
|
|
|
+ token_payload = {
|
|
|
|
|
+ "sub": user.id,
|
|
|
|
|
+ "username": user.username,
|
|
|
|
|
+ "email": user.email,
|
|
|
|
|
+ "is_superuser": user.is_superuser
|
|
|
|
|
+ }
|
|
|
|
|
+ local_access_token = create_access_token(token_payload)
|
|
|
|
|
+ local_refresh_token = create_refresh_token({"sub": user.id})
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 本地 access_token 前20位: {local_access_token[:20]}...")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 6: 保存 token 到数据库
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 6: 保存 token 到数据库")
|
|
|
|
|
+ admin_expire_minutes = config_handler.get_int("admin_app", "ADMIN_TOKEN_EXPIRE_MINUTES", None)
|
|
|
|
|
+ if admin_expire_minutes is not None:
|
|
|
|
|
+ expires_at = datetime.utcnow() + timedelta(minutes=admin_expire_minutes)
|
|
|
|
|
+ else:
|
|
|
|
|
+ expire_minutes = config_handler.get_int("admin_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30)
|
|
|
|
|
+ expires_at = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
|
|
|
|
+
|
|
|
|
|
+ oauth_token = OAuthAccessToken(
|
|
|
|
|
+ user_id=user.id,
|
|
|
|
|
+ app_id=None,
|
|
|
|
|
+ token=local_access_token,
|
|
|
|
|
+ refresh_token=local_refresh_token,
|
|
|
|
|
+ token_type="Bearer",
|
|
|
|
|
+ scope="profile email",
|
|
|
|
|
+ expires_at=expires_at
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(oauth_token)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Token 已保存到数据库")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 7: 获取用户的角色列表返回给前端
|
|
|
|
|
+ logger.info(f"[SSO 码交换] Step 7: 获取用户角色列表")
|
|
|
|
|
+ stmt = select(Role).join(UserRole, Role.id == UserRole.role_id).where(UserRole.user_id == user.id)
|
|
|
|
|
+ result = await db.execute(stmt)
|
|
|
|
|
+ roles = [r.code for r in result.scalars().all()]
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 用户角色列表: {roles}")
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[SSO 码交换] 最终成功! user={user.username}, token={local_access_token[:20]}...")
|
|
|
|
|
+ logger.info(f"[SSO 码交换] ========== [SSO 码交换] 完成 ==========")
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "token": local_access_token,
|
|
|
|
|
+ "refresh_token": local_refresh_token,
|
|
|
|
|
+ "user": {
|
|
|
|
|
+ "id": user.id,
|
|
|
|
|
+ "username": user.username,
|
|
|
|
|
+ "email": user.email,
|
|
|
|
|
+ "phone": user.phone,
|
|
|
|
|
+ "is_superuser": user.is_superuser,
|
|
|
|
|
+ "is_active": user.is_active,
|
|
|
|
|
+ "roles": roles,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.get("/auth/sso/authorize")
|
|
@router.get("/auth/sso/authorize")
|
|
|
async def sso_authorize_url(redirect: bool = False):
|
|
async def sso_authorize_url(redirect: bool = False):
|
|
|
"""获取外部SSO授权URL(供前端使用)
|
|
"""获取外部SSO授权URL(供前端使用)
|
|
@@ -77,194 +320,106 @@ async def sso_callback(
|
|
|
error_description: str = None,
|
|
error_description: str = None,
|
|
|
db: AsyncSession = Depends(get_db)
|
|
db: AsyncSession = Depends(get_db)
|
|
|
):
|
|
):
|
|
|
- """处理外部SSO回调"""
|
|
|
|
|
- try:
|
|
|
|
|
- frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
|
|
|
|
|
-
|
|
|
|
|
- # 处理外部SSO返回的错误
|
|
|
|
|
- if error:
|
|
|
|
|
- logger.error(f"SSO返回错误: {error}, {error_description}")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error={error}&error_description={error_description or ''}",
|
|
|
|
|
- status_code=302
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ """处理外部SSO回调(旧流程:后端 302 重定向到前端)"""
|
|
|
|
|
+ logger.info("========== [SSO 回调 /auth/callback] 开始 ==========")
|
|
|
|
|
+ logger.info(f"[SSO 回调] URL 参数: code={str(code)[:10] if code else None}, error={error}, state={state}")
|
|
|
|
|
+ logger.info(f"[SSO 回调] 完整 URL: {request.url}")
|
|
|
|
|
+ logger.info(f"[SSO 回调] query_params: {dict(request.query_params)}")
|
|
|
|
|
|
|
|
- if not code:
|
|
|
|
|
- logger.error("SSO回调缺少code参数")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
|
|
|
|
|
- status_code=302
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
|
|
|
|
|
|
|
|
- sso_base = config_handler.get("sso", "SSO_BASE_URL", "")
|
|
|
|
|
- client_id = config_handler.get("sso", "CLIENT_ID", "")
|
|
|
|
|
- client_secret = config_handler.get("sso", "CLIENT_SECRET", "")
|
|
|
|
|
- redirect_uri = config_handler.get("sso", "REDIRECT_URI", "")
|
|
|
|
|
|
|
+ # 处理外部SSO返回的错误
|
|
|
|
|
+ if error:
|
|
|
|
|
+ logger.error(f"[SSO 回调] SSO返回错误: {error}, {error_description}")
|
|
|
|
|
+ return RedirectResponse(
|
|
|
|
|
+ url=f"{frontend_url}/login?error={error}&error_description={error_description or ''}",
|
|
|
|
|
+ status_code=302
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- logger.info(f"收到SSO回调, code={code[:10]}...")
|
|
|
|
|
|
|
+ if not code:
|
|
|
|
|
+ logger.error("[SSO 回调] 缺少 code 参数")
|
|
|
|
|
+ return RedirectResponse(
|
|
|
|
|
+ url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
|
|
|
|
|
+ status_code=302
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- # Step 1: 用code换取access_token
|
|
|
|
|
- token_url = f"{sso_base}/oauth/token"
|
|
|
|
|
- token_data = {
|
|
|
|
|
- "grant_type": "authorization_code",
|
|
|
|
|
- "code": code,
|
|
|
|
|
- "redirect_uri": redirect_uri,
|
|
|
|
|
- "client_id": client_id,
|
|
|
|
|
- "client_secret": client_secret
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 调用核心码交换逻辑
|
|
|
|
|
+ result = await _sso_exchange_code(code, db, request)
|
|
|
|
|
|
|
|
- async with httpx.AsyncClient() as client:
|
|
|
|
|
- token_response = await client.post(token_url, data=token_data)
|
|
|
|
|
- logger.info(f"Token接口状态码: {token_response.status_code}")
|
|
|
|
|
-
|
|
|
|
|
- if token_response.status_code != 200:
|
|
|
|
|
- # 尝试GET方式(兼容某些非标准实现)
|
|
|
|
|
- token_response_get = await client.get(token_url, params=token_data)
|
|
|
|
|
- logger.info(f"Token接口GET状态码: {token_response_get.status_code}")
|
|
|
|
|
- if token_response_get.status_code == 200:
|
|
|
|
|
- token_response = token_response_get
|
|
|
|
|
- else:
|
|
|
|
|
- logger.error(f"获取token失败: {token_response.text}")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error=token_failed&error_description=获取令牌失败",
|
|
|
|
|
- status_code=302
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- token_result = token_response.json()
|
|
|
|
|
- access_token = token_result.get("access_token")
|
|
|
|
|
- if not access_token:
|
|
|
|
|
- logger.error(f"Token响应中缺少access_token: {token_result}")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error=invalid_token_response&error_description=无效的令牌响应",
|
|
|
|
|
- status_code=302
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ # 重定向回前端,携带本地token
|
|
|
|
|
+ callback_url = (
|
|
|
|
|
+ f"{frontend_url}/oauth/callback"
|
|
|
|
|
+ f"?token={result['token']}"
|
|
|
|
|
+ f"&refresh_token={result['refresh_token']}"
|
|
|
|
|
+ )
|
|
|
|
|
+ logger.info(f"[SSO 回调] 重定向到: {callback_url[:80]}...")
|
|
|
|
|
+ logger.info(f"[SSO 回调] ========== [SSO 回调] 完成 ==========")
|
|
|
|
|
+ return RedirectResponse(url=callback_url, status_code=302)
|
|
|
|
|
|
|
|
- # Step 2: 用access_token获取用户信息
|
|
|
|
|
- userinfo_url = f"{sso_base}/oauth/userinfo"
|
|
|
|
|
- async with httpx.AsyncClient() as client:
|
|
|
|
|
- userinfo_response = await client.get(
|
|
|
|
|
- userinfo_url,
|
|
|
|
|
- headers={"Authorization": f"Bearer {access_token}"}
|
|
|
|
|
- )
|
|
|
|
|
- logger.info(f"Userinfo接口状态码: {userinfo_response.status_code}")
|
|
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.exception("[SSO 回调] 核心逻辑处理异常")
|
|
|
|
|
+ return RedirectResponse(
|
|
|
|
|
+ url=f"{frontend_url}/login?error=server_error&error_description={str(e)}",
|
|
|
|
|
+ status_code=302
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- if userinfo_response.status_code != 200:
|
|
|
|
|
- logger.error(f"获取用户信息失败: {userinfo_response.text}")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error=userinfo_failed&error_description=获取用户信息失败",
|
|
|
|
|
- status_code=302
|
|
|
|
|
- )
|
|
|
|
|
|
|
|
|
|
- userinfo = userinfo_response.json()
|
|
|
|
|
- logger.info(f"获取到外部用户信息: {userinfo}")
|
|
|
|
|
|
|
+@router.post("/api/oauth/exchange-code")
|
|
|
|
|
+async def sso_exchange_code_api(
|
|
|
|
|
+ request: Request,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db)
|
|
|
|
|
+):
|
|
|
|
|
+ """
|
|
|
|
|
+ SSO 免登接口(新流程)
|
|
|
|
|
|
|
|
- # Step 3: 在本地查找或创建用户
|
|
|
|
|
- external_user_id = str(userinfo.get("sub", userinfo.get("id", "")))
|
|
|
|
|
- username = userinfo.get("username") or userinfo.get("login") or external_user_id
|
|
|
|
|
- email = userinfo.get("email") or f"{username}@placeholder.local"
|
|
|
|
|
- real_name = userinfo.get("name") or userinfo.get("real_name") or username
|
|
|
|
|
|
|
+ 前端从统一认证平台回调获得 code 后,调用此接口换本地 JWT。
|
|
|
|
|
|
|
|
- # 先按username查找
|
|
|
|
|
- stmt = select(User).where(
|
|
|
|
|
- and_(User.username == username, User.is_deleted == False)
|
|
|
|
|
- )
|
|
|
|
|
- result = await db.execute(stmt)
|
|
|
|
|
- user = result.scalar_one_or_none()
|
|
|
|
|
|
|
+ 请求体: { "code": "xxx" }
|
|
|
|
|
+ 返回: { code: "000000", data: { token, refresh_token, user } }
|
|
|
|
|
+ """
|
|
|
|
|
+ logger.info("========== [SSO 码交换 API] 开始 ==========")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 请求方法: {request.method}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 请求头 Content-Type: {request.headers.get('content-type', 'N/A')}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 客户端IP: {request.client.host if request.client else 'N/A'}")
|
|
|
|
|
|
|
|
- if not user:
|
|
|
|
|
- # 再按email查找
|
|
|
|
|
- stmt = select(User).where(
|
|
|
|
|
- and_(User.email == email, User.is_deleted == False)
|
|
|
|
|
- )
|
|
|
|
|
- result = await db.execute(stmt)
|
|
|
|
|
- user = result.scalar_one_or_none()
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ raw_body = await request.body()
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 原始请求体: {raw_body.decode('utf-8')}")
|
|
|
|
|
|
|
|
- if user:
|
|
|
|
|
- # 更新用户最后登录时间
|
|
|
|
|
- user.last_login_at = datetime.utcnow()
|
|
|
|
|
- user.last_login_ip = request.client.host if request.client else None
|
|
|
|
|
- logger.info(f"SSO登录: 更新现有用户 {username}")
|
|
|
|
|
- else:
|
|
|
|
|
- # 创建新用户
|
|
|
|
|
- user_id = str(uuid.uuid4())
|
|
|
|
|
- user = User(
|
|
|
|
|
- id=user_id,
|
|
|
|
|
- username=username,
|
|
|
|
|
- email=email,
|
|
|
|
|
- password_hash=hash_password(generate_random_string(32)),
|
|
|
|
|
- is_active=True,
|
|
|
|
|
- is_superuser=False,
|
|
|
|
|
- last_login_at=datetime.utcnow(),
|
|
|
|
|
- last_login_ip=request.client.host if request.client else None
|
|
|
|
|
- )
|
|
|
|
|
- db.add(user)
|
|
|
|
|
- await db.flush()
|
|
|
|
|
- logger.info(f"SSO登录: 创建新用户 {username}, id={user_id}")
|
|
|
|
|
-
|
|
|
|
|
- # 尝试分配默认角色
|
|
|
|
|
- try:
|
|
|
|
|
- stmt = select(Role).where(
|
|
|
|
|
- and_(Role.code == "user", Role.is_active == True)
|
|
|
|
|
- )
|
|
|
|
|
- result = await db.execute(stmt)
|
|
|
|
|
- default_role = result.scalar_one_or_none()
|
|
|
|
|
- if default_role:
|
|
|
|
|
- user_role = UserRole(
|
|
|
|
|
- user_id=user.id,
|
|
|
|
|
- role_id=default_role.id
|
|
|
|
|
- )
|
|
|
|
|
- db.add(user_role)
|
|
|
|
|
- except Exception as role_err:
|
|
|
|
|
- logger.warning(f"分配默认角色失败: {role_err}")
|
|
|
|
|
|
|
+ body = await request.json()
|
|
|
|
|
+ code = body.get("code")
|
|
|
|
|
|
|
|
- await db.commit()
|
|
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 解析后的 body: {body}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] code 前10位: {str(code)[:10] if code else None}")
|
|
|
|
|
|
|
|
- # Step 4: 生成本地JWT
|
|
|
|
|
- token_payload = {
|
|
|
|
|
- "sub": user.id,
|
|
|
|
|
- "username": user.username,
|
|
|
|
|
- "email": user.email,
|
|
|
|
|
- "is_superuser": user.is_superuser
|
|
|
|
|
- }
|
|
|
|
|
- local_access_token = create_access_token(token_payload)
|
|
|
|
|
- local_refresh_token = create_refresh_token({"sub": user.id})
|
|
|
|
|
-
|
|
|
|
|
- # Step 5: 保存token到数据库
|
|
|
|
|
- admin_expire_minutes = config_handler.get_int("admin_app", "ADMIN_TOKEN_EXPIRE_MINUTES", None)
|
|
|
|
|
- if admin_expire_minutes is not None:
|
|
|
|
|
- expires_at = datetime.utcnow() + timedelta(minutes=admin_expire_minutes)
|
|
|
|
|
- expires_in_seconds = admin_expire_minutes * 60
|
|
|
|
|
- else:
|
|
|
|
|
- expire_minutes = config_handler.get_int("admin_app", "ACCESS_TOKEN_EXPIRE_MINUTES", 30)
|
|
|
|
|
- expires_at = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
|
|
|
|
- expires_in_seconds = expire_minutes * 60
|
|
|
|
|
-
|
|
|
|
|
- oauth_token = OAuthAccessToken(
|
|
|
|
|
- user_id=user.id,
|
|
|
|
|
- app_id=None,
|
|
|
|
|
- token=local_access_token,
|
|
|
|
|
- refresh_token=local_refresh_token,
|
|
|
|
|
- token_type="Bearer",
|
|
|
|
|
- scope="profile email",
|
|
|
|
|
- expires_at=expires_at
|
|
|
|
|
- )
|
|
|
|
|
- db.add(oauth_token)
|
|
|
|
|
- await db.commit()
|
|
|
|
|
|
|
+ if not code:
|
|
|
|
|
+ logger.error("[SSO 码交换 API] 请求体中缺少 code 字段")
|
|
|
|
|
+ return ResponseSchema(
|
|
|
|
|
+ code="100001",
|
|
|
|
|
+ message="缺少授权码",
|
|
|
|
|
+ data=None
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- logger.info(f"SSO登录成功: user={user.username}, token={local_access_token[:20]}...")
|
|
|
|
|
|
|
+ result = await _sso_exchange_code(code, db, request)
|
|
|
|
|
|
|
|
- # Step 6: 重定向回前端,携带本地token
|
|
|
|
|
- callback_url = (
|
|
|
|
|
- f"{frontend_url}/oauth/callback"
|
|
|
|
|
- f"?token={local_access_token}"
|
|
|
|
|
- f"&refresh_token={local_refresh_token}"
|
|
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 返回 data.user.username: {result['user']['username']}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 返回 data.user.roles: {result['user']['roles']}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] ========== [SSO 码交换 API] 成功 ==========")
|
|
|
|
|
+
|
|
|
|
|
+ return ResponseSchema(
|
|
|
|
|
+ code="000000",
|
|
|
|
|
+ message="登录成功",
|
|
|
|
|
+ data=result
|
|
|
)
|
|
)
|
|
|
- return RedirectResponse(url=callback_url, status_code=302)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
- logger.exception("SSO回调处理异常")
|
|
|
|
|
- frontend_url = config_handler.get("sso", "FRONTEND_URL", "http://localhost:5173")
|
|
|
|
|
- return RedirectResponse(
|
|
|
|
|
- url=f"{frontend_url}/login?error=server_error&error_description={str(e)}",
|
|
|
|
|
- status_code=302
|
|
|
|
|
|
|
+ logger.exception("[SSO 码交换 API] 异常")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 异常类型: {type(e).__name__}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] 异常信息: {str(e)}")
|
|
|
|
|
+ logger.info(f"[SSO 码交换 API] ========== [SSO 码交换 API] 失败 ==========")
|
|
|
|
|
+ return ResponseSchema(
|
|
|
|
|
+ code="500001",
|
|
|
|
|
+ message=f"登录失败: {str(e)}",
|
|
|
|
|
+ data=None
|
|
|
)
|
|
)
|