|
@@ -0,0 +1,270 @@
|
|
|
|
|
+"""
|
|
|
|
|
+SSO客户端接入视图
|
|
|
|
|
+处理外部统一认证平台的OAuth2回调
|
|
|
|
|
+"""
|
|
|
|
|
+import sys
|
|
|
|
|
+import os
|
|
|
|
|
+
|
|
|
|
|
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..'))
|
|
|
|
|
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
|
|
|
|
|
+
|
|
|
|
|
+import logging
|
|
|
|
|
+import uuid
|
|
|
|
|
+from urllib.parse import quote
|
|
|
|
|
+from fastapi import APIRouter, Request, Depends
|
|
|
|
|
+from fastapi.responses import RedirectResponse, JSONResponse
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
+from sqlalchemy import select, and_
|
|
|
|
|
+from datetime import datetime, timedelta
|
|
|
|
|
+import httpx
|
|
|
|
|
+
|
|
|
|
|
+from app.base import get_db
|
|
|
|
|
+from app.core.config import config_handler
|
|
|
|
|
+from app.models.user import User, UserRole, Role
|
|
|
|
|
+from app.models.token import OAuthAccessToken
|
|
|
|
|
+from app.utils.security import create_access_token, create_refresh_token, hash_password, generate_random_string
|
|
|
|
|
+from app.schemas.base import ResponseSchema
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+router = APIRouter(prefix="", tags=["SSO接入"])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/auth/sso/authorize")
|
|
|
|
|
+async def sso_authorize_url(redirect: bool = False):
|
|
|
|
|
+ """获取外部SSO授权URL(供前端使用)
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ redirect: 为True时直接重定向到授权URL,为False时返回JSON
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ sso_base = config_handler.get("sso", "SSO_BASE_URL", "")
|
|
|
|
|
+ client_id = config_handler.get("sso", "CLIENT_ID", "")
|
|
|
|
|
+ redirect_uri = config_handler.get("sso", "REDIRECT_URI", "")
|
|
|
|
|
+
|
|
|
|
|
+ scope = config_handler.get("sso", "SCOPE", "read write")
|
|
|
|
|
+ authorize_url = (
|
|
|
|
|
+ f"{sso_base}/oauth/authorize"
|
|
|
|
|
+ f"?client_id={client_id}"
|
|
|
|
|
+ f"&redirect_uri={redirect_uri}"
|
|
|
|
|
+ f"&response_type=code"
|
|
|
|
|
+ f"&scope={quote(scope, safe='')}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if redirect:
|
|
|
|
|
+ return RedirectResponse(url=authorize_url, status_code=302)
|
|
|
|
|
+
|
|
|
|
|
+ return ResponseSchema(
|
|
|
|
|
+ code="000000",
|
|
|
|
|
+ message="获取授权URL成功",
|
|
|
|
|
+ data={"authorize_url": authorize_url}
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.exception("获取SSO授权URL失败")
|
|
|
|
|
+ return ResponseSchema(
|
|
|
|
|
+ code="500001",
|
|
|
|
|
+ message=f"获取授权URL失败: {str(e)}",
|
|
|
|
|
+ data=None
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/auth/callback")
|
|
|
|
|
+async def sso_callback(
|
|
|
|
|
+ request: Request,
|
|
|
|
|
+ code: str = None,
|
|
|
|
|
+ state: str = None,
|
|
|
|
|
+ error: str = None,
|
|
|
|
|
+ error_description: str = None,
|
|
|
|
|
+ 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
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not code:
|
|
|
|
|
+ logger.error("SSO回调缺少code参数")
|
|
|
|
|
+ return RedirectResponse(
|
|
|
|
|
+ url=f"{frontend_url}/login?error=missing_code&error_description=缺少授权码",
|
|
|
|
|
+ status_code=302
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ 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(f"收到SSO回调, code={code[:10]}...")
|
|
|
|
|
+
|
|
|
|
|
+ # 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
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 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}")
|
|
|
|
|
+
|
|
|
|
|
+ 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}")
|
|
|
|
|
+
|
|
|
|
|
+ # 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
|
|
|
|
|
+
|
|
|
|
|
+ # 先按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 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()
|
|
|
|
|
+
|
|
|
|
|
+ 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}")
|
|
|
|
|
+
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ # 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()
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"SSO登录成功: user={user.username}, token={local_access_token[:20]}...")
|
|
|
|
|
+
|
|
|
|
|
+ # Step 6: 重定向回前端,携带本地token
|
|
|
|
|
+ callback_url = (
|
|
|
|
|
+ f"{frontend_url}/oauth/callback"
|
|
|
|
|
+ f"?token={local_access_token}"
|
|
|
|
|
+ f"&refresh_token={local_refresh_token}"
|
|
|
|
|
+ )
|
|
|
|
|
+ return RedirectResponse(url=callback_url, status_code=302)
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+ )
|