"""认证业务编排。 **事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。 **失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚, 若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。 """ 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, 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)