ForcomeBot 2.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/auth/middleware.py ADDED
@@ -0,0 +1,260 @@
1
+ """认证中间件"""
2
+ import logging
3
+ from typing import Optional, Callable, List
4
+ from datetime import datetime
5
+
6
+ from fastapi import Request, HTTPException, Depends
7
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from .database import get_db
12
+ from .models import User, OperationLog
13
+ from .jwt_handler import JWTHandler
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # HTTP Bearer认证
18
+ security = HTTPBearer(auto_error=False)
19
+
20
+ # 全局JWT处理器
21
+ _jwt_handler: Optional[JWTHandler] = None
22
+
23
+ # 认证开关
24
+ _auth_enabled: bool = True
25
+
26
+ # 白名单路由(不需要认证)
27
+ AUTH_WHITELIST = [
28
+ "/health",
29
+ "/qianxun/callback",
30
+ "/api/auth/dingtalk/login",
31
+ "/api/auth/dingtalk/h5-login",
32
+ "/api/auth/dingtalk/qrcode-url",
33
+ "/api/auth/dingtalk/config",
34
+ "/docs",
35
+ "/openapi.json",
36
+ "/redoc",
37
+ ]
38
+
39
+
40
+ def init_auth(jwt_handler: JWTHandler, auth_enabled: bool = True):
41
+ """初始化认证模块"""
42
+ global _jwt_handler, _auth_enabled
43
+ _jwt_handler = jwt_handler
44
+ _auth_enabled = auth_enabled
45
+ logger.info(f"认证模块已初始化, 认证开关: {auth_enabled}")
46
+
47
+
48
+ def is_auth_enabled() -> bool:
49
+ """检查认证是否启用"""
50
+ return _auth_enabled
51
+
52
+
53
+ def get_jwt_handler() -> JWTHandler:
54
+ """获取JWT处理器"""
55
+ if _jwt_handler is None:
56
+ raise RuntimeError("JWT处理器未初始化")
57
+ return _jwt_handler
58
+
59
+
60
+ class AuthMiddleware:
61
+ """认证中间件"""
62
+
63
+ def __init__(self, app):
64
+ self.app = app
65
+
66
+ async def __call__(self, scope, receive, send):
67
+ if scope["type"] == "http":
68
+ # 检查是否需要认证
69
+ path = scope.get("path", "")
70
+
71
+ # 白名单路由跳过认证
72
+ if self._is_whitelisted(path):
73
+ await self.app(scope, receive, send)
74
+ return
75
+
76
+ # 静态文件跳过认证
77
+ if path.startswith("/app/") or path.startswith("/static/"):
78
+ await self.app(scope, receive, send)
79
+ return
80
+
81
+ # 认证未启用时跳过
82
+ if not _auth_enabled:
83
+ await self.app(scope, receive, send)
84
+ return
85
+
86
+ await self.app(scope, receive, send)
87
+
88
+ def _is_whitelisted(self, path: str) -> bool:
89
+ """检查路径是否在白名单中"""
90
+ for whitelist_path in AUTH_WHITELIST:
91
+ if path == whitelist_path or path.startswith(whitelist_path + "/"):
92
+ return True
93
+ return False
94
+
95
+
96
+ async def get_current_user(
97
+ request: Request,
98
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
99
+ ) -> User:
100
+ """
101
+ 获取当前登录用户(必须登录)
102
+
103
+ 用于需要强制登录的接口
104
+ """
105
+ # 认证未启用时返回模拟用户
106
+ if not _auth_enabled:
107
+ return _create_mock_user()
108
+
109
+ if not credentials:
110
+ raise HTTPException(status_code=401, detail="未提供认证凭证")
111
+
112
+ if not _jwt_handler:
113
+ raise HTTPException(status_code=500, detail="认证服务未初始化")
114
+
115
+ # 验证Token
116
+ payload = _jwt_handler.verify_token(credentials.credentials)
117
+ if not payload:
118
+ raise HTTPException(status_code=401, detail="无效的认证凭证")
119
+
120
+ # 获取用户ID
121
+ user_id = payload.get("sub")
122
+ if not user_id:
123
+ raise HTTPException(status_code=401, detail="无效的用户信息")
124
+
125
+ # 从数据库获取用户
126
+ db = get_db()
127
+ async with db.session_factory() as session:
128
+ result = await session.execute(
129
+ select(User).where(User.id == int(user_id))
130
+ )
131
+ user = result.scalar_one_or_none()
132
+
133
+ if not user:
134
+ raise HTTPException(status_code=401, detail="用户不存在")
135
+
136
+ if not user.is_active:
137
+ raise HTTPException(status_code=403, detail="用户已被禁用")
138
+
139
+ return user
140
+
141
+
142
+ async def get_optional_user(
143
+ request: Request,
144
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
145
+ ) -> Optional[User]:
146
+ """
147
+ 获取当前登录用户(可选)
148
+
149
+ 用于不强制登录但需要用户信息的接口
150
+ """
151
+ # 认证未启用时返回模拟用户
152
+ if not _auth_enabled:
153
+ return _create_mock_user()
154
+
155
+ if not credentials:
156
+ return None
157
+
158
+ if not _jwt_handler:
159
+ return None
160
+
161
+ # 验证Token
162
+ payload = _jwt_handler.verify_token(credentials.credentials)
163
+ if not payload:
164
+ return None
165
+
166
+ # 获取用户ID
167
+ user_id = payload.get("sub")
168
+ if not user_id:
169
+ return None
170
+
171
+ # 从数据库获取用户
172
+ try:
173
+ db = get_db()
174
+ async with db.session_factory() as session:
175
+ result = await session.execute(
176
+ select(User).where(User.id == int(user_id))
177
+ )
178
+ user = result.scalar_one_or_none()
179
+ return user if user and user.is_active else None
180
+ except Exception as e:
181
+ logger.warning(f"获取用户信息失败: {e}")
182
+ return None
183
+
184
+
185
+ def _create_mock_user() -> User:
186
+ """创建模拟用户(认证关闭时使用)"""
187
+ user = User(
188
+ id=0,
189
+ dingtalk_userid="mock_user",
190
+ name="系统用户",
191
+ is_active=True,
192
+ is_admin=True
193
+ )
194
+ return user
195
+
196
+
197
+ async def log_operation(
198
+ user: Optional[User],
199
+ action: str,
200
+ resource: str,
201
+ resource_id: Optional[str] = None,
202
+ detail: Optional[dict] = None,
203
+ request: Optional[Request] = None,
204
+ status: str = "success",
205
+ error_message: Optional[str] = None
206
+ ):
207
+ """
208
+ 记录操作日志
209
+
210
+ Args:
211
+ user: 操作用户
212
+ action: 操作类型
213
+ resource: 操作资源
214
+ resource_id: 资源ID
215
+ detail: 操作详情
216
+ request: 请求对象
217
+ status: 操作状态
218
+ error_message: 错误信息
219
+ """
220
+ try:
221
+ db = get_db()
222
+ async with db.session_factory() as session:
223
+ log = OperationLog(
224
+ user_id=user.id if user and user.id else None,
225
+ user_name=user.name if user else None,
226
+ action=action,
227
+ resource=resource,
228
+ resource_id=resource_id,
229
+ detail=detail,
230
+ method=request.method if request else None,
231
+ path=str(request.url.path) if request else None,
232
+ ip_address=_get_client_ip(request) if request else None,
233
+ user_agent=request.headers.get("user-agent", "")[:512] if request else None,
234
+ status=status,
235
+ error_message=error_message,
236
+ created_at=datetime.now()
237
+ )
238
+ session.add(log)
239
+ await session.commit()
240
+ except Exception as e:
241
+ logger.error(f"记录操作日志失败: {e}")
242
+
243
+
244
+ def _get_client_ip(request: Request) -> str:
245
+ """获取客户端IP"""
246
+ # 优先从X-Forwarded-For获取
247
+ forwarded = request.headers.get("x-forwarded-for")
248
+ if forwarded:
249
+ return forwarded.split(",")[0].strip()
250
+
251
+ # 从X-Real-IP获取
252
+ real_ip = request.headers.get("x-real-ip")
253
+ if real_ip:
254
+ return real_ip
255
+
256
+ # 从连接获取
257
+ if request.client:
258
+ return request.client.host
259
+
260
+ return "unknown"
src/auth/models.py ADDED
@@ -0,0 +1,107 @@
1
+ """数据库模型定义"""
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ from sqlalchemy import String, Boolean, DateTime, Integer, Text, JSON, ForeignKey
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from .database import Base
9
+
10
+
11
+ class User(Base):
12
+ """用户表"""
13
+ __tablename__ = "users"
14
+
15
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
16
+
17
+ # 钉钉用户信息
18
+ dingtalk_userid: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="钉钉用户ID")
19
+ dingtalk_unionid: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="钉钉UnionID")
20
+
21
+ # 基本信息
22
+ name: Mapped[str] = mapped_column(String(64), comment="姓名")
23
+ avatar: Mapped[Optional[str]] = mapped_column(String(512), nullable=True, comment="头像URL")
24
+ mobile: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="手机号")
25
+ email: Mapped[Optional[str]] = mapped_column(String(128), nullable=True, comment="邮箱")
26
+ department: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, comment="部门")
27
+ title: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="职位")
28
+
29
+ # 状态
30
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
31
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否管理员")
32
+
33
+ # 时间戳
34
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
35
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
36
+ last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后登录时间")
37
+
38
+ # 关联
39
+ operation_logs: Mapped[list["OperationLog"]] = relationship("OperationLog", back_populates="user")
40
+
41
+ def to_dict(self) -> dict:
42
+ """转换为字典"""
43
+ return {
44
+ "id": self.id,
45
+ "dingtalk_userid": self.dingtalk_userid,
46
+ "name": self.name,
47
+ "avatar": self.avatar,
48
+ "mobile": self.mobile,
49
+ "email": self.email,
50
+ "department": self.department,
51
+ "title": self.title,
52
+ "is_active": self.is_active,
53
+ "is_admin": self.is_admin,
54
+ "created_at": self.created_at.isoformat() if self.created_at else None,
55
+ "last_login": self.last_login.isoformat() if self.last_login else None,
56
+ }
57
+
58
+
59
+ class OperationLog(Base):
60
+ """操作日志表"""
61
+ __tablename__ = "operation_logs"
62
+
63
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
64
+
65
+ # 用户信息
66
+ user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, comment="用户ID")
67
+ user_name: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="用户名称(冗余存储)")
68
+
69
+ # 操作信息
70
+ action: Mapped[str] = mapped_column(String(64), index=True, comment="操作类型")
71
+ resource: Mapped[str] = mapped_column(String(128), comment="操作资源")
72
+ resource_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="资源ID")
73
+ detail: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="操作详情")
74
+
75
+ # 请求信息
76
+ method: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, comment="请求方法")
77
+ path: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, comment="请求路径")
78
+ ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="IP地址")
79
+ user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True, comment="浏览器UA")
80
+
81
+ # 结果
82
+ status: Mapped[str] = mapped_column(String(16), default="success", comment="操作状态: success/failed")
83
+ error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="错误信息")
84
+
85
+ # 时间戳
86
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, index=True, comment="操作时间")
87
+
88
+ # 关联
89
+ user: Mapped[Optional["User"]] = relationship("User", back_populates="operation_logs")
90
+
91
+ def to_dict(self) -> dict:
92
+ """转换为字典"""
93
+ return {
94
+ "id": self.id,
95
+ "user_id": self.user_id,
96
+ "user_name": self.user_name,
97
+ "action": self.action,
98
+ "resource": self.resource,
99
+ "resource_id": self.resource_id,
100
+ "detail": self.detail,
101
+ "method": self.method,
102
+ "path": self.path,
103
+ "ip_address": self.ip_address,
104
+ "status": self.status,
105
+ "error_message": self.error_message,
106
+ "created_at": self.created_at.isoformat() if self.created_at else None,
107
+ }