Skip to main content

koprogo_api/infrastructure/web/handlers/
auth_handlers.rs

1use crate::application::dto::{
2    LoginRequest, LoginResponse, RefreshTokenRequest, RegisterRequest, SwitchRoleRequest,
3    UserResponse,
4};
5use crate::application::error::AppError;
6use crate::infrastructure::web::{
7    build_clearing_cookie, build_refresh_cookie, AppState, AuthenticatedUser, REFRESH_COOKIE_NAME,
8};
9use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
10use serde::Serialize;
11use validator::Validate;
12
13/// Corps de réponse d'authentification **sans** `refresh_token` (WP-FE1).
14///
15/// Le refresh token n'est plus exposé au JavaScript : il part exclusivement
16/// dans le cookie `HttpOnly` (cf. `auth_cookie`). On ne modifie pas le DTO
17/// interne `LoginResponse` (contrat use-case) — on projette ici la vue HTTP.
18#[derive(Serialize)]
19struct AuthBody {
20    token: String,
21    user: UserResponse,
22}
23
24impl From<LoginResponse> for AuthBody {
25    fn from(r: LoginResponse) -> Self {
26        Self {
27            token: r.token,
28            user: r.user,
29        }
30    }
31}
32
33/// Réponse 200 : pose le cookie refresh `HttpOnly` + corps sans refresh.
34fn auth_response_with_cookie(resp: LoginResponse) -> HttpResponse {
35    let cookie = build_refresh_cookie(&resp.refresh_token);
36    HttpResponse::Ok().cookie(cookie).json(AuthBody::from(resp))
37}
38
39#[utoipa::path(
40    post,
41    path = "/auth/login",
42    tag = "Auth",
43    summary = "Login",
44    request_body = LoginRequest,
45    responses(
46        (status = 201, description = "Resource created successfully"),
47        (status = 400, description = "Bad Request"),
48        (status = 404, description = "Not Found"),
49        (status = 500, description = "Internal Server Error"),
50    ),
51)]
52#[post("/auth/login")]
53pub async fn login(
54    data: web::Data<AppState>,
55    request: web::Json<LoginRequest>,
56) -> Result<HttpResponse, AppError> {
57    request
58        .validate()
59        .map_err(|errors| AppError::Validation(errors.to_string()))?;
60
61    let response = data.auth_use_cases.login(request.into_inner()).await?;
62    Ok(auth_response_with_cookie(response))
63}
64
65#[utoipa::path(
66    post,
67    path = "/auth/register",
68    tag = "Auth",
69    summary = "Register",
70    request_body = RegisterRequest,
71    responses(
72        (status = 201, description = "Resource created successfully"),
73        (status = 400, description = "Bad Request"),
74        (status = 404, description = "Not Found"),
75        (status = 500, description = "Internal Server Error"),
76    ),
77)]
78#[post("/auth/register")]
79pub async fn register(
80    data: web::Data<AppState>,
81    req: HttpRequest,
82    request: web::Json<RegisterRequest>,
83) -> impl Responder {
84    // Validate request
85    if let Err(errors) = request.validate() {
86        return HttpResponse::BadRequest().json(serde_json::json!({
87            "error": "Validation failed",
88            "details": errors.to_string()
89        }));
90    }
91
92    // L'appelant est-il DÉJÀ authentifié ?
93    //
94    // `register` sert deux usages opposés : l'inscription de soi-même, où
95    // poser la session du nouveau compte est exactement ce qu'on veut, et la
96    // création d'un utilisateur par un administrateur, où c'est précisément ce
97    // qu'on ne veut pas. Un seul endpoint, un seul comportement, deux besoins
98    // contraires.
99    //
100    // Sans ce garde-fou, le cookie du compte créé écrase celui de l'appelant :
101    // un SuperAdmin qui crée trois utilisateurs se retrouve connecté en tant
102    // que le troisième, sans que rien à l'écran ne l'explique. Constaté en
103    // recette le 2026-09-04 (R1-4), où l'acte 1 demande justement de créer
104    // trois comptes d'affilée.
105    //
106    // Ce n'est pas une escalade de privilège — le compte créé est en général
107    // moins doté — mais une confusion de session, et rien ne garantit que le
108    // sens restera toujours favorable.
109    //
110    // La correction durable est de séparer les deux routes ; ce garde-fou
111    // ferme le défaut sans casser l'inscription publique en attendant.
112    let deja_authentifie = req
113        .headers()
114        .get(actix_web::http::header::AUTHORIZATION)
115        .is_some()
116        || req.cookie(REFRESH_COOKIE_NAME).is_some();
117
118    match data.auth_use_cases.register(request.into_inner()).await {
119        Ok(response) => {
120            if deja_authentifie {
121                // On rend le compte créé, sans toucher à la session en cours.
122                HttpResponse::Created().json(AuthBody::from(response))
123            } else {
124                let cookie = build_refresh_cookie(&response.refresh_token);
125                HttpResponse::Created()
126                    .cookie(cookie)
127                    .json(AuthBody::from(response))
128            }
129        }
130        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
131            "error": e
132        })),
133    }
134}
135
136#[utoipa::path(
137    get,
138    path = "/auth/me",
139    tag = "Auth",
140    summary = "Get Current User",
141    responses(
142        (status = 200, description = "Success"),
143        (status = 400, description = "Bad Request"),
144        (status = 404, description = "Not Found"),
145        (status = 500, description = "Internal Server Error"),
146    ),
147)]
148#[get("/auth/me")]
149pub async fn get_current_user(
150    data: web::Data<AppState>,
151    req: HttpRequest,
152) -> Result<HttpResponse, AppError> {
153    let auth_header = req
154        .headers()
155        .get("Authorization")
156        .ok_or(AppError::Unauthorized)?
157        .to_str()
158        .map_err(|_| AppError::Validation("invalid authorization header".to_string()))?;
159
160    let token = auth_header.trim_start_matches("Bearer ").trim();
161
162    let claims = data.auth_use_cases.verify_token(token)?;
163
164    let user_id = uuid::Uuid::parse_str(&claims.sub)
165        .map_err(|e| AppError::Validation(format!("invalid user id in token: {}", e)))?;
166
167    let user = data.auth_use_cases.get_user_by_id(user_id).await?;
168    Ok(HttpResponse::Ok().json(user))
169}
170
171#[utoipa::path(
172    post,
173    path = "/auth/refresh",
174    tag = "Auth",
175    summary = "Refresh Token",
176    description = "Le refresh token est lu depuis le cookie HttpOnly \
177                   `koprogo_refresh` (WP-FE1) — aucun corps de requête. \
178                   La réponse rote le cookie et ne contient pas de \
179                   refresh_token.",
180    responses(
181        (status = 200, description = "Access token rafraîchi"),
182        (status = 401, description = "Cookie refresh absent, expiré ou révoqué"),
183        (status = 500, description = "Internal Server Error"),
184    ),
185)]
186#[post("/auth/refresh")]
187pub async fn refresh_token(
188    data: web::Data<AppState>,
189    req: HttpRequest,
190) -> Result<HttpResponse, AppError> {
191    // Le refresh token vient EXCLUSIVEMENT du cookie HttpOnly : illisible
192    // par JS, donc non exfiltrable par XSS (WP-FE1). Absence = 401.
193    let refresh_token = req
194        .cookie(REFRESH_COOKIE_NAME)
195        .map(|c| c.value().to_owned())
196        .filter(|v| !v.is_empty())
197        .ok_or(AppError::Unauthorized)?;
198
199    let response = data
200        .auth_use_cases
201        .refresh_token(RefreshTokenRequest { refresh_token })
202        .await?;
203    // Rotation : le use-case révoque l'ancien refresh et en émet un neuf.
204    Ok(auth_response_with_cookie(response))
205}
206
207#[utoipa::path(
208    post,
209    path = "/auth/switch-role",
210    tag = "Auth",
211    summary = "Switch Role",
212    request_body = SwitchRoleRequest,
213    responses(
214        (status = 201, description = "Resource created successfully"),
215        (status = 400, description = "Bad Request"),
216        (status = 404, description = "Not Found"),
217        (status = 500, description = "Internal Server Error"),
218    ),
219)]
220#[post("/auth/switch-role")]
221pub async fn switch_role(
222    data: web::Data<AppState>,
223    user: AuthenticatedUser,
224    request: web::Json<SwitchRoleRequest>,
225) -> impl Responder {
226    let payload = request.into_inner();
227
228    match data
229        .auth_use_cases
230        .switch_active_role(user.user_id, payload.role_id)
231        .await
232    {
233        Ok(response) => auth_response_with_cookie(response),
234        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
235    }
236}
237
238#[utoipa::path(
239    post,
240    path = "/auth/logout",
241    tag = "Auth",
242    summary = "Logout",
243    description = "Révoque tous les refresh tokens de l'utilisateur \
244                   (déconnexion serveur) et expire le cookie HttpOnly \
245                   `koprogo_refresh` (WP-FE1).",
246    responses(
247        (status = 200, description = "Déconnecté, cookie expiré"),
248        (status = 401, description = "Access token absent ou invalide"),
249        (status = 500, description = "Internal Server Error"),
250    ),
251)]
252#[post("/auth/logout")]
253pub async fn logout(
254    data: web::Data<AppState>,
255    user: AuthenticatedUser,
256) -> Result<HttpResponse, AppError> {
257    // Révocation serveur : tous les refresh de l'utilisateur sont
258    // invalidés (le cookie volé éventuel devient inutilisable), puis le
259    // cookie est expiré côté navigateur.
260    data.auth_use_cases
261        .revoke_all_refresh_tokens(user.user_id)
262        .await
263        .map_err(AppError::from)?;
264
265    Ok(HttpResponse::Ok()
266        .cookie(build_clearing_cookie())
267        .json(serde_json::json!({ "message": "logged out" })))
268}