Files
live-hub-py/web/backend/routers/admin_users.py
T
yml2213 2f5a63ae27 feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
2026-10-21 22:00:03 +08:00

124 lines
3.7 KiB
Python

"""管理端账号安全设置:绑定手机号、重置绑定设备。"""
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_permission
from ..errors import ApiError
from ..models import AuditLog, User, UserDevice
from ..schemas import UserInfo, UserPhoneBindRequest
from ..services.dc_auth_service import mask_device_id
from ..services.login_sms_service import mask_phone, normalize_phone
from ..services.user_profile_service import to_user_info
router = APIRouter(prefix="/api/admin/users", tags=["用户管理"])
def _get_user(db: Session, user_id: int) -> User:
user = db.query(User).filter(User.id == user_id, User.deleted_at.is_(None)).first()
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
return user
def _active_device(db: Session, user_id: int) -> UserDevice | None:
return (
db.query(UserDevice)
.filter(UserDevice.user_id == user_id, UserDevice.status == "active")
.order_by(UserDevice.id.desc())
.first()
)
@router.post("/{user_id}/phone", response_model=UserInfo)
def bind_user_phone(
user_id: int,
req: UserPhoneBindRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("user:edit")),
):
user = _get_user(db, user_id)
phone = normalize_phone(req.phone)
if not phone:
raise ApiError(400, "请输入手机号", "phone_required")
existing = db.query(User).filter(User.phone == phone, User.id != user.id).first()
if existing is not None:
raise ApiError(409, "该手机号已绑定其他用户", "phone_exists")
now = datetime.now(UTC)
user.phone = phone
user.phone_bound_at = now
user.phone_bound_by = current.id
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="user:phone_bind",
target=user.username,
detail=f"phone={mask_phone(phone)}",
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise ApiError(409, "该手机号已绑定其他用户", "phone_exists") from exc
db.refresh(user)
return to_user_info(user, _active_device(db, user.id))
@router.post("/{user_id}/device/reset")
def reset_user_device(
user_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("user:edit")),
):
user = _get_user(db, user_id)
now = datetime.now(UTC)
active = _active_device(db, user.id)
revoked = (
db.query(UserDevice)
.filter(UserDevice.user_id == user.id, UserDevice.status == "active")
.update(
{
UserDevice.status: "revoked",
UserDevice.revoked_at: now,
UserDevice.revoked_by: current.id,
UserDevice.updated_at: now,
},
synchronize_session=False,
)
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="user:device_reset",
target=user.username,
detail=(
f"revoked={revoked}; device={mask_device_id(active.device_id)}"
if active is not None
else "revoked=0"
),
)
)
db.commit()
return {
"success": True,
"reset": revoked > 0,
"revoked": revoked,
"message": "已重置设备,下次登录将重新绑定"
if revoked
else "当前没有已绑定设备",
}