diff --git a/README.md b/README.md index c802fc8..7bf429b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/alembic/versions/20240927_0003_email_confirmation.py b/backend/alembic/versions/20240927_0003_email_confirmation.py new file mode 100644 index 0000000..a2dc44b --- /dev/null +++ b/backend/alembic/versions/20240927_0003_email_confirmation.py @@ -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') diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 9f774e3..f6f5023 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -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 подтверждён"} diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 49b8371..b8c7c15 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 469614e..d6ab6d0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c962bc5..dc04439 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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( diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c12cee1..da6dd1d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f11da49..37d2c71 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/email_confirmation.py b/backend/app/services/email_confirmation.py new file mode 100644 index 0000000..cacdad1 --- /dev/null +++ b/backend/app/services/email_confirmation.py @@ -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) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 7caf5c9..2cdac20 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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)); } } diff --git a/scripts/seed_data.py b/scripts/seed_data.py index 93b5c2e..2e0ad0f 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -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()