Files
meijiaka-zy/python-api/app/api/v1/auth.py
T
小鱼开发 923ff63a3d feat: 密码登录功能(验证码/密码双模式 + 忘记密码 + 设置密码)
后端:
- security.py: 新增 bcrypt 密码哈希/校验工具
- auth_service.py: 新增 login_with_password、reset_password_with_sms
- auth.py: 新增 /login-password、/has-password、/set-password、/reset-password 接口
- schemas/auth.py: 新增 PasswordLoginRequest、SetPasswordRequest、ResetPasswordRequest、CheckPasswordResponse
- crud/user.py: 新增 update_password

前端:
- Login.tsx: 支持验证码/密码切换登录,密码模式下显示忘记密码入口
- Login.css: 新增登录方式切换标签、密码输入框样式
- authStore.ts: 新增 loginWithPassword
- Settings.tsx: 新增账号安全区块,显示密码状态,打开设置/修改密码弹窗
- SetPasswordModal.tsx: 设置/修改密码弹窗(旧密码校验、密码显示切换、表单验证)
- ResetPasswordModal.tsx: 忘记密码弹窗(手机号+验证码+新密码重置)

兼容:
- 零数据库迁移,password_hash 字段已存在(nullable)
- 现有接口不变,完全向后兼容旧版本
2026-06-09 23:26:50 +08:00

349 lines
10 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.
"""
认证模块 API
============
采用"手机号 + JWT + 单设备登录"的认证方案。
核心接口:
- /login: 手机号验证码登录(签发双 Token + SSE 踢人)
- /refresh: Refresh Token 轮换(换取新 Token 对)
- /logout: 登出(删除设备记录)
- /send-code: 发送短信验证码
- /me: 获取当前用户信息
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import get_current_user
from app.crud import user as user_crud
from app.db.session import AsyncSession, get_db
from app.models.user import User
from app.schemas.auth import (
CheckPasswordResponse,
MobileLoginRequest,
PasswordLoginRequest,
RefreshTokenRequest,
ResetPasswordRequest,
SendSmsCodeRequest,
SetPasswordRequest,
TokenResponse,
)
from app.schemas.common import ApiResponse, success_response
from app.schemas.user import UpdateNicknameRequest, UserProfileResponse
from app.services.auth_service import (
login_with_password,
login_with_sms,
logout,
refresh_access_token,
reset_password_with_sms,
send_sms_code,
)
router = APIRouter()
@router.post("/send-code", response_model=ApiResponse[dict])
async def send_code(
request: SendSmsCodeRequest,
db: AsyncSession = Depends(get_db),
):
"""
发送短信验证码
开发阶段验证码直接打印到日志,生产环境接入短信服务商。
每个手机号每日最多发送 10 次。
"""
# 校验用户是否存在
user = await user_crud.get_by_mobile(db, mobile=request.mobile)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在",
)
try:
await send_sms_code(request.mobile)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(e),
)
return success_response(
data={"expire_minutes": 5},
message="验证码已发送",
)
@router.post("/login", response_model=ApiResponse[TokenResponse])
async def login(
request: MobileLoginRequest,
db: AsyncSession = Depends(get_db),
http_request: Request = None,
):
"""
手机号验证码登录
流程:
1. 校验验证码
2. 获取或创建用户
3. 踢掉旧设备(SSE 推送)
4. 创建/覆盖设备记录
5. 签发双 TokenAccess + Refresh
"""
# 获取客户端 IP(优先从 Nginx 转发头读取真实公网 IP)
client_ip = None
if http_request:
xff = http_request.headers.get("x-forwarded-for")
if xff:
client_ip = xff.split(",")[0].strip()
else:
xri = http_request.headers.get("x-real-ip")
client_ip = xri or (http_request.client.host if http_request.client else None)
try:
result = await login_with_sms(
db,
mobile=request.mobile,
code=request.code,
device_id=request.device_id,
device_name=request.device_name,
os_info=request.os_info,
app_version=request.app_version,
ip=client_ip,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return success_response(
data=TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
user=result["user"],
),
message="登录成功",
)
@router.post("/login-password", response_model=ApiResponse[TokenResponse])
async def login_password(
request: PasswordLoginRequest,
db: AsyncSession = Depends(get_db),
http_request: Request = None,
):
"""
手机号密码登录
流程与验证码登录一致,只是校验方式改为密码。
"""
client_ip = None
if http_request:
xff = http_request.headers.get("x-forwarded-for")
if xff:
client_ip = xff.split(",")[0].strip()
else:
xri = http_request.headers.get("x-real-ip")
client_ip = xri or (http_request.client.host if http_request.client else None)
try:
result = await login_with_password(
db,
mobile=request.mobile,
password=request.password,
device_id=request.device_id,
device_name=request.device_name,
os_info=request.os_info,
app_version=request.app_version,
ip=client_ip,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return success_response(
data=TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
user=result["user"],
),
message="登录成功",
)
@router.get("/has-password", response_model=ApiResponse[CheckPasswordResponse])
async def check_has_password(
current_user: User = Depends(get_current_user),
):
"""检查当前用户是否已设置密码"""
return success_response(
data=CheckPasswordResponse(has_password=bool(current_user.password_hash)),
message="查询成功",
)
@router.post("/set-password", response_model=ApiResponse[dict])
async def set_password(
request: SetPasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
设置或修改密码
- 首次设置密码:old_password 可不传
- 修改密码:必须传 old_password 校验
"""
from app.core.security import hash_password, verify_password
user = await user_crud.get(db, id=current_user.id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
# 如果已有密码,必须提供旧密码
if user.password_hash:
if not request.old_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="修改密码需要提供旧密码",
)
if not verify_password(request.old_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="旧密码错误",
)
# 更新密码
new_hash = hash_password(request.new_password)
await user_crud.update_password(db, user_id=user.id, password_hash=new_hash)
return success_response(data={}, message="密码设置成功")
@router.post("/reset-password", response_model=ApiResponse[dict])
async def reset_password(
request: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
"""
短信验证码重置密码
无需登录,通过短信验证码验证身份后直接重置密码。
"""
try:
await reset_password_with_sms(
db,
mobile=request.mobile,
code=request.code,
new_password=request.new_password,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
return success_response(data={}, message="密码重置成功")
@router.post("/refresh", response_model=ApiResponse[dict])
async def refresh_token(
request: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
"""
用 Refresh Token 换取新的 Token 对(Token 轮换)
每次刷新都会生成全新的 Access Token 和 Refresh Token
同时更新设备记录中的 refresh_token_hash。
"""
try:
result = await refresh_access_token(db, refresh_token=request.refresh_token)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
return success_response(
data={
"access_token": result["access_token"],
"refresh_token": result["refresh_token"],
},
message="Token 刷新成功",
)
@router.post("/logout", response_model=ApiResponse[dict])
async def do_logout(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
用户登出
删除设备记录,注销 SSE 连接,使当前设备的 Token 失效。
"""
await logout(db, user_id=current_user.id)
return success_response(
data={},
message="登出成功",
)
@router.get("/me", response_model=ApiResponse[UserProfileResponse])
async def get_me(
current_user: User = Depends(get_current_user),
):
"""获取当前登录用户信息"""
return success_response(
data=UserProfileResponse(
id=current_user.id,
mobile=current_user.mobile,
nickname=current_user.nickname,
avatar=current_user.avatar_url or "",
status=current_user.status,
source=current_user.source,
last_login_at=current_user.last_login_at,
created_at=current_user.created_at,
)
)
@router.patch("/me", response_model=ApiResponse[UserProfileResponse])
async def update_me(
request: UpdateNicknameRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""修改当前用户信息(仅支持修改昵称)"""
# current_user 来自 get_current_user 的会话,和当前 db 不是同一个会话
# 需要重新查询到当前会话中才能正确提交
user = await user_crud.get(db, id=current_user.id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
user.nickname = request.nickname.strip()
await db.commit()
await db.refresh(user)
return success_response(
data=UserProfileResponse(
id=user.id,
mobile=user.mobile,
nickname=user.nickname,
avatar=user.avatar_url or "",
status=user.status,
source=user.source,
last_login_at=user.last_login_at,
created_at=user.created_at,
),
message="昵称修改成功",
)