- 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 تمیز.
201 lines
6.1 KiB
Python
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
|