Files
weibo_signin/backend/auth_service/app/main.py

541 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Weibo-HotSign Authentication Service
Main FastAPI application entry point
"""
from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func as sa_func
import uvicorn
import os
import logging
import secrets
from datetime import datetime
# 过滤 /health 的 access log避免日志刷屏
class _HealthFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return "/health" not in record.getMessage()
logging.getLogger("uvicorn.access").addFilter(_HealthFilter())
from shared.models import get_db, User, InviteCode
from shared.config import shared_settings
from auth_service.app.models.database import create_tables
from auth_service.app.schemas.user import (
UserCreate, UserLogin, UserResponse, Token, TokenData,
RefreshTokenRequest, AuthResponse, WxLoginRequest,
)
from auth_service.app.services.auth_service import AuthService
from auth_service.app.utils.security import (
verify_password, create_access_token, decode_access_token,
create_refresh_token, verify_refresh_token, revoke_refresh_token,
)
# Configure logger
logger = logging.getLogger(__name__)
# Initialize FastAPI app
app = FastAPI(
title="Weibo-HotSign Authentication Service",
description="Handles user authentication and authorization for Weibo-HotSign system",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# CORS middleware configuration
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:80"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Security scheme for JWT
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db)
) -> UserResponse:
"""
Dependency to get current user from JWT token
"""
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated",
)
return UserResponse.from_orm(user)
@app.on_event("startup")
async def startup_event():
"""Initialize database tables on startup"""
# 表已通过 create_sqlite_db.py 创建,无需重复创建
# await create_tables()
pass
@app.get("/")
async def root():
return {
"service": "Weibo-HotSign Authentication Service",
"status": "running",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
"""
Register a new user account and return tokens.
Requires a valid invite code.
"""
auth_service = AuthService(db)
# Validate invite code
result = await db.execute(
select(InviteCode).where(
InviteCode.code == user_data.invite_code,
InviteCode.is_used == False,
)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邀请码无效或已被使用",
)
# Check if user already exists
email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username)
if email_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists"
)
if username_user:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken"
)
# Create new user
try:
user = await auth_service.create_user(user_data)
# Mark invite code as used
invite.is_used = True
invite.used_by = str(user.id)
invite.used_at = datetime.utcnow()
await db.commit()
# Create tokens for auto-login
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
refresh_token = await create_refresh_token(str(user.id))
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=3600,
user=UserResponse.from_orm(user)
)
except HTTPException:
raise # 直接传递 HTTPException如密码强度不够
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create user: {str(e)}"
)
@app.post("/auth/login", response_model=AuthResponse)
async def login_user(login_data: UserLogin, db: AsyncSession = Depends(get_db)):
"""
Authenticate user and return JWT token with user info
"""
auth_service = AuthService(db)
# Find user by email
user = await auth_service.get_user_by_email(login_data.email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
# Verify password
if not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password"
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
# Create access token
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
# Create refresh token (stored in Redis)
refresh_token = await create_refresh_token(str(user.id))
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=3600,
user=UserResponse.from_orm(user)
)
@app.post("/auth/refresh", response_model=Token)
async def refresh_token(body: RefreshTokenRequest, db: AsyncSession = Depends(get_db)):
"""
Exchange a valid refresh token for a new access + refresh token pair (Token Rotation).
The old refresh token is revoked immediately.
"""
# Verify the incoming refresh token
user_id = await verify_refresh_token(body.refresh_token)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
# Ensure the user still exists and is active
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or deactivated",
)
# Revoke old token, issue new pair
await revoke_refresh_token(body.refresh_token)
new_access = create_access_token(data={"sub": str(user.id), "username": user.username})
new_refresh = await create_refresh_token(str(user.id))
return Token(
access_token=new_access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=3600,
)
@app.get("/auth/me", response_model=UserResponse)
async def get_current_user_info(current_user: UserResponse = Depends(get_current_user)):
"""
Get current user information
"""
return current_user
@app.post("/auth/wx-login", response_model=AuthResponse)
async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)):
"""
微信小程序登录。
流程:
1. 用 code 调微信 code2Session 接口换取 openid
2. 查找是否已有该 openid 的用户
3. 没有则自动注册,有则直接登录
4. 返回 JWT token
"""
import httpx
appid = shared_settings.WX_APPID
secret = shared_settings.WX_SECRET
if not appid or not secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="微信小程序未配置 APPID 和 SECRET",
)
# Step 1: code 换 openid
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": appid,
"secret": secret,
"js_code": body.code,
"grant_type": "authorization_code",
},
)
wx_data = resp.json()
openid = wx_data.get("openid")
if not openid:
errcode = wx_data.get("errcode", "unknown")
errmsg = wx_data.get("errmsg", "未知错误")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"微信登录失败: {errmsg} (errcode={errcode})",
)
# Step 2: 查找已有用户
result = await db.execute(select(User).where(User.wx_openid == openid))
user = result.scalar_one_or_none()
if user:
# 已有用户 — 更新昵称头像(如果传了)
if body.nickname and body.nickname != user.wx_nickname:
user.wx_nickname = body.nickname
if body.avatar_url and body.avatar_url != user.wx_avatar:
user.wx_avatar = body.avatar_url
await db.commit()
await db.refresh(user)
else:
# Step 3: 自动注册
import uuid
nickname = body.nickname or f"wx_{openid[:8]}"
# 生成唯一 username避免冲突
username = f"wx_{openid[:12]}"
user = User(
id=str(uuid.uuid4()),
username=username,
wx_openid=openid,
wx_nickname=nickname,
wx_avatar=body.avatar_url,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"微信用户自动注册: openid={openid[:16]}..., username={username}")
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已被禁用",
)
# Step 4: 签发 token
access_token = create_access_token(
data={"sub": str(user.id), "username": user.username}
)
refresh_token = await create_refresh_token(str(user.id))
return AuthResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in=3600,
user=UserResponse.from_orm(user),
)
# ===================== Admin Endpoints =====================
async def require_admin(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Dependency: require admin user."""
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found")
if not user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@app.get("/admin/users")
async def admin_list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员查看所有用户"""
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return {
"success": True,
"data": [
{
"id": str(u.id),
"username": u.username,
"email": u.email,
"is_active": u.is_active,
"is_admin": u.is_admin,
"created_at": str(u.created_at) if u.created_at else None,
}
for u in users
],
}
@app.put("/admin/users/{user_id}/toggle")
async def admin_toggle_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员启用/禁用用户"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if str(user.id) == str(admin.id):
raise HTTPException(status_code=400, detail="不能禁用自己")
user.is_active = not user.is_active
await db.commit()
return {"success": True, "is_active": user.is_active}
@app.post("/admin/invite-codes")
async def admin_create_invite_code(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员生成邀请码"""
code = secrets.token_urlsafe(8)[:12].upper()
invite = InviteCode(code=code, created_by=str(admin.id))
db.add(invite)
await db.commit()
await db.refresh(invite)
return {
"success": True,
"data": {
"id": str(invite.id),
"code": invite.code,
"created_at": str(invite.created_at),
},
}
@app.get("/admin/invite-codes")
async def admin_list_invite_codes(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员查看所有邀请码"""
result = await db.execute(select(InviteCode).order_by(InviteCode.created_at.desc()))
codes = result.scalars().all()
return {
"success": True,
"data": [
{
"id": str(c.id),
"code": c.code,
"is_used": c.is_used,
"used_by": c.used_by,
"created_at": str(c.created_at) if c.created_at else None,
"used_at": str(c.used_at) if c.used_at else None,
}
for c in codes
],
}
@app.delete("/admin/invite-codes/{code_id}")
async def admin_delete_invite_code(
code_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员删除未使用的邀请码"""
result = await db.execute(select(InviteCode).where(InviteCode.id == code_id))
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="邀请码不存在")
if invite.is_used:
raise HTTPException(status_code=400, detail="已使用的邀请码不能删除")
await db.delete(invite)
await db.commit()
return {"success": True}
# ===================== System Config Endpoints =====================
from shared.models.system_config import SystemConfig
@app.get("/admin/config")
async def admin_get_config(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取系统配置"""
result = await db.execute(select(SystemConfig))
rows = result.scalars().all()
config = {r.key: r.value for r in rows}
return {"success": True, "data": config}
@app.put("/admin/config")
async def admin_update_config(
body: dict,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""更新系统配置,同时通知调度器重新加载"""
allowed_keys = {"webhook_url", "daily_report_hour", "daily_report_minute"}
for key, value in body.items():
if key not in allowed_keys:
continue
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
row = result.scalar_one_or_none()
if row:
row.value = str(value)
else:
db.add(SystemConfig(key=key, value=str(value)))
await db.commit()
# 通过 Redis 通知调度器重新加载配置
try:
import redis.asyncio as aioredis
r = aioredis.from_url(shared_settings.REDIS_URL, decode_responses=True)
await r.publish("config_updates", "reload")
await r.close()
except Exception as e:
logger.warning(f"通知调度器失败(不影响保存): {e}")
return {"success": True, "message": "配置已保存"}