Files
wecom_it_smart_desk/backend/app/api/auth.py
T

522 lines
17 KiB
Python
Raw Normal View History

# =============================================================================
# 企微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)}")