Files
RoleForge/app/routers/auth.py
Sergey Antropoff b2d3b6b803 Профиль и аккаунт
- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки.
- Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок
  инициализации (показ admin-only после initAuthSession, currentUser).
- GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool).

Команды и роли
- Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды
  в модалке, только команды с активным членством; API team_id в details/ update.
- GET /api/v1/teams?membership=active для списка «своих» команд.
- Форма роли: сегмент Team, панель выбора команды только при Team и не при
  с
2026-05-05 08:15:21 +03:00

259 lines
9.3 KiB
Python

from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from jose import JWTError, jwt
from app.core.config import get_settings
from app.core.security import (
create_access_token,
create_refresh_token,
hash_password,
verify_password,
)
from app.deps.auth import get_current_user_id
from app.db.pool import get_pool
from app.services.account_status import clear_expired_temporary_ban
from app.services.founder_policy import account_group_from_role
from app.services.avatar_storage import avatar_urls
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenPairResponse,
)
from app.schemas.profile import ProfileMeResponse
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenPairResponse)
async def register(payload: RegisterRequest) -> TokenPairResponse:
pool = get_pool()
async with pool.acquire() as conn:
user_count = await conn.fetchval("select count(*)::int from users")
exists = await conn.fetchval(
"select id from users where email=$1 or username=$2",
payload.email,
payload.username,
)
if exists:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email or username already exists",
)
is_first = int(user_count or 0) == 0
user_id = await conn.fetchval(
"""
insert into users (username, email, password_hash, full_name, role, is_founder)
values ($1, $2, $3, $4, $5, $6)
returning id::text
""",
payload.username,
payload.email,
hash_password(payload.password),
payload.full_name,
"root" if is_first else "user",
is_first,
)
return TokenPairResponse(
access_token=create_access_token(user_id),
refresh_token=create_refresh_token(user_id),
)
@router.post("/login", response_model=TokenPairResponse)
async def login(payload: LoginRequest) -> TokenPairResponse:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
select
id::text as id,
password_hash
from users
where email=$1 or username=$1
""",
payload.login,
)
if not row or not verify_password(payload.password, row["password_hash"]):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
await clear_expired_temporary_ban(conn, row["id"])
row2 = await conn.fetchrow(
"""
select
id::text as id,
coalesce(is_active, true) as is_active,
deleted_at,
ban_until,
coalesce(ban_reason, '') as ban_reason
from users
where id = $1::uuid
""",
row["id"],
)
if not row2:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if row2["deleted_at"] is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account removed from directory",
)
now = datetime.now(tz=timezone.utc)
if row2["ban_until"] is not None and row2["ban_until"] > now:
br = str(row2["ban_reason"] or "").strip()
msg = f"Account suspended until {row2['ban_until'].isoformat()}."
if br:
msg = f"{msg} Reason: {br}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=msg)
if not row2["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account disabled")
return TokenPairResponse(
access_token=create_access_token(row2["id"]),
refresh_token=create_refresh_token(row2["id"]),
)
@router.post("/refresh", response_model=TokenPairResponse)
async def refresh(payload: RefreshRequest) -> TokenPairResponse:
settings = get_settings()
try:
data = jwt.decode(payload.refresh_token, settings.app_secret_key, algorithms=["HS256"])
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
if data.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
user_id = str(data.get("sub", ""))
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
pool = get_pool()
async with pool.acquire() as conn:
await clear_expired_temporary_ban(conn, user_id)
row = await conn.fetchrow(
"""
select
coalesce(is_active, true) as is_active,
deleted_at,
ban_until,
coalesce(ban_reason, '') as ban_reason
from users
where id = $1::uuid
""",
user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
if row["deleted_at"] is not None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account removed from directory")
now = datetime.now(tz=timezone.utc)
if row["ban_until"] is not None and row["ban_until"] > now:
br = str(row["ban_reason"] or "").strip()
msg = f"Account suspended until {row['ban_until'].isoformat()}."
if br:
msg = f"{msg} Reason: {br}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=msg)
if not row["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account disabled")
return TokenPairResponse(
access_token=create_access_token(user_id),
refresh_token=create_refresh_token(user_id),
)
@router.post("/forgot-password")
async def forgot_password(payload: ForgotPasswordRequest) -> dict[str, str]:
expires_at = datetime.now(tz=timezone.utc) + timedelta(minutes=30)
token = ""
pool = get_pool()
async with pool.acquire() as conn:
user_id = await conn.fetchval(
"select id from users where email=$1 or username=$1",
payload.login,
)
if user_id:
token = str(uuid4())
await conn.execute(
"""
insert into password_reset_tokens (user_id, token, expires_at)
values ($1, $2, $3)
""",
user_id,
token,
expires_at,
)
return {"message": "If account exists, reset link has been generated", "token_for_dev": token}
@router.post("/reset-password")
async def reset_password(payload: ResetPasswordRequest) -> dict[str, str]:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
select t.id, t.user_id
from password_reset_tokens t
where t.token=$1 and t.used_at is null and t.expires_at > now()
""",
payload.token,
)
if not row:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired token")
await conn.execute(
"update users set password_hash=$1 where id=$2",
hash_password(payload.new_password),
row["user_id"],
)
await conn.execute("update password_reset_tokens set used_at=now() where id=$1", row["id"])
return {"message": "Password updated"}
def _avatar_cache_ts(updated_at) -> str | None:
if not updated_at:
return None
return str(int(updated_at.timestamp()))
@router.get("/me", response_model=ProfileMeResponse)
async def me(user_id: str = Depends(get_current_user_id)) -> ProfileMeResponse:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
select
id::text as id,
username,
email,
full_name,
role,
coalesce(is_founder, false) as is_founder,
coalesce(profile_bio, '') as profile_bio,
avatar_ext,
avatar_updated_at
from users where id=$1::uuid
""",
user_id,
)
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
urls = avatar_urls(row["id"], row.get("avatar_ext"), _avatar_cache_ts(row.get("avatar_updated_at")))
rrole = row["role"] or "user"
return ProfileMeResponse(
id=row["id"],
username=row["username"] or "",
email=row["email"] or "",
full_name=row["full_name"] or "",
role=rrole,
account_group=account_group_from_role(rrole),
is_founder=bool(row.get("is_founder")),
profile_bio=str(row.get("profile_bio") or ""),
avatar_ext=row.get("avatar_ext"),
**urls,
)
@router.post("/logout")
async def logout(_: dict | None = None) -> dict[str, str]:
return {"message": "Logged out"}