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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ CELERY_TASK_SOFT_LIMIT_SECONDS=240
# --- CORS --------------------------------------------------------------------
# Comma-separated list. Defaults are coded in app/core/config.py.
# CORS_ORIGINS=https://app.simandou-screening.com,http://localhost:5173

# --- Journal de connexions : alerte sur contexte inconnu ---------------------
# Une connexion depuis une IP ou un appareil jamais vus déclenche un courriel.
# Sans identifiants SMTP, le journal enregistre quand même tout : seul l'envoi
# est ignoré. Destinataire : LOGIN_ALERT_TO_EMAIL, à défaut BREVO_TO_EMAIL.
LOGIN_ALERT_ENABLED=true
LOGIN_ALERT_TO_EMAIL=
BREVO_SMTP_LOGIN=
BREVO_SMTP_KEY=
BREVO_FROM_EMAIL=
BREVO_FROM_NAME=Simandou Conformité
BREVO_TO_EMAIL=
87 changes: 87 additions & 0 deletions alembic/versions/s4_login_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""S4: journal des connexions (login_events) + dernière connexion sur users

Revision ID: s4_login_events
Revises: m13_offshore_relations
Create Date: 2026-08-07
"""
from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers
revision = "s4_login_events"
down_revision = "m13_offshore_relations"
branch_labels = None
depends_on = None

# Le rôle applicatif qui contourne RLS pour l'authentification (cf.
# settings.AUTH_BYPASS_ROLE). Les événements de connexion sont écrits SOUS ce
# rôle, puisque l'écriture a lieu pendant le login — avant qu'un contexte de
# tenant n'existe. Sans le GRANT ci-dessous, l'insertion échouerait en
# production alors qu'elle passe en test (où le rôle reçoit tous les droits).
AUTH_BYPASS_ROLE = "auth_bypass_rls"


def upgrade() -> None:
op.create_table(
"login_events",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("event", sa.String(16), nullable=False),
sa.Column("reason", sa.String(32), nullable=True),
# Pas de ForeignKey volontairement : la trace doit survivre à la
# suppression du compte, et un échec sur une adresse inconnue n'a
# aucun utilisateur à référencer.
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("email", sa.Text(), nullable=True),
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("ip", sa.String(64), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column(
"is_new_context",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.create_index("ix_login_events_created_at", "login_events", ["created_at"])
op.create_index("ix_login_events_user_created", "login_events", ["user_id", "created_at"])
op.create_index("ix_login_events_email", "login_events", ["email"])
op.create_index("ix_login_events_ip", "login_events", ["ip"])

op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("last_login_ip", sa.String(64), nullable=True))

# Droits pour le rôle d'authentification, s'il existe sur cette instance.
op.execute(
f"""
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{AUTH_BYPASS_ROLE}') THEN
GRANT SELECT, INSERT ON public.login_events TO {AUTH_BYPASS_ROLE};
GRANT SELECT, UPDATE ON public.users TO {AUTH_BYPASS_ROLE};
END IF;
END $$;
"""
)


def downgrade() -> None:
op.drop_column("users", "last_login_ip")
op.drop_column("users", "last_login_at")
op.drop_index("ix_login_events_ip", table_name="login_events")
op.drop_index("ix_login_events_email", table_name="login_events")
op.drop_index("ix_login_events_user_created", table_name="login_events")
op.drop_index("ix_login_events_created_at", table_name="login_events")
op.drop_table("login_events")
97 changes: 91 additions & 6 deletions app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# app/api/routes/auth.py
from typing import Annotated

from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy.orm import Session

Expand All @@ -16,6 +16,7 @@
RefreshRequest,
TokenPairResponse,
)
from app.services import login_audit_service
from app.services.auth_service import (
consume_refresh_token,
create_access_token,
Expand Down Expand Up @@ -61,6 +62,7 @@ def _access_token_for(user: dict) -> tuple[str, int]:
def login(
request: Request,
payload: Annotated[LoginRequest, Body()],
background: BackgroundTasks,
db: Session = Depends(get_db_public),
):
_reset_session(db)
Expand All @@ -71,31 +73,60 @@ def login(
logger.exception("SET ROLE auth_bypass failed")
raise HTTPException(status_code=500, detail="Auth DB role misconfigured")

ip, ua = _client_meta(request)

try:
u = get_user_by_email(db, payload.email)

if not u:
logger.info("login_failed", extra={"email": payload.email, "reason": "unknown_user"})
login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_LOGIN_FAILED,
email=payload.email,
ip=ip,
user_agent=ua,
reason="unknown_user",
)
raise HTTPException(status_code=401, detail="Invalid credentials")

if not verify_password(payload.password, u.get("password_hash") or ""):
logger.info(
"login_failed",
extra={"email": payload.email, "user_id": u.get("id"), "reason": "bad_password"},
)
login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_LOGIN_FAILED,
email=payload.email,
user_id=str(u["id"]),
tenant_id=str(u["tenant_id"]) if u.get("tenant_id") else None,
ip=ip,
user_agent=ua,
reason="bad_password",
)
raise HTTPException(status_code=401, detail="Invalid credentials")

if not u.get("is_active", True) or u.get("status") != "ACTIVE":
logger.info(
"login_failed",
extra={"email": payload.email, "user_id": u.get("id"), "reason": "disabled"},
)
login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_LOGIN_FAILED,
email=payload.email,
user_id=str(u["id"]),
tenant_id=str(u["tenant_id"]) if u.get("tenant_id") else None,
ip=ip,
user_agent=ua,
reason="disabled",
)
raise HTTPException(status_code=403, detail="User disabled")

# Émet l'access token + persiste un refresh token
access_token, expires_in = _access_token_for(u)

ip, ua = _client_meta(request)
refresh_token, refresh_expires_at = issue_refresh_token(
db,
user_id=str(u["id"]),
Expand All @@ -105,6 +136,29 @@ def login(
)
db.commit()

# Journal de connexion — APRÈS le commit de la session : une panne du
# journal ne doit pas annuler une connexion déjà acquise.
evenement = login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_LOGIN_OK,
email=u.get("email") or payload.email,
user_id=str(u["id"]),
tenant_id=str(u["tenant_id"]),
ip=ip,
user_agent=ua,
detect_new_context=True,
)
login_audit_service.touch_last_login(db, user_id=str(u["id"]), ip=ip)

# Adresse ou appareil jamais vus → alerte à la Conformité, hors du
# chemin de réponse (une session SMTP prend plusieurs secondes).
if evenement and evenement.get("is_new_context"):
background.add_task(
login_audit_service.notify_new_context,
evenement,
full_name=u.get("full_name"),
)

user_id_ctx.set(str(u["id"]))
logger.info(
"login_success",
Expand Down Expand Up @@ -154,10 +208,14 @@ def refresh(
# Recharge l'utilisateur (status peut avoir changé entre login et refresh)
u = db.execute(
text("""
SELECT id::text AS id, email, full_name, is_active, status,
tenant_id::text AS tenant_id
FROM public.users
WHERE id = CAST(:uid AS uuid)
SELECT u.id::text AS id, u.email, u.full_name, u.is_active, u.status,
u.tenant_id::text AS tenant_id,
EXISTS (
SELECT 1 FROM public.user_roles ur
WHERE ur.user_id = u.id AND ur.role = 'SUPER_ADMIN'
) AS is_super_admin
FROM public.users u
WHERE u.id = CAST(:uid AS uuid)
"""),
{"uid": found["user_id"]},
).mappings().first()
Expand All @@ -181,6 +239,16 @@ def refresh(
user_agent=ua,
)
db.commit()
login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_REFRESH,
email=u["email"],
user_id=str(u["id"]),
tenant_id=str(u["tenant_id"]),
ip=ip,
user_agent=ua,
reason="rotated",
)
logger.info("refresh_rotated", extra={"user_id": str(u["id"])})
return TokenPairResponse(
access_token=access_token,
Expand Down Expand Up @@ -210,6 +278,7 @@ def refresh(

@router.post("/logout", status_code=204)
def logout(
request: Request,
payload: LogoutRequest,
user=Depends(get_current_user),
db: Session = Depends(get_db_public),
Expand All @@ -230,14 +299,30 @@ def logout(
logger.exception("SET ROLE auth_bypass failed (logout)")
raise HTTPException(status_code=500, detail="Auth DB role misconfigured")

ip, ua = _client_meta(request)

def _trace(motif: str) -> None:
login_audit_service.record_safe(
db,
event=login_audit_service.EVENT_LOGOUT,
email=user.get("email"),
user_id=str(user["id"]),
tenant_id=str(user["tenant_id"]) if user.get("tenant_id") else None,
ip=ip,
user_agent=ua,
reason=motif,
)

try:
if payload.all_devices:
n = revoke_all_user_tokens(db, user_id=str(user["id"]), reason="logout_all")
db.commit()
_trace("logout_all")
logger.info("logout_all", extra={"user_id": str(user["id"]), "revoked": n})
elif payload.refresh_token:
revoke_refresh_token(db, token=payload.refresh_token, reason="logout")
db.commit()
_trace("logout")
logger.info("logout_single", extra={"user_id": str(user["id"])})
else:
# Pas de refresh fourni, pas de all_devices → noop accepté
Expand Down
Loading
Loading