Files
smart-org/apps/api/app/interfaces/http/routers/auth.py
T
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
2026-10-13 21:58:41 +03:30

201 lines
6.1 KiB
Python

"""Authentication endpoints.
There is deliberately **no public signup**: accounts are created either by the
platform owner (``POST /admin/users`` — managers and plain users) or by
redeeming an organization invite (``POST /invites/accept`` with a password).
Login, the profile view and self-service password change live here.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import AuthenticationError, ValidationError
from app.core.security import (
create_access_token,
hash_password,
verify_password,
)
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
email: str
name: str
avatar_url: str | None = None
#: Platform-wide role — ``owner`` | ``manager`` | ``user``. Exactly one
#: account is ``owner``; only ``owner`` and ``manager`` may create
#: organizations.
platform_role: str = "user"
is_manager: bool = False
is_platform_owner: bool = False
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class OrganizationSummary(BaseModel):
id: str
name: str
slug: str
role: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class MeResponse(BaseModel):
user: UserOut
organizations: list[OrganizationSummary]
def _to_user_out(user: User) -> UserOut:
return UserOut(
id=user.id,
email=user.email,
name=user.display_name,
avatar_url=user.avatar_url,
platform_role=user.platform_role.value,
is_manager=user.is_manager,
is_platform_owner=user.is_platform_owner,
)
@router.post("/login", response_model=TokenResponse)
async def login(
payload: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
user = await UserRepository(session).get_by_email(payload.email)
if user is None or not verify_password(payload.password, user.password_hash):
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
if not user.is_active:
raise AuthenticationError("حساب کاربری غیرفعال است.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGIN,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
"""JWTs are stateless; logout is an audit marker today and a token-revocation
hook once a denylist (Redis) is wired in."""
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGOUT,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
payload: ChangePasswordRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
"""Replace the caller's own password.
This is how a temp password handed out by the platform owner (or by an
invite) becomes a private one — without it, every account created by
someone else would live on a shared secret forever.
"""
users = UserRepository(session)
if not verify_password(payload.current_password, user.password_hash):
raise AuthenticationError("رمز عبور فعلی نادرست است.")
if payload.new_password == payload.current_password:
raise ValidationError("رمز عبور جدید باید با رمز فعلی متفاوت باشد.")
await users.update_password(user.id, hash_password(payload.new_password))
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.PASSWORD_CHANGE,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
@router.get("/me", response_model=MeResponse)
async def me(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
orgs = await OrganizationRepository(session).list_for_user(user.id)
return MeResponse(
user=_to_user_out(user),
organizations=[
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
for org, role in orgs
],
)
@router.get("/me/memberships")
async def my_memberships(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[dict]:
orgs = await OrganizationRepository(session).list_for_user(user.id)
memberships = MembershipRepository(session)
out: list[dict] = []
for org, role in orgs:
membership = await memberships.get(org.id, user.id)
out.append(
{
"organization_id": org.id,
"organization_name": org.name,
"role": role.value,
"status": membership.status.value if membership else "unknown",
}
)
return out