2026-10-09 06:46:36 -04:00
|
|
|
|
"""认证业务编排。
|
|
|
|
|
|
|
|
|
|
|
|
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
|
|
|
|
|
|
|
|
|
|
|
|
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
|
|
|
|
|
|
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.core.config import get_settings
|
2026-10-09 06:46:36 -04:00
|
|
|
|
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 = "用户名或密码错误"
|
|
|
|
|
|
|
2026-11-03 04:18:36 -05:00
|
|
|
|
# 与真实库哈希同算法、同轮次的假哈希。用户不存在时也执行一次校验,
|
|
|
|
|
|
# 让两条失败路径耗时接近,避免响应时间暴露有效用户名。
|
|
|
|
|
|
_DUMMY_PASSWORD_HASH = "$2b$12$T7f7tDu4ljCgkXigiJ0EHea3ik07F0Dqpv2B1rlUF1FljlylH68Um"
|
|
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-22 07:43:20 -04:00
|
|
|
|
login_log_repository.LoginLogEntry(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
username=username,
|
|
|
|
|
|
ip=ip,
|
|
|
|
|
|
user_agent=user_agent,
|
|
|
|
|
|
success=success,
|
|
|
|
|
|
fail_reason=fail_reason,
|
|
|
|
|
|
),
|
2026-10-09 06:46:36 -04:00
|
|
|
|
)
|
|
|
|
|
|
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)
|
2026-11-03 04:18:36 -05:00
|
|
|
|
credentials_ok = verify_password(
|
|
|
|
|
|
password, user.password if user is not None else _DUMMY_PASSWORD_HASH
|
|
|
|
|
|
)
|
|
|
|
|
|
if user is None or not credentials_ok:
|
2026-10-09 06:46:36 -04:00
|
|
|
|
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)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.services.authorization_service import session_info
|
|
|
|
|
|
|
|
|
|
|
|
authorization = session_info(session, user)
|
2026-10-09 06:46:36 -04:00
|
|
|
|
return LoginOut(
|
|
|
|
|
|
access_token=token,
|
|
|
|
|
|
expires_in=expires_in,
|
|
|
|
|
|
user=UserBrief(
|
2026-11-02 22:20:55 -05:00
|
|
|
|
id=user.id, username=user.username, real_name=user.real_name, **authorization,
|
2026-11-01 20:42:53 -05:00
|
|
|
|
payroll_approval_enabled=get_settings().payroll_approval_enabled,
|
2026-10-09 06:46:36 -04:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|