923ff63a3d
后端: - 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) - 现有接口不变,完全向后兼容旧版本
349 lines
10 KiB
Python
349 lines
10 KiB
Python
"""
|
||
认证模块 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. 签发双 Token(Access + 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="昵称修改成功",
|
||
)
|