PayrollManagementSystem/backend/app/services/auth_service.py
liuwenxin 5ece9f6dab refactor(backend): 仓储层散参数收敛为查询 DTO
仓储层的 list_*/count_* 此前以 keyword/status/offset/limit 四五个散参数
传参,调用点重复铺开、易漏传且不可扩展;按项目「参数 >4 封装 DTO」的
约定统一收敛为各模块的 Query 对象(如 RoleQuery、BusinessQuery),
服务层与测试调用同步调整,行为不变。

- repositories 层查询方法签名改为 (session, query),分页与筛选条件
  由 Query 承载,_apply_filters 同步收口
- schemas 层补齐各模块 Query 模型(approval/business_role/report 等)
- services 层调用点同步改为传 Query
- tests 随之调整;新增用例:待办角色流转(财务通过后领导出现待办)、
  参数预览顺序稳定

验证:全量 pytest 900 passed(11:08);单元 526 passed;ruff check 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 19:43:20 +08:00

170 lines
5.8 KiB
Python
Raw Permalink 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.

"""认证业务编排。
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
from app.core.security import create_access_token, decode_token, verify_password
from app.repositories import login_log_repository, user_repository
from app.schemas.auth import LoginOut, UserBrief
from app.services import captcha_service, login_limiter, token_blacklist
logger = logging.getLogger(__name__)
# 统一凭据错误文案:不区分「用户不存在」与「密码错误」,防止账号枚举。
# 攻击者据此无法判断某个用户名是否真实存在。
INVALID_CREDENTIALS = "用户名或密码错误"
def _record(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add(
session,
login_log_repository.LoginLogEntry(
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
),
)
session.commit()
def login(
session: Session,
*,
username: str,
password: str,
captcha_id: str,
captcha_code: str,
ip: str | None,
user_agent: str | None,
) -> LoginOut:
"""执行登录,返回访问令牌与用户信息。
判定顺序是有意设计的,每一步对应一类攻击面:
1. **锁定检查放在最前** —— 否则攻击者无需通过验证码即可消耗失败计数
2. **验证码校验并消费** —— 阻断自动化撞库
3. **凭据校验** —— 用户不存在与密码错误共用同一文案
4. **状态检查放在最后** —— 若提前到密码校验之前,会向未持有正确密码者
暴露「该账号存在但已禁用」
:raises ForbiddenError: 账号锁定或已禁用(403)
:raises BadRequestError: 验证码错误或凭据错误(400)
"""
# ---- 1. 锁定检查 ----
if login_limiter.is_locked(username):
remaining = login_limiter.remaining_lock_seconds(username)
minutes = max(1, (remaining + 59) // 60) # 向上取整,避免显示"0 分钟"
_record(
session,
user_id=None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="账号锁定",
)
raise ForbiddenError(f"账号已锁定,请 {minutes} 分钟后重试")
# ---- 2. 验证码校验(无论成败,该验证码都已失效)----
if not captcha_service.verify_and_consume(captcha_id, captcha_code):
_record(
session,
user_id=None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="验证码错误",
)
raise BadRequestError("验证码错误或已过期,请刷新验证码")
# ---- 3. 凭据校验 ----
user = user_repository.get_by_username(session, username)
if user is None or not verify_password(password, user.password):
login_limiter.record_failure(username)
_record(
session,
user_id=user.id if user else None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason=INVALID_CREDENTIALS,
)
raise BadRequestError(INVALID_CREDENTIALS)
# ---- 4. 状态检查 ----
if user.status != 1:
_record(
session,
user_id=user.id,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="账号禁用",
)
raise ForbiddenError("账号已被禁用,请联系管理员")
# ---- 成功 ----
login_limiter.clear(username)
role_codes = user_repository.get_role_codes(user)
# 库列存本地时间(容器 TZ=Asia/Shanghai);JWT 内部另用 UTC,两者不混算
user_repository.touch_last_login(session, user, datetime.now())
token, _, expires_in = create_access_token(
user_id=user.id, token_version=user.token_version, roles=role_codes
)
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
return LoginOut(
access_token=token,
expires_in=expires_in,
user=UserBrief(
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
),
)
def logout(access_token: str) -> None:
"""登出:把当前令牌加入黑名单。
令牌已自然过期或格式非法时**静默返回** —— 此时没有需要撤销的凭证,
对调用方而言登出都是"已达成";返回 401 只会让前端多弹一次无意义的报错。
"""
try:
payload = decode_token(access_token)
except AuthError:
return
jti = payload.get("jti")
exp = payload.get("exp")
if not jti or not exp:
return
# 黑名单 TTL 必须等于令牌**剩余**有效期,写死固定值会导致"登出后又活了"
remaining = int(exp - datetime.now(UTC).timestamp())
token_blacklist.revoke(jti, remaining)