Separation users roles - verified
This commit is contained in:
parent
4b228a9a46
commit
418c8bdb65
14
README.md
14
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
|
||||
|
|
|
|||
28
backend/alembic/versions/20240927_0003_email_confirmation.py
Normal file
28
backend/alembic/versions/20240927_0003_email_confirmation.py
Normal 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')
|
||||
|
|
@ -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 подтверждён"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
backend/app/services/email_confirmation.py
Normal file
34
backend/app/services/email_confirmation.py
Normal 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)
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user