Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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
1 change: 1 addition & 0 deletions migrations/000012_create_password_reset_tokens.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_tokens;
11 changes: 11 additions & 0 deletions migrations/000012_create_password_reset_tokens.up.sql
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 11 additions & 0 deletions migrations/up/000012_create_password_reset_tokens.up.sql
Original file line number Diff line number Diff line change
@@ -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);
20 changes: 19 additions & 1 deletion src/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<User id={self.user_id} email={self.email!r}>"
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -385,4 +403,4 @@ class Route(Base):
selected_zone = relationship("ParkingZone", foreign_keys=[selected_zone_id])

def __repr__(self) -> str:
return f"<Route id={self.route_id} user_id={self.user_id} status={self.status}>"
return f"<Route id={self.route_id} user_id={self.user_id} status={self.status}>"
145 changes: 144 additions & 1 deletion src/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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")


# ---------------------------------------------------------------------------
# Вспомогательная функция сборки ответа
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
28 changes: 27 additions & 1 deletion src/schemas/auth.py
Original file line number Diff line number Diff line change
@@ -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


# ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/schemas/partners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand All @@ -26,13 +28,23 @@ 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)
contact_email: EmailStr | None = None
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]
Expand Down
Loading
Loading