Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IS_DEFAULT: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}

jobs:
build-and-push:
Expand Down Expand Up @@ -86,3 +87,31 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

deploy:
needs:
- build-and-push

runs-on: ubuntu-latest

permissions:
contents: read
packages: write

if: github.event_name == 'push'

steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_IP }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
if [ "${{ env.IS_DEFAULT }}" = "true" ]; then
cd ${{ secrets.COMPOSE_DIRECTORY_PATH }}
docker compose up -d
else
cd ${{ secrets.DEVELOPMENT_COMPOSE_DIRECTORY_PATH }}
docker compose -p parktrack-dev up -d
fi
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}>"
2 changes: 2 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"https://labeler.parktrack.live",
"https://admin.parktrack.live",
"https://parktrack.live",
"https://dev.parktrack.live",
"https://admin.dev.parktrack.live",
"http://localhost:5173",
"http://127.0.0.1:5173"
],
Expand Down
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
2 changes: 1 addition & 1 deletion src/routers/forecasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def _parse_bbox(bbox: str) -> tuple[float, float, float, float]:
| list[ForecastMapItem],
)
def list_forecasts(
current_user: Annotated[User, require("forecasts.view")],
# 2026-05-16: открыто без авторизации (по запросу) — режим «Будущее» на карте.
db: Annotated[Session, Depends(get_db)],
zone_id: int | None = None,
camera_id: int | None = None,
Expand Down
2 changes: 1 addition & 1 deletion src/routers/occupancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def _parse_bbox(bbox: str) -> tuple[float, float, float, float]:
| list[OccupancyMapItem],
)
def list_occupancy(
current_user: Annotated[User, require("occupancy.view")],
# 2026-05-16: открыто без авторизации (по запросу) — режим «Прошлое» на карте.
db: Annotated[Session, Depends(get_db)],
zone_id: int | None = None,
camera_id: int | None = None,
Expand Down
Loading
Loading