103 lines
4.1 KiB
Python
103 lines
4.1 KiB
Python
"""Local account authentication and RBAC mapping."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.exceptions import AppException
|
|
from app.core.security import create_access_token, verify_password
|
|
from app.models.entities import OperationAudit, UserAccount
|
|
from app.repositories.users import UserRepository
|
|
from app.schemas.auth import TokenData, UserData
|
|
|
|
MAX_LOGIN_FAILURES = 5
|
|
LOCK_MINUTES = 30
|
|
|
|
|
|
class AuthService:
|
|
def __init__(self, session) -> None: # noqa: ANN001
|
|
self.repo = UserRepository(session)
|
|
self.session = session
|
|
|
|
async def login(self, username: str, password: str) -> TokenData:
|
|
user = await self.repo.get_by_username(username)
|
|
now = datetime.now(UTC)
|
|
if user is None or user.status != "启用":
|
|
self.session.add(
|
|
OperationAudit(
|
|
actor_emp_no=username[:20],
|
|
action="auth.login",
|
|
result="failure",
|
|
audit_metadata={"reason": "invalid_credentials"},
|
|
)
|
|
)
|
|
await self.session.commit()
|
|
raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401)
|
|
if user.locked_until and user.locked_until > now:
|
|
self.session.add(
|
|
OperationAudit(
|
|
actor_user_id=user.id,
|
|
actor_emp_no=user.emp_no,
|
|
action="auth.login",
|
|
result="failure",
|
|
audit_metadata={"reason": "account_locked"},
|
|
)
|
|
)
|
|
await self.session.commit()
|
|
raise AppException("账号已锁定,请稍后重试", code=423, status_code=423)
|
|
if not verify_password(password, user.password_hash):
|
|
user.login_failed_count += 1
|
|
if user.login_failed_count >= MAX_LOGIN_FAILURES:
|
|
user.locked_until = now + timedelta(minutes=LOCK_MINUTES)
|
|
user.login_failed_count = 0
|
|
self.session.add(
|
|
OperationAudit(
|
|
actor_user_id=user.id,
|
|
actor_emp_no=user.emp_no,
|
|
action="auth.login",
|
|
result="failure",
|
|
audit_metadata={"reason": "invalid_credentials"},
|
|
)
|
|
)
|
|
await self.session.flush()
|
|
await self.session.commit()
|
|
raise AppException("工号/企业邮箱或密码错误", code=401, status_code=401)
|
|
user.login_failed_count = 0
|
|
user.locked_until = None
|
|
user.last_login_at = now
|
|
self.session.add(OperationAudit(actor_user_id=user.id, actor_emp_no=user.emp_no, action="auth.login"))
|
|
await self.session.flush()
|
|
return await self._token_data(user)
|
|
|
|
async def _token_data(self, user: UserAccount) -> TokenData:
|
|
permissions = await self.repo.permissions_for_role(user.role)
|
|
permission_keys = sorted({permission.func_key for permission in permissions})
|
|
settings = get_settings()
|
|
return TokenData(
|
|
access_token=create_access_token(user.id, role=user.role),
|
|
expires_in=settings.jwt_expire_minutes * 60,
|
|
user=self.to_user_data(user, permission_keys),
|
|
)
|
|
|
|
@staticmethod
|
|
def to_user_data(user: UserAccount, permissions: list[str] | None = None) -> UserData:
|
|
return UserData(
|
|
id=user.id,
|
|
emp_no=user.emp_no,
|
|
email=user.email,
|
|
name=user.name,
|
|
dept=user.dept,
|
|
position=user.position,
|
|
role=user.role,
|
|
status=user.status,
|
|
dingtalk_id=user.dingtalk_id,
|
|
dingtalk_job_number=user.dingtalk_job_number,
|
|
dingtalk_title=user.dingtalk_title,
|
|
dingtalk_dept_id=user.dingtalk_dept_id,
|
|
dingtalk_is_dept_manager=user.dingtalk_is_dept_manager,
|
|
permissions=permissions or [],
|
|
)
|
|
|
|
async def me(self, user: UserAccount) -> UserData:
|
|
permissions = sorted({permission.func_key for permission in await self.repo.permissions_for_role(user.role)})
|
|
return self.to_user_data(user, permissions)
|