# ============================================================================= # 企微IT智能服务台 — 统一认证 API # ============================================================================= # 说明:统一认证入口,整合现有的扫码登录和企微SSO # 三端认证重构:取消OTP/账号密码登录,统一为企微扫码认证 # # API 端点列表: # GET /api/auth/qrcode — 获取扫码登录二维码 # GET /api/auth/scan/status — 轮询扫码状态 # GET /api/auth/oauth2/callback — OAuth2回调处理 # POST /api/auth/bind — 账号绑定(互联企业) # POST /api/auth/verify — 验证Token # POST /api/auth/logout — 登出 # GET /api/auth/me — 获取当前用户信息 # POST /api/auth/switch-role — 切换角色 # ============================================================================= import logging from typing import Optional import redis.asyncio as aioredis from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.dependencies import UserInfo, get_current_user, get_redis from app.schemas.auth import ( BindAccountRequest, CurrentUserResponse, LogoutResponse, SwitchRoleRequest, SwitchRoleResponse, TokenVerifyRequest, TokenVerifyResponse, UnifiedAuthResponse, ) from app.services.auth_service import AuthService from app.services.token_service import TokenService, TOKEN_TTL_SECONDS from app.utils.response import AppException, success_response logger = logging.getLogger(__name__) # 创建路由器 router = APIRouter(prefix="/auth", tags=["统一认证"]) # Token 黑名单 Key 前缀 TOKEN_BLACKLIST_PREFIX = "token:blacklist:" def _get_auth_service( redis_client: aioredis.Redis, db: AsyncSession = Depends(get_db), ) -> AuthService: """工厂函数:构造统一认证服务。 Args: redis_client: Redis 客户端 db: 数据库会话 Returns: AuthService: 统一认证服务实例 """ return AuthService(redis_client, db) def _get_client_info(request: Request) -> tuple: """获取客户端信息。 Args: request: 请求对象 Returns: tuple: (ip_address, user_agent) """ # 获取客户端 IP ip_address = request.client.host if request.client else None # 尝试从代理获取真实 IP forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: ip_address = forwarded_for.split(",")[0].strip() # 获取 User-Agent user_agent = request.headers.get("User-Agent", "") return ip_address, user_agent # -------------------------------------------------------------------------- # GET /api/auth/qrcode — 获取扫码登录二维码 # -------------------------------------------------------------------------- @router.get("/qrcode", response_model=None) async def get_qrcode( redis_client: aioredis.Redis = Depends(get_redis), ): """获取扫码登录二维码。 无需鉴权(用户尚未登录)。 返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。 Returns: Dict: 统一响应格式,data 字段包含 qrcode 信息 """ try: # 复用现有的扫码服务 from app.services.qrcode_service import QrcodeService service = QrcodeService(redis_client) result = await service.create_ticket() return success_response(data={ "ticket": result["ticket"], "qrcode_url": result["qrcode_url"], "qrcode_png_base64": result["qrcode_png_base64"], "expires_in": result["expires_in"], "expires_at": result["expires_at"].isoformat(), }) except Exception as e: logger.error(f"获取扫码二维码异常: {e}", exc_info=True) raise AppException(1005, f"获取扫码二维码失败: {str(e)}") # -------------------------------------------------------------------------- # GET /api/auth/scan/status — 轮询扫码状态 # -------------------------------------------------------------------------- @router.get("/scan/status", response_model=None) async def get_scan_status( ticket: str = Query(..., description="扫码登录票据"), redis_client: aioredis.Redis = Depends(get_redis), ): """轮询扫码状态。 无需鉴权(浏览器未登录态访问)。 状态机: - waiting: ticket 有效,等待扫码 - scanned: 已扫码,等待 confirm - confirmed: 已确认,返回 token - expired: ticket 过期/不存在 Args: ticket: 扫码登录票据 Returns: Dict: 统一响应格式,data 字段包含状态信息 """ try: # 复用现有的扫码服务 from app.services.qrcode_service import QrcodeService service = QrcodeService(redis_client) result = await service.get_poll_state(ticket) return success_response(data={ "status": result["status"], "employee_id": result.get("employee_id"), "name": result.get("name"), "token": result.get("token"), }) except Exception as e: logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True) raise AppException(1005, f"轮询扫码状态失败: {str(e)}") # -------------------------------------------------------------------------- # GET /api/auth/oauth2/url — 获取 OAuth2 授权 URL # -------------------------------------------------------------------------- @router.get("/oauth2/url", response_model=None) async def get_oauth2_url( redirect_uri: str = Query(..., description="授权成功后的回调地址"), ): """获取企微 OAuth2 授权 URL。 前端在企微内打开时调用此接口,获取授权 URL 后跳转到企微授权页。 授权成功后企微会携带 code 回调到 redirect_uri。 Args: redirect_uri: 授权成功后的回调地址(三端不同:/h5/、/itadmin/、/itagent/) Returns: Dict: 统一响应格式,data 字段包含 authorize_url """ import urllib.parse # 构建企微 OAuth2 授权 URL params = { "appid": settings.wecom_corp_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": "snsapi_base", "state": "auth_login", } if getattr(settings, "wecom_agent_id", None): params["agentid"] = str(settings.wecom_agent_id) query = urllib.parse.urlencode(params) authorize_url = f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect" logger.info(f"生成统一认证OAuth授权URL: redirect_uri={redirect_uri}") return success_response(data={ "authorize_url": authorize_url, "redirect_uri": redirect_uri, }) # -------------------------------------------------------------------------- # GET /api/auth/oauth2/callback — OAuth2回调处理 # -------------------------------------------------------------------------- @router.get("/oauth2/callback", response_model=None) async def oauth2_callback( request: Request, code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"), state: Optional[str] = Query(None, description="防 CSRF state"), errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"), errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"), redis_client: aioredis.Redis = Depends(get_redis), db: AsyncSession = Depends(get_db), ): """企微 OAuth2 回调处理。 用 code 换 userid → 查 role → 生成 token → 跳转到目标页面。 实际上是复用现有的 auth_wecom_sso 逻辑。 Args: code: 企微 OAuth2 授权 code state: 防 CSRF state errcode: 企微 OAuth 错误码 errmsg: 企微 OAuth 错误信息 Returns: RedirectResponse: 跳转到目标页面 """ from fastapi.responses import RedirectResponse # 复用 auth_wecom_sso 的逻辑 from app.api.auth_wecom_sso import sso_callback # 调用现有的 SSO callback return await sso_callback( request=request, code=code, state=state, errcode=errcode, errmsg=errmsg, next=None, redis_client=redis_client, db=db, ) # -------------------------------------------------------------------------- # POST /api/auth/verify — 验证 Token # -------------------------------------------------------------------------- @router.post("/verify", response_model=None) async def verify_token( body: TokenVerifyRequest, redis_client: aioredis.Redis = Depends(get_redis), ): """验证 Token 有效性。 Args: body: Token 验证请求 Returns: Dict: 统一响应格式,data 字段包含验证结果 """ try: auth_service = _get_auth_service(redis_client) user_info = await auth_service.verify_token(body.token) if not user_info: return success_response(data=TokenVerifyResponse( valid=False, )) # 获取 Token 剩余有效期 import hashlib token_hash = hashlib.sha256(body.token.encode()).hexdigest() key = f"user:token:{token_hash}" ttl = await redis_client.ttl(key) if ttl < 0: ttl = TOKEN_TTL_SECONDS return success_response(data=TokenVerifyResponse( valid=True, employee_id=user_info.get("employee_id"), name=user_info.get("name"), roles=user_info.get("roles", []), current_role=user_info.get("current_role"), login_source=user_info.get("login_source"), expires_in=ttl, )) except Exception as e: logger.error(f"验证 Token 异常: {e}", exc_info=True) raise AppException(1005, f"验证 Token 失败: {str(e)}") # -------------------------------------------------------------------------- # POST /api/auth/logout — 登出 # -------------------------------------------------------------------------- @router.post("/logout", response_model=None) async def logout( request: Request, current_user: UserInfo = Depends(get_current_user), redis_client: aioredis.Redis = Depends(get_redis), ): """登出:将 Token 加入黑名单并清除 Redis 中的 Token。 需要鉴权:已登录用户才能登出。 Args: current_user: 当前用户信息 Returns: Dict: 统一响应格式 """ try: # 从请求头获取 Token auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] # 将 Token 加入黑名单 import hashlib token_hash = hashlib.sha256(token.encode()).hexdigest() key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}" await redis_client.setex(key, TOKEN_TTL_SECONDS, "1") # 清除 Redis 中的 Token token_service = TokenService(redis_client) await token_service.invalidate_token(token) logger.info(f"用户登出: employee_id={current_user.employee_id}") return success_response(data=LogoutResponse( success=True, message="登出成功", )) except Exception as e: logger.error(f"登出异常: {e}", exc_info=True) raise AppException(1005, f"登出失败: {str(e)}") # -------------------------------------------------------------------------- # GET /api/auth/me — 获取当前用户信息 # -------------------------------------------------------------------------- @router.get("/me", response_model=None) async def get_current_user_info( current_user: UserInfo = Depends(get_current_user), ): """获取当前用户信息。 需要鉴权:已登录用户才能调用。 Args: current_user: 当前用户信息 Returns: Dict: 统一响应格式,data 字段包含用户信息 """ return success_response(data=CurrentUserResponse( employee_id=current_user.employee_id, name=current_user.name, avatar=current_user.avatar, department=current_user.department, roles=current_user.roles, current_role=current_user.current_role, login_source=current_user.login_source, )) # -------------------------------------------------------------------------- # POST /api/auth/switch-role — 切换角色 # -------------------------------------------------------------------------- @router.post("/switch-role", response_model=None) async def switch_role( body: SwitchRoleRequest, current_user: UserInfo = Depends(get_current_user), redis_client: aioredis.Redis = Depends(get_redis), request: Request = None, ): """切换当前角色。 需要鉴权:已登录用户才能调用。 Args: body: 角色切换请求 current_user: 当前用户信息 Returns: Dict: 统一响应格式 """ try: # 从请求头获取 Token token = None auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] if not token: raise AppException(1001, "无法获取 Token") auth_service = _get_auth_service(redis_client) success = await auth_service.switch_role(token, body.role) if not success: raise AppException(1003, f"切换角色失败:用户没有 {body.role} 角色") # 获取更新后的用户信息 user_info = await auth_service.get_current_user(token) logger.info( f"用户切换角色: employee_id={current_user.employee_id}, " f"new_role={body.role}" ) return success_response(data=SwitchRoleResponse( success=True, current_role=body.role, message="角色切换成功", )) except AppException: raise except Exception as e: logger.error(f"切换角色异常: {e}", exc_info=True) raise AppException(1005, f"切换角色失败: {str(e)}") # -------------------------------------------------------------------------- # POST /api/auth/bind — 账号绑定(互联企业) # -------------------------------------------------------------------------- @router.post("/bind", response_model=None) async def bind_account( body: BindAccountRequest, request: Request, redis_client: aioredis.Redis = Depends(get_redis), db: AsyncSession = Depends(get_db), ): """账号绑定(互联企业)。 用于互联企业用户绑定已有账号。 Args: body: 账号绑定请求(含 employee_id, corp_id 等) request: 请求对象(用于获取客户端信息) Returns: Dict: 统一响应格式 """ try: from sqlalchemy import select from app.models.employee import Employee # corp_id 默认使用系统配置的主企业ID corp_id = body.corp_id or settings.wecom_corp_id employee_id = body.employee_id # 查询员工信息 stmt = select(Employee).where( Employee.corp_id == corp_id, Employee.employee_id == employee_id, ) result = await db.execute(stmt) employee = result.scalar_one_or_none() if not employee: raise AppException(1003, "员工信息不存在,请先通过企微认证") # 查询角色 from app.models.role import Role from app.models.user_role import UserRole role_stmt = ( select(Role) .join(UserRole, Role.id == UserRole.role_id) .where(UserRole.employee_id == employee_id) ) role_result = await db.execute(role_stmt) roles = role_result.scalars().all() if not roles: # 没有绑定角色:跳"无权限"页 raise AppException(1003, "该员工没有绑定角色,请联系管理员") # 选择最高权限角色 role_priority = {"admin": 3, "agent": 2, "user": 1} best_role = max(roles, key=lambda r: role_priority.get(r.name, 0)) role_name = best_role.name role_list = [r.name for r in roles] # 获取客户端信息 ip_address, user_agent = _get_client_info(request) # 创建 Token 并记录登录日志 auth_service = _get_auth_service(redis_client, db) result = await auth_service.create_token_and_login( employee_id=employee_id, name=employee.name, roles=role_list, corp_id=corp_id, login_method="bind", login_source="h5", department=employee.department, avatar=employee.avatar, ip_address=ip_address, user_agent=user_agent, ) return success_response(data=UnifiedAuthResponse(**result)) except AppException: raise except Exception as e: logger.error(f"账号绑定异常: {e}", exc_info=True) raise AppException(1005, f"账号绑定失败: {str(e)}")