补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
"""认证业务编排。
|
||
|
||
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
|
||
|
||
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
|
||
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
|
||
"""
|
||
|
||
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
|
||
from app.services.payroll_access import can_write_payroll
|
||
|
||
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,
|
||
can_write_payroll=can_write_payroll(user),
|
||
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)
|