Metadata-Version: 2.4
Name: authforce-rs-labs
Version: 0.2.0
Summary: Autenticazione riusabile per backend FastAPI: password, JWT, sessioni con refresh ruotato e hashato, verifica email, Google e Apple.
Author: Roffer
License-Expression: MIT
Project-URL: Homepage, https://github.com/Samu7le/quiz_f1_generator/tree/main/libs/authforce
Project-URL: Changelog, https://github.com/Samu7le/quiz_f1_generator/blob/main/libs/authforce/CHANGELOG.md
Keywords: fastapi,authentication,jwt,oauth,sqlalchemy,sign-in-with-apple,google-sign-in
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi>=0.110
Requires-Dist: sqlalchemy[asyncio]>=2.0
Requires-Dist: pydantic[email]>=2.5
Requires-Dist: bcrypt>=4.1
Requires-Dist: PyJWT[crypto]>=2.8
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: aiosqlite>=0.20; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Dynamic: license-file

# AuthForce

*May the AuthForce be with you.*

Pacchetto `authforce-rs-labs` di Roffer, licenza MIT, si importa come `authforce`. Libreria di autenticazione per backend FastAPI con SQLAlchemy async. Contiene solo le parti generiche e di sicurezza del login. Le regole di un progetto (inviti, email, profilo, statistiche) restano nel progetto e chiamano i metodi della libreria.

Installazione: `pip install authforce-rs-labs`. Per i test: `pip install "authforce-rs-labs[dev]"`. Versioni in [CHANGELOG.md](CHANGELOG.md).

## Cosa fa

| Area | Contenuto |
|---|---|
| Password | bcrypt (`security.hash_password`, `verify_password`). Oltre 72 byte `PasswordTooLong` alla registrazione e credenziali non valide al login |
| Access token | JWT firmato (HS256, HS384 o HS512), breve durata, tipo `access` verificato (`security.access_token_subject`) |
| Sessioni | refresh token opaco da 256 bit, salvato nel DB come SHA-256 e ruotato a ogni uso: un token già usato non vale più, anche con due refresh in parallelo. Riuso di un token già ruotato nel log e, a scelta, chiusura di tutte le sessioni. Logout, chiusura di tutte le sessioni |
| Utenti locali | registrazione, login con lo stesso errore e lo stesso tempo di risposta per email sconosciuta e password sbagliata, email normalizzate (una casella, un account), riattivazione di un account disattivato (`reactivate_on_login`, altrimenti `InactiveUser`) |
| Verifica email | token one-shot con scadenza (48 ore di default), salvato come SHA-256 |
| Google | login web (redirect e callback) e da app nativa (id_token verificato in locale con le chiavi pubbliche di Google: firma, issuer, audience, scadenza) |
| Apple | identity_token verificato (RS256, audience, issuer, `nonce` facoltativo), scambio dell'authorization code, revoca alla cancellazione dell'account |
| Login social | utente trovato per account social, poi per email (collegamento a un account locale solo se il provider garantisce l'email, altrimenti `OAuthEmailNotVerified`), altrimenti creato. Email di avviso con link "scollega" quando un login social si aggancia a un account esistente |
| Stato OAuth | parametro `state` e cookie HttpOnly contro il CSRF, confronto a tempo costante |
| Rate limit | interfaccia `RateLimiter` (il progetto passa Redis), `InMemoryRateLimiter` per un solo processo |
| FastAPI | dipendenze `current_user`, `optional_user` e `require(...)`; router base pronto per un progetto nuovo |

La configurazione rifiuta un segreto JWT più corto di 32 caratteri e un algoritmo fuori da HS256, HS384, HS512. Gli errori (`authforce.errors`) non contengono testi per l'utente: il progetto li traduce nella propria lingua. La libreria scrive nel logger `authforce` solo gli eventi di sicurezza (riuso di un refresh token), mai token o email.

## Progetto nuovo

```python
from datetime import timedelta
from sqlalchemy.orm import DeclarativeBase
from authforce import (AuthConfig, AuthModels, AuthService, CredentialsMixin,
                         EmailVerificationMixin, OAuthAccountMixin, SessionMixin, UserMixin)
from authforce.router import build_auth_router

class Base(DeclarativeBase): ...

class User(Base, UserMixin):
    __tablename__ = "users"

class Credentials(Base, CredentialsMixin):
    __tablename__ = "credentials"

class OAuthAccount(Base, OAuthAccountMixin):
    __tablename__ = "oauth_accounts"

class Session(Base, SessionMixin):
    __tablename__ = "sessions"

class EmailVerification(Base, EmailVerificationMixin):
    __tablename__ = "email_verifications"

auth = AuthService(
    AuthConfig(jwt_secret_key=SECRET, access_token_ttl=timedelta(minutes=30),
               accept_plain_stored_tokens=False),   # progetto nuovo: nessuna riga in chiaro da accettare
    AuthModels(User, Credentials, OAuthAccount, Session, EmailVerification),
)
app.include_router(
    build_auth_router(auth, get_session, user_response=UserOut, rate_limiter=redis_limiter,
                      send_verification_email=send_email),
    prefix="/api/v1/auth",
)
```

Con uno schema Postgres, ogni tabella collegata agli utenti indica la chiave esterna: `__auth_user_fk__ = "app_auth.users.id"`. Le tabelle si creano con le migrazioni del progetto (alembic autogenerate le vede dai mixin). Con `normalize_emails` conviene un indice funzionale su `lower(email)` se la tabella è grande.

## Progetto con regole proprie

Il progetto scrive le sue rotte e chiama i metodi di `AuthService`. I metodi che non chiudono un login (`create_local_user`, `login_with_oauth`) fanno solo flush, così il progetto aggiunge le sue righe nella stessa transazione prima di `start_session`, che fa il commit.

```python
user = await auth.create_local_user(db, email, password, username=username)
await apply_referral(db, user)                  # logica del progetto
tokens = await auth.start_session(db, user, ip, user_agent)
token = await auth.create_email_verification(db, user)
send_verification_email.delay(user.email, token)  # logica del progetto
```

Esempio completo: PitQuiz, in `BE/app/core/auth.py` (configurazione e modelli esistenti, senza migrazioni) e `BE/app/api/v1/auth/service/` (inviti, sponsor, email, classifiche).

## Scelte di sicurezza e come cambiarle

Tutte in `AuthConfig`.

- `hash_stored_tokens` (acceso). Nel database ci sono gli hash dei refresh token e dei token di verifica: chi legge il database non apre sessioni. Il codice del progetto che confronta la colonna con il token del client passa da `security.hash_token`.
- `accept_plain_stored_tokens` (acceso). Per chi ha attivato l'hash dopo: le righe in chiaro restano valide finché scadono o vengono ruotate. Un valore con la forma di un hash non viene mai cercato in chiaro. Spegnerlo dopo `refresh_token_ttl` dal deploy, o subito in un progetto nuovo.
- `revoke_all_on_refresh_reuse` (spento). Un refresh token già ruotato presentato di nuovo è un token copiato oppure un client che ha perso la risposta. Acceso chiude tutte le sessioni dell'utente (OAuth 2.1); spento risponde solo `InvalidSession` e scrive nel log.
- `normalize_emails` (acceso). Minuscole e senza spazi ai bordi, anche nella ricerca sulle righe scritte prima.
- Dopo un cambio password chiamare `revoke_all_sessions` e aprire una sessione nuova: la libreria non lo fa da sola perché il progetto decide se tenere viva la sessione corrente.
- Gli access token restano validi fino alla scadenza anche dopo `revoke_all_sessions`: tenere `access_token_ttl` corto (15-30 minuti).
- IP del client per il rate limit e le sessioni: dietro un reverse proxy il server ASGI deve leggere `X-Forwarded-For` solo dai proxy fidati (uvicorn: `--proxy-headers --forwarded-allow-ips=<reti del proxy>`, mai `*`). In alternativa `build_auth_router(client_ip=header_client_ip("CF-Connecting-IP"))` se il proxy sovrascrive sempre quell'header ed è l'unico a raggiungere l'applicazione.
- Google da app nativa: `GoogleOAuth(native_client_ids=(...))` è obbligatorio, l'id_token si verifica in locale (JWKS di Google, cache delle chiavi per processo). `id_token_verifier="tokeninfo"` chiede a Google a ogni login.

## Test e lint

```
pip install -e ".[dev]"
pytest -q            # SQLite in memoria, Google e Apple simulati
ruff check authforce tests
```

## Pubblicazione

Versione in `pyproject.toml` e `authforce/__init__.py`, voce nel CHANGELOG, poi da un ambiente separato da quello del progetto (twine porta dipendenze che non devono finire nella venv dell'applicazione):

```
python -m venv /tmp/authforce-build && source /tmp/authforce-build/bin/activate
pip install build twine
cd libs/authforce && rm -rf dist && python -m build && twine check dist/*
twine upload --repository testpypi dist/*     # prova su test.pypi.org
twine upload dist/*                           # PyPI
```

Credenziali: token API di PyPI (`__token__` come utente), mai la password. Dopo la pubblicazione, un tag git `authforce-v0.2.0`.
