PayrollManagementSystem/backend/app/services/auth_service.py
liuwenxin 273ebf4657 chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-09 18:46:36 +08:00

168 lines
5.7 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.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)