Files
disknext/service/user/login.py
于小丘 a99091ea7a feat: embed permission claims in JWT and add captcha verification
- Add GroupClaims model for JWT permission snapshots
- Add JWTPayload model for typed JWT decoding
- Refactor auth middleware: jwt_required (no DB) -> admin_required (no DB) -> auth_required (DB)
- Add UserBanStore for instant ban enforcement via Redis + memory fallback
- Fix status check bug: StrEnum is always truthy, use explicit != ACTIVE
- Shorten access_token expiry from 3h to 1h
- Add CaptchaScene enum and verify_captcha_if_needed service
- Add require_captcha dependency injection factory
- Add CLA document and new default settings
- Update all tests for new JWT API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:07:48 +08:00

89 lines
3.1 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.
from uuid import uuid4
from loguru import logger
from middleware.dependencies import SessionDep
from sqlmodels import LoginRequest, TokenResponse, User
from sqlmodels.group import GroupClaims, GroupOptions
from sqlmodels.user import UserStatus
from utils import http_exceptions
from utils.JWT import create_access_token, create_refresh_token
from utils.password.pwd import Password, PasswordStatus
async def login(
session: SessionDep,
login_request: LoginRequest,
) -> TokenResponse:
"""
根据账号密码进行登录。
如果登录成功,返回一个 TokenResponse 对象,包含访问令牌和刷新令牌以及它们的过期时间。
:param session: 数据库会话
:param login_request: 登录请求
:return: TokenResponse 对象或状态码或 None
"""
# 获取用户信息(预加载 group 关系)
current_user: User = await User.get(
session,
User.email == login_request.email,
fetch_mode="first",
load=User.group,
) #type: ignore
# 验证用户是否存在
if not current_user:
logger.debug(f"Cannot find user with email: {login_request.email}")
http_exceptions.raise_unauthorized("Invalid email or password")
# 验证密码是否正确
if Password.verify(current_user.password, login_request.password) != PasswordStatus.VALID:
logger.debug(f"Password verification failed for user: {login_request.email}")
http_exceptions.raise_unauthorized("Invalid email or password")
# 验证用户是否可登录修复显式枚举比较StrEnum 永远 truthy
if current_user.status != UserStatus.ACTIVE:
http_exceptions.raise_forbidden("Your account is disabled")
# 检查两步验证
if current_user.two_factor:
# 用户已启用两步验证
if not login_request.two_fa_code:
logger.debug(f"2FA required for user: {login_request.email}")
http_exceptions.raise_precondition_required("2FA required")
# 验证 OTP 码
if Password.verify_totp(current_user.two_factor, login_request.two_fa_code) != PasswordStatus.VALID:
logger.debug(f"Invalid 2FA code for user: {login_request.email}")
http_exceptions.raise_unauthorized("Invalid 2FA code")
# 加载 GroupOptions
group_options: GroupOptions | None = await GroupOptions.get(
session,
GroupOptions.group_id == current_user.group_id,
)
# 构建权限快照
current_user.group.options = group_options
group_claims = GroupClaims.from_group(current_user.group)
# 创建令牌
access_token = create_access_token(
sub=current_user.id,
jti=uuid4(),
status=current_user.status.value,
group=group_claims,
)
refresh_token = create_refresh_token(
sub=current_user.id,
jti=uuid4()
)
return TokenResponse(
access_token=access_token.access_token,
access_expires=access_token.access_expires,
refresh_token=refresh_token.refresh_token,
refresh_expires=refresh_token.refresh_expires,
)