PayrollManagementSystem/backend/app/api/v1/auth.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

87 lines
3.5 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.

"""认证接口:验证码、登录、登出、当前用户。"""
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, get_current_user, get_raw_token
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。
图片以 base64 内联返回而非单独 URL:保证 id 与图片成对产生,
避免前端分两次请求时拿到不匹配的组合。
"""
captcha_id, image_base64, expires_in = captcha_service.issue()
return ok(CaptchaOut(captcha_id=captcha_id, image_base64=image_base64, expires_in=expires_in))
@router.post("/login", response_model=ApiResponse[LoginOut], summary="登录")
def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse[LoginOut]:
"""账号 + 密码 + 图形验证码登录。
失败一律返回 **400 或 403,绝不使用 401**:前端的 401 拦截器会清空令牌
并跳转登录页,若登录接口自身返回 401,用户输错一次密码就会触发
「清空 + 跳转 + 报错」的组合动作,登录页反复重挂载、验证码反复刷新。
另注意:验证码经 GETDEL 消费后即失效,**前端必须在登录失败后刷新验证码**,
否则第二次提交必然报「验证码错误」。
"""
result = auth_service.login(
session,
username=payload.username,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
ip=_client_ip(request),
user_agent=request.headers.get("user-agent"),
)
return ok(result, message="登录成功")
@router.post("/logout", response_model=ApiResponse[None], summary="登出")
def logout(
token: Annotated[str, Depends(get_raw_token)],
_current_user: Annotated[User, Depends(get_current_user)],
) -> ApiResponse[None]:
"""登出:把当前令牌加入黑名单,使其立即失效。
同时依赖 `get_current_user` 做真实鉴权 —— 仅凭原始令牌字符串不能证明
它是有效的(可能已过期或已被拉黑)。
"""
auth_service.logout(token)
return ok(message="已退出登录")
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
return ok(
CurrentUserOut(
id=current_user.id,
username=current_user.username,
real_name=current_user.real_name,
roles=get_role_codes(current_user),
last_login_time=current_user.last_login_time,
)
)