diff --git a/example.env b/example.env index 4a1c0f6..52d682b 100644 --- a/example.env +++ b/example.env @@ -10,4 +10,20 @@ POSTGRES_TEST_DB=cars_test_db # Note: port and host are hardcoded due in-compose routing DATABASE_HOST_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432" -DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" \ No newline at end of file +DATABASE_URL="${DATABASE_HOST_URL}/${POSTGRES_DB}?sslmode=disable" + +# Password reset +# If SMTP_HOST is set, reset tokens are sent by email. +# Without SMTP, the API returns reset_token by default for local testing. +PASSWORD_RESET_LOGIN_URL="http://localhost:5173/#/login" +PASSWORD_RESET_TTL_MINUTES=30 +# PASSWORD_RESET_RETURN_TOKEN=0 + +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME=parktrack@example.com +# SMTP_PASSWORD=secret +# SMTP_FROM_EMAIL=parktrack@example.com +# SMTP_FROM_NAME=ParkTrack +# SMTP_USE_TLS=1 +# SMTP_USE_SSL=0 diff --git a/migrations/000012_create_password_reset_tokens.down.sql b/migrations/000012_create_password_reset_tokens.down.sql new file mode 100644 index 0000000..68371a2 --- /dev/null +++ b/migrations/000012_create_password_reset_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS password_reset_tokens; diff --git a/migrations/000012_create_password_reset_tokens.up.sql b/migrations/000012_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..7263651 --- /dev/null +++ b/migrations/000012_create_password_reset_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + token_hash VARCHAR(128) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/migrations/up/000012_create_password_reset_tokens.up.sql b/migrations/up/000012_create_password_reset_tokens.up.sql new file mode 100644 index 0000000..7263651 --- /dev/null +++ b/migrations/up/000012_create_password_reset_tokens.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + token_hash VARCHAR(128) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at); diff --git a/src/db_models.py b/src/db_models.py index 4175af7..bd93372 100644 --- a/src/db_models.py +++ b/src/db_models.py @@ -85,6 +85,7 @@ class User(Base): ) sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") memberships = relationship("PartnerMembership", back_populates="user", cascade="all, delete-orphan") + password_reset_tokens = relationship("PasswordResetToken", back_populates="user", cascade="all, delete-orphan") def __repr__(self) -> str: return f"" @@ -109,6 +110,23 @@ class Session(Base): user = relationship("User", back_populates="sessions") +# --------------------------------------------------------------------------- +# Password Reset Tokens +# --------------------------------------------------------------------------- + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + token_id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False) + token_hash = Column(String(128), unique=True, nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + used_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=_now) + + user = relationship("User", back_populates="password_reset_tokens") + + # --------------------------------------------------------------------------- # User Permissions # --------------------------------------------------------------------------- @@ -385,4 +403,4 @@ class Route(Base): selected_zone = relationship("ParkingZone", foreign_keys=[selected_zone_id]) def __repr__(self) -> str: - return f"" \ No newline at end of file + return f"" diff --git a/src/routers/auth.py b/src/routers/auth.py index dce1b7d..cb3d9b4 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -1,12 +1,20 @@ from __future__ import annotations +import hashlib +import os +import secrets +import smtplib +from datetime import datetime, timedelta, timezone +from email.message import EmailMessage +from email.utils import formataddr from typing import Annotated +from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db -from ..db_models import GlobalRole, User +from ..db_models import GlobalRole, PasswordResetToken, User from ..dependencies import ( JWT_EXPIRE_SECONDS, CurrentUser, @@ -22,12 +30,28 @@ LoginRequest, MeResponse, PartnerMembershipInfo, + PasswordResetConfirmRequest, + PasswordResetConfirmResponse, + PasswordResetRequest, + PasswordResetRequestResponse, RegisterRequest, TokenResponse, ) router = APIRouter(prefix="/auth", tags=["Auth"]) +PASSWORD_RESET_TTL_MINUTES = int(os.environ.get("PASSWORD_RESET_TTL_MINUTES", "30")) +PASSWORD_RESET_LOGIN_URL = os.environ.get("PASSWORD_RESET_LOGIN_URL", "http://localhost:5173/#/login") +SMTP_HOST = os.environ.get("SMTP_HOST") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USERNAME = os.environ.get("SMTP_USERNAME") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD") +SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL") or SMTP_USERNAME +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "ParkTrack") +SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "1").lower() in {"1", "true", "yes", "on"} +SMTP_USE_SSL = os.environ.get("SMTP_USE_SSL", "0").lower() in {"1", "true", "yes", "on"} +_PASSWORD_RESET_RETURN_TOKEN = os.environ.get("PASSWORD_RESET_RETURN_TOKEN") + # --------------------------------------------------------------------------- # Вспомогательная функция сборки ответа @@ -62,6 +86,67 @@ def _build_token_response(user: User, db: Session) -> TokenResponse: ), ) + +def _hash_reset_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _is_expired(dt: datetime) -> bool: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt <= datetime.now(timezone.utc) + + +def _smtp_enabled() -> bool: + return bool(SMTP_HOST and SMTP_FROM_EMAIL) + + +def _should_return_reset_token() -> bool: + if _PASSWORD_RESET_RETURN_TOKEN is None: + return not _smtp_enabled() + return _PASSWORD_RESET_RETURN_TOKEN.lower() in {"1", "true", "yes", "on"} + + +def _build_password_reset_link(token: str, email: str) -> str: + separator = "&" if "?" in PASSWORD_RESET_LOGIN_URL else "?" + return f"{PASSWORD_RESET_LOGIN_URL}{separator}{urlencode({'reset_token': token, 'email': email})}" + + +def _send_password_reset_email(email: str, token: str) -> bool: + if not _smtp_enabled(): + return False + + reset_link = _build_password_reset_link(token, email) + message = EmailMessage() + message["Subject"] = "Сброс пароля ParkTrack" + message["From"] = formataddr((SMTP_FROM_NAME, SMTP_FROM_EMAIL)) if SMTP_FROM_NAME else SMTP_FROM_EMAIL + message["To"] = email + message.set_content( + "Вы запросили сброс пароля ParkTrack.\n\n" + f"Ссылка для сброса: {reset_link}\n\n" + f"Reset-token: {token}\n\n" + f"Токен действует {PASSWORD_RESET_TTL_MINUTES} минут. " + "Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.\n" + ) + + try: + if SMTP_USE_SSL: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=15) as smtp: + if SMTP_USERNAME: + smtp.login(SMTP_USERNAME, SMTP_PASSWORD or "") + smtp.send_message(message) + else: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as smtp: + if SMTP_USE_TLS: + smtp.starttls() + if SMTP_USERNAME: + smtp.login(SMTP_USERNAME, SMTP_PASSWORD or "") + smtp.send_message(message) + return True + except Exception as exc: + print(f"Password reset email failed: {exc}") + return False + # --------------------------------------------------------------------------- # POST /auth/register # --------------------------------------------------------------------------- @@ -113,6 +198,64 @@ def login(body: LoginRequest, db: Annotated[Session, Depends(get_db)]): return _build_token_response(user, db) +# --------------------------------------------------------------------------- +# POST /auth/password-reset/request +# --------------------------------------------------------------------------- + +@router.post("/password-reset/request", status_code=status.HTTP_200_OK, response_model=PasswordResetRequestResponse) +def request_password_reset(body: PasswordResetRequest, db: Annotated[Session, Depends(get_db)]): + user = db.query(User).filter(User.email == body.email).one_or_none() + raw_token: str | None = None + + if user is not None and user.is_active: + raw_token = secrets.token_urlsafe(32) + token = PasswordResetToken( + user_id=user.user_id, + token_hash=_hash_reset_token(raw_token), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TTL_MINUTES), + ) + db.add(token) + db.commit() + _send_password_reset_email(user.email, raw_token) + + return PasswordResetRequestResponse( + ok=True, + reset_token=raw_token if raw_token and _should_return_reset_token() else None, + ) + + +# --------------------------------------------------------------------------- +# POST /auth/password-reset/confirm +# --------------------------------------------------------------------------- + +@router.post("/password-reset/confirm", status_code=status.HTTP_200_OK, response_model=PasswordResetConfirmResponse) +def confirm_password_reset(body: PasswordResetConfirmRequest, db: Annotated[Session, Depends(get_db)]): + token = ( + db.query(PasswordResetToken) + .filter(PasswordResetToken.token_hash == _hash_reset_token(body.token)) + .one_or_none() + ) + + if token is None or token.used_at is not None or _is_expired(token.expires_at): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error_description": "Reset token is invalid or expired"}, + ) + + user = db.query(User).filter(User.user_id == token.user_id).one_or_none() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error_description": "Reset token is invalid or expired"}, + ) + + user.hashed_password = hash_password(body.new_password) + token.used_at = datetime.now(timezone.utc) + db.commit() + + return PasswordResetConfirmResponse(ok=True) + + # --------------------------------------------------------------------------- # POST /auth/logout # --------------------------------------------------------------------------- diff --git a/src/schemas/auth.py b/src/schemas/auth.py index 5deaf5a..0f31263 100644 --- a/src/schemas/auth.py +++ b/src/schemas/auth.py @@ -1,6 +1,8 @@ from __future__ import annotations -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone # --------------------------------------------------------------------------- @@ -13,16 +15,40 @@ class RegisterRequest(BaseModel): full_name: str | None = Field(None, max_length=255) phone: str | None = Field(None, max_length=50) + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class LoginRequest(BaseModel): login: str password: str +class PasswordResetRequest(BaseModel): + email: EmailStr + + +class PasswordResetConfirmRequest(BaseModel): + token: str = Field(min_length=16) + new_password: str = Field(min_length=6, max_length=72) + + # --------------------------------------------------------------------------- # Ответы # --------------------------------------------------------------------------- +class PasswordResetRequestResponse(BaseModel): + ok: bool = True + reset_token: str | None = None + + +class PasswordResetConfirmResponse(BaseModel): + ok: bool = True + + + class AuthUserInfo(BaseModel): user_id: int email: str diff --git a/src/schemas/partners.py b/src/schemas/partners.py index e9c6fd7..15f57ff 100644 --- a/src/schemas/partners.py +++ b/src/schemas/partners.py @@ -2,7 +2,9 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone # --------------------------------------------------------------------------- @@ -26,6 +28,11 @@ class CreatePartnerRequest(BaseModel): contact_email: EmailStr contact_phone: str = Field(min_length=5, max_length=255) + @field_validator("contact_phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UpdatePartnerRequest(BaseModel): legal_name: str | None = Field(None, min_length=2, max_length=255) @@ -33,6 +40,11 @@ class UpdatePartnerRequest(BaseModel): contact_phone: str | None = Field(None, min_length=5, max_length=255) is_active: bool | None = None + @field_validator("contact_phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class PartnerListResponse(BaseModel): items: list[PartnerResponse] diff --git a/src/schemas/users.py b/src/schemas/users.py index f1986da..368d32b 100644 --- a/src/schemas/users.py +++ b/src/schemas/users.py @@ -2,7 +2,9 @@ from datetime import datetime -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, field_validator + +from .validators import validate_optional_phone class UserResponse(BaseModel): @@ -22,6 +24,11 @@ class UpdateUserRequest(BaseModel): phone: str | None = Field(None, max_length=50) email: EmailStr | None = None + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UpdatePasswordRequest(BaseModel): old_password: str @@ -36,6 +43,11 @@ class AdminUpdateUserRequest(BaseModel): global_role: str | None = None is_active: bool | None = None + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + class UserListResponse(BaseModel): items: list[UserResponse] @@ -62,4 +74,9 @@ class AdminCreateUserRequest(BaseModel): full_name: str | None = Field(None, max_length=255) phone: str | None = Field(None, max_length=50) global_role: str = "user" - \ No newline at end of file + + @field_validator("phone") + @classmethod + def validate_phone(cls, value: str | None) -> str | None: + return validate_optional_phone(value) + diff --git a/src/schemas/validators.py b/src/schemas/validators.py new file mode 100644 index 0000000..37ae0e2 --- /dev/null +++ b/src/schemas/validators.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import re + +PHONE_VALIDATION_MESSAGE = "Номер телефона должен содержать 10-15 цифр и может начинаться с +" +_PHONE_ALLOWED_RE = re.compile(r"^\+?[0-9\s().-]+$") + + +def validate_optional_phone(value: str | None) -> str | None: + if value is None: + return None + + phone = value.strip() + if not phone: + return None + + digits = re.sub(r"\D", "", phone) + if not _PHONE_ALLOWED_RE.fullmatch(phone) or not 10 <= len(digits) <= 15: + raise ValueError(PHONE_VALIDATION_MESSAGE) + + return phone