PayrollManagementSystem/backend/app/services/auth_service.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

175 lines
6.0 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.

"""认证业务编排。
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.config import get_settings
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)
from app.services.authorization_service import session_info
authorization = session_info(session, user)
return LoginOut(
access_token=token,
expires_in=expires_in,
user=UserBrief(
id=user.id, username=user.username, real_name=user.real_name, **authorization,
payroll_approval_enabled=get_settings().payroll_approval_enabled,
),
)
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)