Files
live-hub-py/web/backend/routers/auth.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

266 lines
7.7 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.
"""认证路由:安全登录(短信 + DCAuth v2)与旧账号密码兜底。"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..errors import ApiError
from ..models import AuditLog, DeviceChallenge, User
from ..permissions import ROLE_LABELS, get_user_permissions
from ..schemas import (
LoginCompleteRequest,
LoginPrepareRequest,
LoginPrepareResponse,
LoginRequest,
SmsCodeRequest,
SmsCodeResponse,
TokenResponse,
)
from ..security import ACCESS_TOKEN_EXPIRE_HOURS, create_access_token, verify_password
from ..services.dc_auth_service import (
APP_ID,
CHALLENGE_PURPOSE,
consume_device_ticket,
create_login_challenge,
ensure_device_binding,
get_challenge,
mask_device_id,
normalize_api_base,
)
from ..services.login_sms_service import (
mask_phone,
send_login_sms_code,
verify_login_sms_code,
)
router = APIRouter(prefix="/api/auth", tags=["认证"])
# Cookie 安全配置(生产环境 HTTPS 部署时设置 COOKIE_SECURE=true)
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
def _find_user(db: Session, username_value: str) -> User | None:
"""用户名只 trim,不做大小写折叠、不限制字符集。
MySQL 默认排序规则大小写不敏感,因此查出来后还要做一次 Python 侧精确比对,
避免 ``Admin`` 命中 ``admin``。
"""
username = str(username_value or "").strip()
if not username:
return None
user = db.query(User).filter(User.username == username).first()
if user is not None and user.username != username:
return None
return user
def _authenticate(db: Session, username: str, password: str) -> User:
user = _find_user(db, username)
if user is None or not verify_password(password, user.password_hash):
raise ApiError(401, "用户名或密码错误", "invalid_credentials")
if not user.is_active or user.deleted_at is not None:
raise ApiError(403, "账号已禁用,请联系管理员", "user_disabled")
return user
def _request_origin(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin:
return origin
return str(request.base_url).rstrip("/")
def _set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=_COOKIE_SECURE,
samesite="lax",
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
path="/",
)
def _issue_session(
db: Session,
response: Response,
user: User,
method: str,
device_id: str = "",
) -> TokenResponse:
token = create_access_token({"sub": str(user.id), "role": user.role})
_set_auth_cookie(response, token)
detail = f"method={method}"
if device_id:
detail += f"; device={mask_device_id(device_id)}"
db.add(
AuditLog(
user_id=user.id,
username=user.username,
action="login",
target="auth",
detail=detail,
)
)
db.commit()
return TokenResponse(
access_token=token,
role=user.role,
username=user.username,
permissions=get_user_permissions(user),
)
def _require_phone(user: User) -> str:
phone = str(user.phone or "").strip()
if not phone:
raise ApiError(
409,
"该账号尚未绑定手机号,请联系管理员",
"phone_unbound",
)
return phone
@router.post("/sms-code", response_model=SmsCodeResponse)
def send_sms_code(
req: SmsCodeRequest,
request: Request,
db: Session = Depends(get_db),
):
user = _authenticate(db, req.username, req.password)
phone = _require_phone(user)
request_ip = request.client.host if request.client else ""
result = send_login_sms_code(db, user, phone, request_ip=request_ip)
return SmsCodeResponse(
sent=True,
phone_masked=result["phoneMasked"],
expires_in_seconds=result["expiresInSeconds"],
provider=result["provider"],
)
@router.post("/prepare", response_model=LoginPrepareResponse)
def prepare_login(
req: LoginPrepareRequest,
request: Request,
db: Session = Depends(get_db),
):
user = _authenticate(db, req.username, req.password)
phone = _require_phone(user)
# 先校验并消费短信码,再创建 challenge(与参考实现一致)。
verify_login_sms_code(db, user.id, req.sms_code)
api_base = normalize_api_base(req.api_base or _request_origin(request))
challenge = create_login_challenge(db, user.id, api_base)
return LoginPrepareResponse(
challenge_id=challenge["challengeId"],
challenge=challenge["challenge"],
expires_at=challenge["expiresAt"],
poll_token=challenge["pollToken"],
launch_url=challenge["launchUrl"],
phone_masked=mask_phone(phone),
app=challenge["app"],
)
@router.post("/complete", response_model=TokenResponse)
def complete_login(
req: LoginCompleteRequest,
response: Response,
db: Session = Depends(get_db),
):
challenge = get_challenge(db, req.challenge_id)
if (
challenge is None
or challenge.user_id is None
or challenge.app != APP_ID
or challenge.purpose != CHALLENGE_PURPOSE
):
raise ApiError(
404,
"设备认证会话不存在,请重新登录",
"challenge_not_found",
)
user = (
db.query(User)
.filter(User.id == challenge.user_id, User.deleted_at.is_(None))
.first()
)
if user is None or not user.is_active:
raise ApiError(401, "账号已不可用,请重新登录", "user_disabled")
consumed: DeviceChallenge = consume_device_ticket(
db,
req.challenge_id,
req.poll_token,
req.device_ticket,
)
if consumed.user_id != user.id:
raise ApiError(401, "设备认证票据无效", "ticket_invalid")
ensure_device_binding(db, user, consumed)
return _issue_session(db, response, user, "sms_device", consumed.device_id)
@router.post("/login", response_model=TokenResponse)
def login(
req: LoginRequest,
response: Response,
db: Session = Depends(get_db),
):
"""旧账号兜底:仅当用户未绑定手机号时允许密码单因素登录。"""
user = _authenticate(db, req.username, req.password)
if str(user.phone or "").strip():
raise ApiError(
403,
"该账号已启用安全登录,请使用用户名 + 密码 + 短信验证码 + 本机验证",
"secure_login_required",
)
return _issue_session(db, response, user, "password_legacy")
@router.get("/me")
def me(current_user: User = Depends(get_current_user)):
return {
"id": current_user.id,
"username": current_user.username,
"role": current_user.role,
"role_label": ROLE_LABELS.get(current_user.role, current_user.role),
"is_active": current_user.is_active,
"remark": current_user.remark,
"permissions": get_user_permissions(current_user),
"phone_masked": mask_phone(current_user.phone),
"phone_bound": bool(str(current_user.phone or "").strip()),
}
@router.post("/logout")
def logout(
response: Response,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
response.delete_cookie(key="access_token", path="/")
db.add(
AuditLog(
user_id=current_user.id,
username=current_user.username,
action="logout",
target="auth",
)
)
db.commit()
return {"message": "已登出"}