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