| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- """登录与注销接口。"""
- from __future__ import annotations
- from datetime import datetime, timezone
- from flask import current_app, request
- from sqlalchemy import select
- from dms.api.v1.blueprint import api_v1
- from dms.common.enums import AuditAction, AuditResult, EnabledStatus
- from dms.common.errors import (
- AccountLockedError,
- InvalidArgumentError,
- InvalidCredentialsError,
- LoginIpForbiddenError,
- UserDisabledError,
- )
- from dms.services.captcha_service import issue as captcha_issue, verify as captcha_verify
- from dms.common.response import success_response
- from dms.database.transaction import for_update, transaction
- from dms.extensions import db
- from dms.models import User
- from dms.security.auth_context import get_auth_context
- from dms.security.decorators import bearer_auth_required
- from dms.security.jwt_tokens import issue_access_token, issue_reauth_token
- from dms.security.passwords import verify_password
- from dms.services.admin_permission import SUPER_ADMIN_USERNAME
- from dms.services.audit_service import auth_audit, client_ip, record_login_failure
- from dms.services.ip_entry_service import verify_login_ip
- from dms.services.login_lockout import (
- clear_failed_attempts,
- is_locked,
- register_failed_password_attempt,
- )
- from dms.services.user_service import allowed_modules_for_user, user_summary
- def _login_payload() -> tuple[str, str, bool, str | None, str | None]:
- if not request.is_json:
- raise InvalidArgumentError("请求体必须是JSON对象")
- body = request.get_json(silent=True)
- if not isinstance(body, dict):
- raise InvalidArgumentError("请求体必须是JSON对象")
- # 验证码开启(安全需求4)时请求体扩为五键,关闭时保持三键不变
- captcha_enabled = current_app.config.get("DMS_CAPTCHA_ENABLED", True)
- expected = {"username", "password", "keepSignedIn"}
- if captcha_enabled:
- expected |= {"captchaId", "captchaCode"}
- if set(body) != expected:
- raise InvalidArgumentError(
- "请求体必须且只能包含username、password和keepSignedIn"
- + ("、captchaId和captchaCode" if captcha_enabled else "")
- )
- username = body["username"]
- password = body["password"]
- keep_signed_in = body["keepSignedIn"]
- if not isinstance(username, str) or not username.strip():
- raise InvalidArgumentError("username不能为空")
- if not isinstance(password, str) or not password:
- raise InvalidArgumentError("password不能为空")
- if not isinstance(keep_signed_in, bool):
- raise InvalidArgumentError("keepSignedIn必须是布尔值")
- captcha_id: str | None = None
- captcha_code: str | None = None
- if captcha_enabled:
- captcha_id = body["captchaId"]
- captcha_code = body["captchaCode"]
- if not isinstance(captcha_id, str) or not captcha_id.strip():
- raise InvalidArgumentError("captchaId不能为空")
- if not isinstance(captcha_code, str) or not captcha_code.strip():
- raise InvalidArgumentError("captchaCode不能为空")
- return username.strip(), password, keep_signed_in, captcha_id, captcha_code
- @api_v1.get("/auth/captcha")
- def captcha():
- """登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。"""
- if not current_app.config.get("DMS_CAPTCHA_ENABLED", True):
- return success_response({"enabled": False})
- data: dict[str, object] = captcha_issue()
- data["enabled"] = True
- return success_response(data)
- @api_v1.post("/auth/login")
- def login():
- username, password, keep_signed_in, captcha_id, captcha_code = _login_payload()
- # 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定;
- # 开关关闭时直接放行(对齐 Java CaptchaService.verify,登录回归三键契约)
- if current_app.config.get("DMS_CAPTCHA_ENABLED", True):
- captcha_verify(captcha_id, captcha_code)
- # 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
- # 检查全部短路,报错与密码错误完全一致,不泄露账号存在
- admin_login_blocked = (
- username == SUPER_ADMIN_USERNAME
- and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
- )
- user = (
- None
- if admin_login_blocked
- else db.session.scalar(
- select(User).where(User.username == username, User.is_deleted.is_(False))
- )
- )
- # 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
- if user is not None and is_locked(user):
- record_login_failure(
- user=user,
- attempted_username=username,
- failure_reason="ACCOUNT_LOCKED",
- )
- raise AccountLockedError()
- password_valid = verify_password(
- user.password_hash if user is not None else None,
- password,
- )
- if user is None or not password_valid:
- record_login_failure(
- user=user,
- attempted_username=username,
- failure_reason="INVALID_CREDENTIALS",
- )
- # 仅密码错误计入锁定计数(用户名不存在无账号可锁)
- if user is not None:
- register_failed_password_attempt(user)
- raise InvalidCredentialsError()
- if user.status != EnabledStatus.ENABLED.value:
- record_login_failure(
- user=user,
- attempted_username=username,
- failure_reason="USER_DISABLED",
- )
- raise UserDisabledError()
- try:
- verify_login_ip(user)
- except LoginIpForbiddenError:
- record_login_failure(
- user=user,
- attempted_username=username,
- failure_reason="IP_NOT_ALLOWED",
- )
- raise
- token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
- with transaction() as session:
- user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
- user.last_login_ip = client_ip()
- clear_failed_attempts(user)
- user.row_version += 1
- session.add(
- auth_audit(
- action=AuditAction.LOGIN,
- result=AuditResult.SUCCESS,
- user=user,
- detail={"keepSignedIn": keep_signed_in},
- )
- )
- return success_response(
- {
- "accessToken": token,
- "tokenType": "Bearer",
- "expiresIn": expires_in,
- # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
- "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
- "user": user_summary(
- user, allowed_modules=allowed_modules_for_user(user)
- ),
- }
- )
- @api_v1.post("/auth/logout")
- @bearer_auth_required
- def logout():
- context = get_auth_context()
- with transaction() as session:
- user = session.scalar(for_update(select(User).where(User.id == context.user_id)))
- if user is None or user.is_deleted:
- raise InvalidCredentialsError()
- user.auth_version += 1
- user.row_version += 1
- session.add(
- auth_audit(
- action=AuditAction.LOGOUT,
- result=AuditResult.SUCCESS,
- user=user,
- detail={"authVersionIncremented": True},
- )
- )
- return success_response()
- @api_v1.post("/auth/reauth")
- @bearer_auth_required
- def reauth():
- """敏感操作二次认证:登录态重输登录密码,换取 5 分钟 reauth 令牌(契约1.21)。"""
- if not request.is_json:
- raise InvalidArgumentError("请求体必须是JSON对象")
- body = request.get_json(silent=True)
- if not isinstance(body, dict) or set(body) != {"password"}:
- raise InvalidArgumentError("请求体必须且只能包含password")
- password = body["password"]
- if not isinstance(password, str) or not password:
- raise InvalidArgumentError("password不能为空")
- context = get_auth_context()
- user = db.session.scalar(
- select(User).where(User.id == context.user_id, User.is_deleted.is_(False))
- )
- if user is None or not verify_password(user.password_hash, password):
- raise InvalidCredentialsError()
- token, expires_in = issue_reauth_token(user)
- return success_response({"reauthToken": token, "expiresIn": expires_in})
|