Files
Stend_mont/app/auth.py
T
ruslan 46e5b5a41e Add pilots: invite-only products with per-user RDP slots
New Service.is_pilot flag - a pilot is a Service (type RDP) that:
- is excluded from /api/public/services-by-category and
  /api/request-more-access (admin-granted only, no self-service)
- still shows as a locked card on the dashboard for users without
  access (Доступно по запросу section), but with a По приглашению
  badge instead of the self-request button/flow
- gets its own non-clickable teaser row on the public /login page
  (logos only, informational)

RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one
specific user instead of being drawn from the shared pool; regular
RDP services are unaffected (field stays NULL, same pool behaviour
as before). The /go/ allocator branches on service.is_pilot to pick
the caller's assigned slot instead of any free one. Slots release
automatically (cleanup_loop) when the owning grant is revoked or
expires, and immediately on manual ACL revoke.

UserServiceAccess.expires_at (nullable) - per-grant access window,
used by pilots so their access can be shorter than the account's own
expires_at; NULL (unchanged default) means "follow the account".
has_access() and the dashboard's granted/locked split both honour it.

Admin UI: "Это пилот" checkbox on the RDP service form, an
assign-user dropdown on a pilot's slot table (replaces the
occupied-by column), and a per-pilot expiry date field in the user
ACL grid.

Schema is applied via the existing ensure_schema_compatibility()
idempotent ALTER TABLE pattern (no alembic in this project) - no
manual migration step needed, it runs at container startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 06:32:05 +00:00

154 lines
4.7 KiB
Python

import secrets
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from config import COOKIE_MAX_AGE, COOKIE_NAME, CSRF_COOKIE
from database import get_db
from models import User, UserServiceAccess
from utils import now_utc
from sqlalchemy import select
import os
_SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
serializer = URLSafeTimedSerializer(_SIGNING_KEY, salt="portal-auth")
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def user_is_valid(user: User) -> bool:
return bool(user.active and user.expires_at > now_utc())
def issue_auth_cookie(response: RedirectResponse, user: User) -> None:
token = serializer.dumps({"user_id": user.id})
response.set_cookie(
key=COOKIE_NAME,
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=COOKIE_MAX_AGE,
path="/",
)
def issue_csrf_cookie(response: RedirectResponse) -> str:
token = secrets.token_urlsafe(24)
response.set_cookie(
key=CSRF_COOKIE,
value=token,
httponly=False,
secure=True,
samesite="lax",
max_age=COOKIE_MAX_AGE,
path="/",
)
return token
def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
raw = request.cookies.get(COOKIE_NAME)
if not raw:
return None
try:
payload = serializer.loads(raw, max_age=COOKIE_MAX_AGE)
except BadSignature:
return None
user = db.get(User, int(payload["user_id"]))
if not user or not user_is_valid(user):
return None
return user
def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
return user
def require_admin(user: User = Depends(require_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
return user
def validate_csrf(request: Request) -> None:
cookie = request.cookies.get(CSRF_COOKIE)
form_val = request.headers.get("X-CSRF-Token")
if request.headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
return
if not cookie or not form_val or cookie != form_val:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF failed")
def has_access(db: Session, user_id: int, service_id: int) -> bool:
access = db.scalar(
select(UserServiceAccess).where(
UserServiceAccess.user_id == user_id,
UserServiceAccess.service_id == service_id,
)
)
if access is None:
return False
# A per-grant expires_at (used by pilots, whose access window can be
# shorter than the account's own expires_at) overrides the account
# expiry for this one product. NULL means "follow the account".
if access.expires_at is not None and access.expires_at <= now_utc():
return False
return True
import threading
import time
_login_attempts: dict = {}
_login_lock = threading.Lock()
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_WINDOW = 300 # сброс счётчика через 5 минут
_LOGIN_BLOCK = 900 # блокировка на 15 минут
def check_login_rate_limit(ip: str) -> Optional[float]:
"""Возвращает blocked_until (time.monotonic) если IP заблокирован, иначе None."""
now = time.monotonic()
with _login_lock:
entry = _login_attempts.get(ip)
if not entry:
return None
if entry["blocked_until"] > now:
return entry["blocked_until"]
if now - entry["first"] > _LOGIN_WINDOW:
del _login_attempts[ip]
return None
def record_login_failure(ip: str) -> None:
now = time.monotonic()
with _login_lock:
entry = _login_attempts.get(ip)
if not entry or now - entry["first"] > _LOGIN_WINDOW:
_login_attempts[ip] = {"count": 1, "first": now, "blocked_until": 0.0}
entry = _login_attempts[ip]
else:
entry["count"] += 1
if entry["count"] >= _LOGIN_MAX_ATTEMPTS:
entry["blocked_until"] = now + _LOGIN_BLOCK
def record_login_success(ip: str) -> None:
with _login_lock:
_login_attempts.pop(ip, None)