9c37c9a574
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列 测试: tests/test_phase_upgrades.py 97项全覆盖 CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式 修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, timedelta
|
|
import jwt
|
|
import bcrypt
|
|
import os
|
|
from dotenv import load_dotenv
|
|
|
|
from ..database import get_db
|
|
from ..models import User
|
|
from ..schemas import LoginRequest, TokenResponse, UserResponse
|
|
from ..core.audit_logger import audit_log
|
|
|
|
# 加载环境变量
|
|
load_dotenv()
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
# JWT 配置
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-this")
|
|
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
|
ACCESS_TOKEN_EXPIRE_DAYS = int(os.getenv("ACCESS_TOKEN_EXPIRE_DAYS", "7"))
|
|
|
|
# 默认管理员配置
|
|
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
|
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123")
|
|
|
|
def create_token(user: User) -> str:
|
|
"""生成 JWT token"""
|
|
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
|
payload = {
|
|
"sub": str(user.id),
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"org_id": user.org_id,
|
|
"exp": expire
|
|
}
|
|
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
|
|
|
def verify_token(token: str, db: Session) -> User:
|
|
"""验证 JWT token 并返回用户"""
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
user_id = int(payload["sub"])
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="用户不存在")
|
|
return user
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail="token 已过期")
|
|
except jwt.InvalidTokenError:
|
|
raise HTTPException(status_code=401, detail="无效的 token")
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
|
"""管理员登录(支持 bcrypt 密码验证)"""
|
|
client_ip = request.client.host if request.client else None
|
|
user_agent = request.headers.get("user-agent", "")
|
|
|
|
# 检查是否是默认管理员(bcrypt 验证)
|
|
if login_data.username == DEFAULT_ADMIN_USERNAME:
|
|
# 查询或创建管理员用户
|
|
user = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first()
|
|
if not user:
|
|
# 首次创建,密码哈希存储
|
|
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
|
|
user = User(
|
|
username=DEFAULT_ADMIN_USERNAME,
|
|
password_hash=hashed.decode('utf-8'),
|
|
role="admin",
|
|
org_id="default"
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
else:
|
|
# 验证密码
|
|
if not user.password_hash:
|
|
# 旧数据可能没有密码,设置为默认密码
|
|
user.password_hash = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
db.commit()
|
|
else:
|
|
# 验证密码是否匹配
|
|
if not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
|
|
# 记录失败日志
|
|
audit_log(
|
|
action="login_failed",
|
|
username=login_data.username,
|
|
details={"reason": "invalid_password"},
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
db=db
|
|
)
|
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
|
token = create_token(user)
|
|
# 记录成功登录日志
|
|
audit_log(
|
|
action="login",
|
|
user=user,
|
|
details={"role": user.role},
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
db=db
|
|
)
|
|
return TokenResponse(token=token, role=user.role, user=UserResponse.model_validate(user))
|
|
|
|
# 从数据库查询其他用户
|
|
user = db.query(User).filter(User.username == login_data.username).first()
|
|
if not user:
|
|
audit_log(
|
|
action="login_failed",
|
|
username=login_data.username,
|
|
details={"reason": "user_not_found"},
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
db=db
|
|
)
|
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
|
|
|
# bcrypt 验证
|
|
if not user.password_hash or not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
|
|
audit_log(
|
|
action="login_failed",
|
|
user=user,
|
|
details={"reason": "invalid_password"},
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
db=db
|
|
)
|
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
|
|
|
token = create_token(user)
|
|
audit_log(
|
|
action="login",
|
|
user=user,
|
|
details={"role": user.role},
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
db=db
|
|
)
|
|
return TokenResponse(token=token, role=user.role, user=UserResponse.model_validate(user))
|
|
|
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|
"""依赖项:验证用户登录"""
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="未提供认证令牌")
|
|
token = auth_header.split(" ")[1]
|
|
user = verify_token(token, db)
|
|
return user
|
|
|
|
def org_filter(current_user: User, model):
|
|
"""返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。"""
|
|
if current_user.role == "admin":
|
|
return True # no filter
|
|
return model.org_id == current_user.org_id
|
|
|
|
def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
|
"""依赖项:验证管理员权限"""
|
|
if current_user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
|
return current_user
|
|
@router.get("/me")
|
|
def get_me(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
"""获取当前登录用户信息"""
|
|
return {"user": UserResponse.model_validate(current_user)}
|
|
|