feat(auth): add opaque bearer sessions

This commit is contained in:
caoxiaozhu
2026-07-13 14:45:36 +08:00
parent 661990b27b
commit 653eda0596
59 changed files with 1408 additions and 408 deletions

View File

@@ -11,9 +11,11 @@ from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import verify_password
from app.models.auth_session import AuthSession
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse
from app.services.auth_sessions import AuthSessionService
from app.services.employee import EmployeeService
from app.services.employee_seed import ROLE_DISPLAY_ORDER
from app.services.settings import SettingsService
@@ -50,6 +52,8 @@ class AuthenticatedUser:
email: str
avatar: str
is_admin: bool = False
employee_id: str | None = None
tenant_id: str = "default"
class AuthService:
@@ -79,12 +83,61 @@ class AuthService:
raise ValueError("账号或密码错误。")
def _build_login_response(self, user: AuthenticatedUser) -> LoginResponse:
session = UserSessionMetricService(self.db).start_session(user)
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
settings_snapshot = SettingsService(self.db).get_settings_snapshot()
timeout_minutes = settings_snapshot.adminForm.sessionTimeout
try:
metric_session = UserSessionMetricService(self.db).start_session(user, commit=False)
access_token, auth_session = AuthSessionService(self.db).issue(
user,
metric_session_id=metric_session.session_id,
timeout_minutes=timeout_minutes,
)
self.db.commit()
self.db.refresh(auth_session)
except Exception:
self.db.rollback()
raise
return LoginResponse(
user=self._serialize_user(user),
sessionId=metric_session.session_id,
accessToken=access_token,
expiresAt=auth_session.expires_at,
)
def get_session_user(self, auth_session: AuthSession) -> AuthenticatedUser | None:
if auth_session.principal_type == "admin":
record = SettingsService(self.db).get_admin_credentials()
if record is None:
return None
allowed_identifiers = {
str(record.account or "").strip().casefold(),
str(record.email or "").strip().casefold(),
}
if auth_session.username.strip().casefold() not in allowed_identifiers:
return None
return self._build_admin_user(record)
if auth_session.principal_type != "employee":
return None
stmt = select(Employee).options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
)
if auth_session.employee_id:
stmt = stmt.where(Employee.id == auth_session.employee_id)
else:
stmt = stmt.where(func.lower(Employee.email) == auth_session.username.lower())
employee = self.db.execute(stmt).scalars().first()
if employee is None or employee.employment_status == "停用":
return None
return self._build_employee_user(employee)
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
normalized = identifier.strip()
if not normalized or not self.settings.setup_completed:
if not normalized:
return None
employee = self._find_employee_by_email(normalized)
@@ -101,6 +154,10 @@ class AuthService:
if record is None:
return None
return self._build_admin_user(record)
@staticmethod
def _build_admin_user(record: Any) -> AuthenticatedUser:
admin_username = record.account.strip()
admin_email = record.email.strip()
display_name = admin_username or admin_email or "系统管理员"
@@ -169,7 +226,9 @@ class AuthService:
)
role_codes = [role.role_code for role in sorted_roles]
primary_role_code = role_codes[0] if role_codes else "user"
department = employee.organization_unit.name if employee.organization_unit is not None else ""
department = (
employee.organization_unit.name if employee.organization_unit is not None else ""
)
manager_name = self._resolve_manager_name(employee)
return AuthenticatedUser(
@@ -189,6 +248,7 @@ class AuthService:
email=employee.email,
avatar=(employee.name or "?")[:1].upper(),
is_admin=False,
employee_id=employee.id,
)
@staticmethod
@@ -235,7 +295,11 @@ class AuthService:
"riskyClaimCount": sum(1 for claim in claims if claim.risk_flags_json),
"draftClaimCount": sum(1 for claim in claims if claim.status == "draft"),
"recentRiskFlags": recent_risk_flags,
"lastClaimAt": claims[0].occurred_at.isoformat() if claims and claims[0].occurred_at else "",
"lastClaimAt": (
claims[0].occurred_at.isoformat()
if claims and claims[0].occurred_at
else ""
),
}
@staticmethod