Étape 8 : connexion par mot de passe pour la coordination
Deux systèmes d'authentification, une même session JWT :
- COORDINATION : login e-mail + mot de passe (bcrypt), POST .../auth/login ;
POST .../auth/change-password pour le compte courant
- RÉFÉRENTS / BÉNÉVOLES : lien magique inchangé, mais refusé pour un compte
qui a un mot de passe
- Account.password_hash (nullable) + migration 0002 ; propriété auth_method
("password" / "magic_link") exposée dans AccountRead
- app/auth/passwords.py : hash/verify bcrypt, longueur mini 10
- Création de compte : password requis pour COORDINATOR, interdit sinon
- seed : la coordination de démo a un mot de passe, affiché dans le résumé
- 12 tests (login ok/ko, séparation des deux systèmes, change-password,
validation à la création) ; 129 tests au total ; alembic check vert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1f9f460540
commit
8a879ca5c3
@@ -115,20 +115,33 @@ Matrice appliquée par les endpoints :
|
|||||||
| `assignments` lecture | ✔ | ✔ | les siennes |
|
| `assignments` lecture | ✔ | ✔ | les siennes |
|
||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
|
|
||||||
**Authentification** : lien magique + session JWT (`Bearer`), sans mot de passe.
|
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||||
|
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
||||||
|
Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magique
|
||||||
|
(`Account.auth_method` = `"password"` / `"magic_link"`).
|
||||||
|
|
||||||
1. `POST /editions/{id}/auth/magic-link` `{email}` → réponse `202` identique
|
*Mot de passe* — comptes `COORDINATOR` :
|
||||||
qu'un compte existe ou non. Si oui, un `MagicLinkToken` est créé (jeton
|
|
||||||
aléatoire, **haché** en base, TTL `magic_link_ttl_minutes`).
|
|
||||||
2. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`,
|
|
||||||
niveau INFO, `jeton=…` — identifiants seulement, aucune donnée nominative).
|
|
||||||
3. `POST /editions/{id}/auth/verify` `{token}` → JWT HS256
|
|
||||||
(`session_ttl_hours`) + `AccountRead`. Jeton à usage unique.
|
|
||||||
4. `GET /auth/me` → compte courant. En-tête `Authorization: Bearer <jwt>`.
|
|
||||||
|
|
||||||
Un bénévole sans email n'a pas de `Account` et ne se connecte pas ; il est géré
|
1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`.
|
||||||
entièrement par la coordination. La coordination crée les comptes via
|
Échec = `401 auth.invalid_credentials` (aucune divulgation).
|
||||||
`POST /editions/{id}/accounts`.
|
2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}`
|
||||||
|
(compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10).
|
||||||
|
|
||||||
|
*Lien magique* — comptes `LEAD` / `VOLUNTEER` (sans mot de passe) :
|
||||||
|
|
||||||
|
3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un
|
||||||
|
compte **sans mot de passe** correspond, un `MagicLinkToken` est créé
|
||||||
|
(jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`).
|
||||||
|
4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO,
|
||||||
|
`jeton=…` — identifiants seulement, aucune donnée nominative).
|
||||||
|
5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à
|
||||||
|
usage unique.
|
||||||
|
|
||||||
|
`GET /auth/me` → compte courant.
|
||||||
|
|
||||||
|
La coordination crée les comptes via `POST /editions/{id}/accounts` : un
|
||||||
|
`COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le
|
||||||
|
**refuse** (lien magique). Un bénévole sans email n'a pas de `Account`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -294,7 +307,8 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
|
| 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée |
|
||||||
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
|
| 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée |
|
||||||
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
|
| 6 | Jeu de données de démonstration (`app/seed.py`) | validée |
|
||||||
| 7 | Authentification : lien magique + JWT + application des rôles | à valider |
|
| 7 | Authentification : lien magique + JWT + application des rôles | validée |
|
||||||
|
| 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | à valider |
|
||||||
|
|
||||||
Premier incrément backend + authentification terminés. **Reste hors
|
Premier incrément backend + authentification terminés. **Reste hors
|
||||||
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien
|
périmètre** (modèle prêt à les accueillir) : envoi réel d'emails/SMS (le lien
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ from app.api._common import owned_or_404
|
|||||||
from app.api.deps import get_edition
|
from app.api.deps import get_edition
|
||||||
from app.api.schemas import AccountCreate, AccountRead
|
from app.api.schemas import AccountCreate, AccountRead
|
||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
|
from app.auth.passwords import MIN_LENGTH, hash_password
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models import Account, Edition, Volunteer
|
from app.models import Account, Edition, Role, Volunteer
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/accounts", tags=["accounts"]
|
prefix="/editions/{edition_id}/accounts", tags=["accounts"]
|
||||||
@@ -32,11 +33,30 @@ def create_account(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422, detail={"code": "account.volunteer_wrong_edition"}
|
status_code=422, detail={"code": "account.volunteer_wrong_edition"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deux systèmes de connexion : mot de passe pour la COORDINATION,
|
||||||
|
# lien magique pour les autres.
|
||||||
|
if payload.role is Role.COORDINATOR:
|
||||||
|
if not payload.password or len(payload.password) < MIN_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "account.password_required", "min": MIN_LENGTH},
|
||||||
|
)
|
||||||
|
password_hash = hash_password(payload.password)
|
||||||
|
else:
|
||||||
|
if payload.password is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "account.password_not_allowed"},
|
||||||
|
)
|
||||||
|
password_hash = None
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
email=payload.email.strip(),
|
email=payload.email.strip(),
|
||||||
role=payload.role,
|
role=payload.role,
|
||||||
volunteer_id=payload.volunteer_id,
|
volunteer_id=payload.volunteer_id,
|
||||||
|
password_hash=password_hash,
|
||||||
)
|
)
|
||||||
session.add(account)
|
session.add(account)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+101
-22
@@ -1,4 +1,11 @@
|
|||||||
"""Endpoints d'authentification : lien magique, vérification, profil courant."""
|
"""Authentification.
|
||||||
|
|
||||||
|
Deux systèmes, selon le compte :
|
||||||
|
- **mot de passe** (`POST .../auth/login`) pour les comptes qui en ont un —
|
||||||
|
la coordination ;
|
||||||
|
- **lien magique** (`.../auth/magic-link` puis `.../auth/verify`) pour les
|
||||||
|
comptes sans mot de passe — référents et bénévoles.
|
||||||
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
@@ -10,11 +17,14 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
AccountRead,
|
AccountRead,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
)
|
)
|
||||||
from app.auth.deps import get_current_account
|
from app.auth.deps import get_current_account
|
||||||
|
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
|
||||||
from app.auth.tokens import create_access_token, hash_token, new_raw_token
|
from app.auth.tokens import create_access_token, hash_token, new_raw_token
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
@@ -24,6 +34,89 @@ router = APIRouter(tags=["auth"])
|
|||||||
logger = logging.getLogger("app.auth")
|
logger = logging.getLogger("app.auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _account_by_email(
|
||||||
|
session: Session, edition_id: uuid.UUID, email: str
|
||||||
|
) -> Account | None:
|
||||||
|
return session.exec(
|
||||||
|
select(Account)
|
||||||
|
.where(Account.edition_id == edition_id)
|
||||||
|
.where(func.lower(Account.email) == email.strip().lower())
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(account: Account) -> TokenResponse:
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(account),
|
||||||
|
token_type="bearer",
|
||||||
|
account=AccountRead.model_validate(account),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_credentials() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "auth.invalid_credentials"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_token() -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "auth.invalid_token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mot de passe (admins) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.post("/editions/{edition_id}/auth/login", response_model=TokenResponse)
|
||||||
|
def login(
|
||||||
|
edition_id: uuid.UUID,
|
||||||
|
payload: LoginRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> TokenResponse:
|
||||||
|
account = _account_by_email(session, edition_id, payload.email)
|
||||||
|
if (
|
||||||
|
account is None
|
||||||
|
or account.password_hash is None
|
||||||
|
or not verify_password(payload.password, account.password_hash)
|
||||||
|
):
|
||||||
|
raise _invalid_credentials()
|
||||||
|
return _issue(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/editions/{edition_id}/auth/change-password",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
def change_password(
|
||||||
|
edition_id: uuid.UUID,
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
if account.edition_id != edition_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={"code": "error.forbidden"},
|
||||||
|
)
|
||||||
|
if account.password_hash is None or not verify_password(
|
||||||
|
payload.current_password, account.password_hash
|
||||||
|
):
|
||||||
|
raise _invalid_credentials()
|
||||||
|
if len(payload.new_password) < MIN_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "auth.password_too_short", "min": MIN_LENGTH},
|
||||||
|
)
|
||||||
|
account.password_hash = hash_password(payload.new_password)
|
||||||
|
session.add(account)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Lien magique (référents, bénévoles) #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
@router.post(
|
@router.post(
|
||||||
"/editions/{edition_id}/auth/magic-link",
|
"/editions/{edition_id}/auth/magic-link",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
@@ -33,14 +126,10 @@ def request_magic_link(
|
|||||||
payload: MagicLinkRequest,
|
payload: MagicLinkRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Émet un lien magique si un compte correspond. Réponse identique dans
|
"""Émet un lien magique si un compte **sans mot de passe** correspond.
|
||||||
tous les cas (pas de divulgation de l'existence d'un compte)."""
|
Réponse identique dans tous les cas (pas de divulgation)."""
|
||||||
account = session.exec(
|
account = _account_by_email(session, edition_id, payload.email)
|
||||||
select(Account)
|
if account is not None and account.password_hash is None:
|
||||||
.where(Account.edition_id == edition_id)
|
|
||||||
.where(func.lower(Account.email) == payload.email.strip().lower())
|
|
||||||
).first()
|
|
||||||
if account is not None:
|
|
||||||
raw = new_raw_token()
|
raw = new_raw_token()
|
||||||
token = MagicLinkToken(
|
token = MagicLinkToken(
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
@@ -78,24 +167,14 @@ def verify_magic_link(
|
|||||||
or token.used_at is not None
|
or token.used_at is not None
|
||||||
or token.expires_at < local_now()
|
or token.expires_at < local_now()
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise _invalid_token()
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail={"code": "auth.invalid_token"},
|
|
||||||
)
|
|
||||||
account = session.get(Account, token.account_id)
|
account = session.get(Account, token.account_id)
|
||||||
if account is None or account.edition_id != edition_id:
|
if account is None or account.edition_id != edition_id:
|
||||||
raise HTTPException(
|
raise _invalid_token()
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail={"code": "auth.invalid_token"},
|
|
||||||
)
|
|
||||||
token.used_at = local_now()
|
token.used_at = local_now()
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(account)
|
session.refresh(account)
|
||||||
return TokenResponse(
|
return _issue(account)
|
||||||
access_token=create_access_token(account),
|
|
||||||
token_type="bearer",
|
|
||||||
account=AccountRead.model_validate(account),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth/me", response_model=AccountRead)
|
@router.get("/auth/me", response_model=AccountRead)
|
||||||
|
|||||||
@@ -204,12 +204,24 @@ class VerifyRequest(ApiModel):
|
|||||||
token: str
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(ApiModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(ApiModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class AccountRead(ApiModel):
|
class AccountRead(ApiModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
edition_id: uuid.UUID
|
edition_id: uuid.UUID
|
||||||
email: str
|
email: str
|
||||||
role: Role
|
role: Role
|
||||||
volunteer_id: uuid.UUID | None
|
volunteer_id: uuid.UUID | None
|
||||||
|
# Mode de connexion : "password" (admins) ou "magic_link".
|
||||||
|
auth_method: str
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
@@ -224,3 +236,5 @@ class AccountCreate(ApiModel):
|
|||||||
email: str
|
email: str
|
||||||
role: Role
|
role: Role
|
||||||
volunteer_id: uuid.UUID | None = None
|
volunteer_id: uuid.UUID | None = None
|
||||||
|
# Requis pour un compte COORDINATION, interdit sinon.
|
||||||
|
password: str | None = None
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Hachage des mots de passe (connexion des admins)."""
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# bcrypt ignore au-delà de 72 octets : on tronque explicitement.
|
||||||
|
_MAX_BYTES = 72
|
||||||
|
MIN_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
payload = plain.encode("utf-8")[:_MAX_BYTES]
|
||||||
|
return bcrypt.hashpw(payload, bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(
|
||||||
|
plain.encode("utf-8")[:_MAX_BYTES], hashed.encode("ascii")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
@@ -11,8 +11,12 @@
|
|||||||
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
|
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
|
||||||
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
|
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
|
||||||
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
|
"auth.invalid_token": "Lien de connexion invalide ou expiré.",
|
||||||
|
"auth.invalid_credentials": "Identifiant ou mot de passe incorrect.",
|
||||||
|
"auth.password_too_short": "Le mot de passe doit contenir au moins {min} caractères.",
|
||||||
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole.",
|
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole.",
|
||||||
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
|
||||||
|
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
|
||||||
|
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
|
||||||
|
|
||||||
"import.error.empty_file": "Le fichier est vide.",
|
"import.error.empty_file": "Le fichier est vide.",
|
||||||
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Compte : mot de passe optionnel (connexion admin)
|
||||||
|
|
||||||
|
Revision ID: 0002_account_password
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2026-09-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002_account_password"
|
||||||
|
down_revision: str | None = "0001_initial"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"account",
|
||||||
|
sa.Column("password_hash", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("account", "password_hash")
|
||||||
@@ -173,6 +173,9 @@ class Account(TimestampMixin, table=True):
|
|||||||
role: Role = Field(
|
role: Role = Field(
|
||||||
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False)
|
sa_column=Column(SAEnum(Role, native_enum=False), nullable=False)
|
||||||
)
|
)
|
||||||
|
# Défini ⇒ connexion par mot de passe (admins). None ⇒ connexion par lien
|
||||||
|
# magique (référents, bénévoles).
|
||||||
|
password_hash: str | None = None
|
||||||
# Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
|
# Lien optionnel vers le bénévole. Supprimé avec lui (droit à l'effacement).
|
||||||
volunteer_id: uuid.UUID | None = Field(
|
volunteer_id: uuid.UUID | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -181,6 +184,10 @@ class Account(TimestampMixin, table=True):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_method(self) -> str:
|
||||||
|
return "password" if self.password_hash else "magic_link"
|
||||||
|
|
||||||
|
|
||||||
class MagicLinkToken(TimestampMixin, table=True):
|
class MagicLinkToken(TimestampMixin, table=True):
|
||||||
__tablename__ = "magic_link_token"
|
__tablename__ = "magic_link_token"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from dataclasses import dataclass, field
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.passwords import hash_password
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.importing.text import normalize
|
from app.importing.text import normalize
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -47,6 +48,7 @@ from app.validation.loader import build_validation_input
|
|||||||
|
|
||||||
SEED = 20260709
|
SEED = 20260709
|
||||||
DEMO_EDITION_NAME = "Festival des Lucioles"
|
DEMO_EDITION_NAME = "Festival des Lucioles"
|
||||||
|
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
|
||||||
DEMO_YEAR = 2026
|
DEMO_YEAR = 2026
|
||||||
START = dt.date(2026, 7, 9)
|
START = dt.date(2026, 7, 9)
|
||||||
END = dt.date(2026, 7, 12)
|
END = dt.date(2026, 7, 12)
|
||||||
@@ -229,6 +231,15 @@ class Summary:
|
|||||||
for code, count in sorted(self.warnings.items()):
|
for code, count in sorted(self.warnings.items()):
|
||||||
lines.append(f" {code} : {count}")
|
lines.append(f" {code} : {count}")
|
||||||
lines.append(f" Violations bloquantes : {self.blocking}")
|
lines.append(f" Violations bloquantes : {self.blocking}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
" Connexion coordination (mot de passe) : "
|
||||||
|
"coordination@lucioles.test / " + DEMO_COORDINATION_PASSWORD
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
" Référents et bénévoles : lien magique "
|
||||||
|
"(referent0..2@lucioles.test, <prénom>.<nom><n>@example.test)"
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -292,6 +303,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
|||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
email="coordination@lucioles.test",
|
email="coordination@lucioles.test",
|
||||||
role=Role.COORDINATOR,
|
role=Role.COORDINATOR,
|
||||||
|
password_hash=hash_password(DEMO_COORDINATION_PASSWORD),
|
||||||
)
|
)
|
||||||
leads = [
|
leads = [
|
||||||
Account(
|
Account(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.12",
|
"python-multipart>=0.0.12",
|
||||||
"openpyxl>=3.1",
|
"openpyxl>=3.1",
|
||||||
"pyjwt>=2.9",
|
"pyjwt>=2.9",
|
||||||
|
"bcrypt>=4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -85,13 +85,20 @@ def make_account(session, edition):
|
|||||||
counter = itertools.count()
|
counter = itertools.count()
|
||||||
|
|
||||||
def _make(
|
def _make(
|
||||||
role: Role, *, volunteer_id=None, email: str | None = None
|
role: Role,
|
||||||
|
*,
|
||||||
|
volunteer_id=None,
|
||||||
|
email: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
|
from app.auth.passwords import hash_password
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
edition_id=edition.id,
|
edition_id=edition.id,
|
||||||
role=role,
|
role=role,
|
||||||
email=email or f"{role.value.lower()}{next(counter)}@test",
|
email=email or f"{role.value.lower()}{next(counter)}@test",
|
||||||
volunteer_id=volunteer_id,
|
volunteer_id=volunteer_id,
|
||||||
|
password_hash=hash_password(password) if password else None,
|
||||||
)
|
)
|
||||||
session.add(account)
|
session.add(account)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ def test_magic_link_unknown_email_is_accepted_without_token(
|
|||||||
def test_full_flow_magic_link_then_jwt_then_me(
|
def test_full_flow_magic_link_then_jwt_then_me(
|
||||||
raw_client, session, edition, make_account, caplog
|
raw_client, session, edition, make_account, caplog
|
||||||
):
|
):
|
||||||
make_account(Role.COORDINATOR, email="coord@example.fr")
|
make_account(Role.LEAD, email="ref@example.fr")
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="app.auth"):
|
with caplog.at_level(logging.INFO, logger="app.auth"):
|
||||||
resp = raw_client.post(
|
resp = raw_client.post(
|
||||||
_url(edition, "magic-link"), json={"email": "COORD@example.fr"}
|
_url(edition, "magic-link"), json={"email": "REF@example.fr"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 202
|
assert resp.status_code == 202
|
||||||
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
|
log_line = next(m for m in caplog.messages if "STUB lien magique" in m)
|
||||||
@@ -40,14 +40,15 @@ def test_full_flow_magic_link_then_jwt_then_me(
|
|||||||
assert verified.status_code == 200
|
assert verified.status_code == 200
|
||||||
body = verified.json()
|
body = verified.json()
|
||||||
assert body["token_type"] == "bearer"
|
assert body["token_type"] == "bearer"
|
||||||
assert body["account"]["role"] == "COORDINATOR"
|
assert body["account"]["role"] == "LEAD"
|
||||||
|
assert body["account"]["auth_method"] == "magic_link"
|
||||||
|
|
||||||
me = raw_client.get(
|
me = raw_client.get(
|
||||||
"/auth/me",
|
"/auth/me",
|
||||||
headers={"Authorization": f"Bearer {body['access_token']}"},
|
headers={"Authorization": f"Bearer {body['access_token']}"},
|
||||||
)
|
)
|
||||||
assert me.status_code == 200
|
assert me.status_code == 200
|
||||||
assert me.json()["email"] == "coord@example.fr"
|
assert me.json()["email"] == "ref@example.fr"
|
||||||
|
|
||||||
|
|
||||||
def test_verify_is_single_use(raw_client, session, edition, make_account):
|
def test_verify_is_single_use(raw_client, session, edition, make_account):
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Connexion par mot de passe (admins) et séparation des deux systèmes."""
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models import MagicLinkToken, Role
|
||||||
|
|
||||||
|
PASSWORD = "coordination-2026"
|
||||||
|
|
||||||
|
|
||||||
|
def _url(edition, suffix):
|
||||||
|
return f"/editions/{edition.id}/auth/{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_correct_password(raw_client, edition, make_account):
|
||||||
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "ADMIN@e.fr", "password": PASSWORD},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["token_type"] == "bearer"
|
||||||
|
assert body["account"]["role"] == "COORDINATOR"
|
||||||
|
assert body["account"]["auth_method"] == "password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_wrong_password(raw_client, edition, make_account):
|
||||||
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "admin@e.fr", "password": "faux"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["detail"]["code"] == "auth.invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_unknown_email(raw_client, edition):
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "personne@e.fr", "password": PASSWORD},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_fails_for_magic_link_account(raw_client, edition, make_account):
|
||||||
|
make_account(Role.LEAD, email="ref@e.fr") # sans mot de passe
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "ref@e.fr", "password": "peu importe"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_ignored_for_password_account(
|
||||||
|
raw_client, session, edition, make_account
|
||||||
|
):
|
||||||
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "magic-link"), json={"email": "admin@e.fr"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 202
|
||||||
|
assert session.exec(select(MagicLinkToken)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password(raw_client, edition, make_account):
|
||||||
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
||||||
|
jwt = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
||||||
|
).json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {jwt}"}
|
||||||
|
|
||||||
|
changed = raw_client.post(
|
||||||
|
_url(edition, "change-password"),
|
||||||
|
json={"current_password": PASSWORD, "new_password": "nouveau-mdp-2026"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert changed.status_code == 204
|
||||||
|
|
||||||
|
assert (
|
||||||
|
raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "admin@e.fr", "password": "nouveau-mdp-2026"},
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_rejects_wrong_current(
|
||||||
|
raw_client, edition, make_account
|
||||||
|
):
|
||||||
|
make_account(Role.COORDINATOR, email="admin@e.fr", password=PASSWORD)
|
||||||
|
jwt = raw_client.post(
|
||||||
|
_url(edition, "login"),
|
||||||
|
json={"email": "admin@e.fr", "password": PASSWORD},
|
||||||
|
).json()["access_token"]
|
||||||
|
resp = raw_client.post(
|
||||||
|
_url(edition, "change-password"),
|
||||||
|
json={"current_password": "faux", "new_password": "nouveau-mdp-2026"},
|
||||||
|
headers={"Authorization": f"Bearer {jwt}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Création de compte : mot de passe selon le rôle #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_create_coordinator_requires_password(client, edition):
|
||||||
|
resp = client.post(
|
||||||
|
f"/editions/{edition.id}/accounts",
|
||||||
|
json={"email": "admin2@e.fr", "role": "COORDINATOR"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "account.password_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_coordinator_with_password_can_log_in(
|
||||||
|
client, raw_client, edition
|
||||||
|
):
|
||||||
|
created = client.post(
|
||||||
|
f"/editions/{edition.id}/accounts",
|
||||||
|
json={
|
||||||
|
"email": "admin2@e.fr",
|
||||||
|
"role": "COORDINATOR",
|
||||||
|
"password": "un-mot-de-passe-solide",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["auth_method"] == "password"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
raw_client.post(
|
||||||
|
f"/editions/{edition.id}/auth/login",
|
||||||
|
json={"email": "admin2@e.fr", "password": "un-mot-de-passe-solide"},
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_lead_rejects_password(client, edition):
|
||||||
|
resp = client.post(
|
||||||
|
f"/editions/{edition.id}/accounts",
|
||||||
|
json={
|
||||||
|
"email": "ref2@e.fr",
|
||||||
|
"role": "LEAD",
|
||||||
|
"password": "pas-permis",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "account.password_not_allowed"
|
||||||
Generated
+40
@@ -46,12 +46,51 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 },
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "benevoles-backend"
|
name = "benevoles-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
@@ -73,6 +112,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
|
{ name = "bcrypt", specifier = ">=4.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1" },
|
{ name = "openpyxl", specifier = ">=3.1" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user