Separation users roles - verified

This commit is contained in:
danilgryaznev 2025-09-27 21:07:59 +03:00
parent 4b228a9a46
commit 418c8bdb65
11 changed files with 160 additions and 11 deletions

View File

@ -48,6 +48,9 @@ pip install -r requirements-dev.txt
# подготовьте переменные окружения (однократно)
cp .env.example .env
# при необходимости включите в .env подтверждение почты
# ALABUGA_REQUIRE_EMAIL_CONFIRMATION=true
# применяем миграции
alembic upgrade head
@ -85,6 +88,17 @@ npm run dev
5. **HR панель**: под HR-пользователем проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
6. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR; при недостатке маны интерфейс подскажет, что делать.
### Подтверждение электронной почты
По умолчанию вход не требует подтверждения почты (`ALABUGA_REQUIRE_EMAIL_CONFIRMATION=false`).
Чтобы включить проверку:
1. Измените переменную `ALABUGA_REQUIRE_EMAIL_CONFIRMATION` в `backend/.env` и перезапустите API.
2. Пользователь должен запросить код через `POST /auth/request-confirmation` (в разработке код возвращается в ответе).
3. Затем подтвердите e-mail вызовом `POST /auth/confirm` с почтой и кодом. После этого вход станет доступен.
Демо-учётные записи в сид-данных имеют уже подтверждённый e-mail.
## Тестирование
```bash

View File

@ -0,0 +1,28 @@
"""Add email confirmation fields to users"""
from __future__ import annotations
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20240927_0003'
down_revision = '20240611_0002'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('is_email_confirmed', sa.Boolean(), nullable=False, server_default=sa.true()))
op.add_column('users', sa.Column('email_confirmation_token', sa.String(length=128), nullable=True))
op.add_column('users', sa.Column('email_confirmed_at', sa.DateTime(timezone=True), nullable=True))
op.execute('UPDATE users SET is_email_confirmed = 1')
op.alter_column('users', 'is_email_confirmed', server_default=None)
def downgrade() -> None:
op.drop_column('users', 'email_confirmed_at')
op.drop_column('users', 'email_confirmation_token')
op.drop_column('users', 'is_email_confirmed')

View File

@ -12,8 +12,10 @@ from app.core.config import settings
from app.core.security import create_access_token, verify_password
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import Token
from app.schemas.auth import EmailConfirm, EmailRequest, Token
from app.schemas.user import UserLogin, UserRead
from app.services.email_confirmation import confirm_email as mark_confirmed
from app.services.email_confirmation import issue_confirmation_token
router = APIRouter(prefix="/auth", tags=["auth"])
@ -26,6 +28,12 @@ def login(user_in: UserLogin, db: Session = Depends(get_db)) -> Token:
if not user or not verify_password(user_in.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные данные")
if settings.require_email_confirmation and not user.is_email_confirmed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Подтвердите e-mail, прежде чем войти",
)
token = create_access_token(user.email, timedelta(minutes=settings.access_token_expire_minutes))
return Token(access_token=token)
@ -35,3 +43,41 @@ def read_current_user(current_user: User = Depends(get_current_user)) -> UserRea
"""Простая проверка токена."""
return current_user
@router.post("/request-confirmation", summary="Повторная отправка письма с кодом")
def request_confirmation(payload: EmailRequest, db: Session = Depends(get_db)) -> dict[str, str | None]:
"""Генерируем код подтверждения и в режиме разработки возвращаем его в ответе."""
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
token = issue_confirmation_token(user, db)
hint = None
if settings.environment != 'production':
hint = token
return {
"detail": "Письмо с подтверждением отправлено. Проверьте почту.",
"debug_token": hint,
}
@router.post("/confirm", summary="Подтверждаем e-mail по коду")
def confirm_email(payload: EmailConfirm, db: Session = Depends(get_db)) -> dict[str, str]:
"""Активируем почту, если код совпал."""
user = db.query(User).filter(User.email == payload.email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
if not user.email_confirmation_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Код не запрошен")
if user.email_confirmation_token != payload.token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
mark_confirmed(user, db)
return {"detail": "E-mail подтверждён"}

View File

@ -24,8 +24,10 @@ def get_profile(
"""Возвращаем профиль и связанные сущности."""
db.refresh(current_user)
_ = current_user.competencies
_ = current_user.artifacts
for item in current_user.competencies:
_ = item.competency
for artifact in current_user.artifacts:
_ = artifact.artifact
return UserProfile.model_validate(current_user)

View File

@ -22,6 +22,7 @@ class Settings(BaseSettings):
secret_key: str = "super-secret-key-change-me"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 12
require_email_confirmation: bool = False
backend_cors_origins: List[str] = [
"http://localhost:3000",

View File

@ -2,10 +2,11 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import List, Optional
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
@ -35,6 +36,9 @@ class User(Base, TimestampMixin):
mana: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
current_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_email_confirmed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
email_confirmation_token: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
email_confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
current_rank = relationship("Rank", back_populates="pilots")
competencies: Mapped[List["UserCompetency"]] = relationship(

View File

@ -1,6 +1,6 @@
"""Схемы авторизации."""
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
class Token(BaseModel):
@ -8,3 +8,16 @@ class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class EmailRequest(BaseModel):
"""Запрос на повторную отправку письма."""
email: EmailStr
class EmailConfirm(BaseModel):
"""Подтверждение e-mail по коду."""
email: EmailStr
token: str

View File

@ -8,6 +8,7 @@ from typing import Optional
from pydantic import BaseModel, EmailStr
from app.models.user import CompetencyCategory, UserRole
from app.schemas.artifact import ArtifactRead
class CompetencyBase(BaseModel):
@ -33,13 +34,11 @@ class UserCompetencyRead(BaseModel):
class UserArtifactRead(BaseModel):
"""Полученный артефакт."""
"""Полученный пользователем артефакт с датой выдачи."""
id: int
name: str
description: str
rarity: str
image_url: Optional[str]
artifact: ArtifactRead
created_at: datetime
class Config:
from_attributes = True
@ -56,6 +55,8 @@ class UserRead(BaseModel):
mana: int
current_rank_id: Optional[int]
is_active: bool
is_email_confirmed: bool
email_confirmed_at: Optional[datetime]
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,34 @@
"""Вспомогательные функции для подтверждения электронной почты."""
from __future__ import annotations
from datetime import datetime, timezone
from secrets import token_urlsafe
from sqlalchemy.orm import Session
from app.models.user import User
def issue_confirmation_token(user: User, db: Session) -> str:
"""Генерируем и сохраняем одноразовый код подтверждения."""
token = token_urlsafe(16)
user.email_confirmation_token = token
user.is_email_confirmed = False
user.email_confirmed_at = None
db.add(user)
db.commit()
db.refresh(user)
return token
def confirm_email(user: User, db: Session) -> None:
"""Помечаем почту подтверждённой."""
user.is_email_confirmed = True
user.email_confirmation_token = None
user.email_confirmed_at = datetime.now(timezone.utc)
db.add(user)
db.commit()
db.refresh(user)

View File

@ -35,7 +35,11 @@ async function authenticate(formData: FormData) {
redirect(profile.role === 'hr' ? '/admin' : '/');
} catch (error) {
console.error('Login failed:', error);
redirect('/login?error=' + encodeURIComponent('Неверный email или пароль. Попробуйте ещё раз.'));
let message = 'Неверный email или пароль. Попробуйте ещё раз.';
if (error instanceof Error && error.message.includes('Подтвердите e-mail')) {
message = 'Почта не подтверждена. Запросите письмо с кодом и завершите подтверждение.';
}
redirect('/login?error=' + encodeURIComponent(message));
}
}

View File

@ -233,6 +233,7 @@ def seed() -> None:
role=UserRole.PILOT,
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[0].id,
is_email_confirmed=True,
)
hr = User(
email="hr@alabuga.space",
@ -240,6 +241,7 @@ def seed() -> None:
role=UserRole.HR,
hashed_password=get_password_hash("orbita123"),
current_rank_id=ranks[2].id,
is_email_confirmed=True,
)
session.add_all([pilot, hr])
session.flush()