- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064 - 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置 - 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录 - 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
124 lines
3.7 KiB
Python
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 "当前没有已绑定设备",
|
|
}
|