create_rust_app/auth/endpoints/
service_actixweb.rs

1#[cfg(feature = "plugin_utoipa")]
2use crate::auth::{
3    AuthMessageResponse, AuthTokenResponse, JwtSecurityAddon, UserSessionJson, UserSessionResponse,
4};
5use actix_http::StatusCode;
6use actix_web::cookie::{Cookie, SameSite};
7use actix_web::{delete, get, post, web, Error as AWError, Result};
8use actix_web::{
9    web::{Data, Json, Path, Query},
10    HttpRequest, HttpResponse,
11};
12use serde_json::json;
13#[cfg(feature = "plugin_utoipa")]
14use utoipa::OpenApi;
15
16use crate::auth::{
17    controller,
18    controller::{
19        ActivationInput, ChangeInput, ForgotInput, LoginInput, RegisterInput, ResetInput,
20        COOKIE_NAME,
21    },
22    Auth, PaginationParams, ID,
23};
24use crate::{auth::AuthConfig, AppConfig, Database, Mailer};
25
26/// handler for GET requests at the .../sessions endpoint,
27///
28/// requires auth
29///
30/// queries [`db`](`Database`) for all sessions owned by the User
31/// associated with [`auth`](`Auth`)
32///
33/// breaks up the results of that query as defined by [`info`](`PaginationParams`)
34///
35/// Items are arranged in the database in such a way that the most recently added or updated items are last
36/// and are paginated accordingly
37#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
38    context_path = "/api/auth",
39    params(PaginationParams),
40    responses(
41        (status = 200, description = "success, returns a json payload with all the sessions belonging to the authenticated user", body = UserSessionResponse),
42        (status = 401, description = "Error: Unauthorized"),
43        (status = 500, description = "Could not fetch sessions."),
44    ),
45    tag = "Sessions",
46    security ( ("JWT" = []))
47))]
48#[get("/sessions")]
49async fn sessions(
50    db: Data<Database>,
51    auth: Auth,
52    Query(info): Query<PaginationParams>,
53) -> Result<HttpResponse> {
54    let result =
55        web::block(move || controller::get_sessions(db.into_inner().as_ref(), &auth, &info))
56            .await?;
57
58    match result {
59        Ok(sessions) => Ok(HttpResponse::Ok().json(sessions)),
60        Err((status_code, error_message)) => Ok(HttpResponse::build(
61            StatusCode::from_u16(status_code).unwrap(),
62        )
63        .body(json!({ "message": error_message }).to_string())),
64    }
65}
66
67/// handler for DELETE requests at the .../sessions/{id} endpoint.
68///
69/// requires auth
70///
71/// deletes the entry in the `user_session` with the specified [`item_id`](`ID`) from
72/// [`db`](`Database`) if it's owned by the User associated with [`auth`](`Auth`)
73#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
74    context_path = "/api/auth",
75    responses(
76        (status = 200, description = "Deleted", body = AuthMessageResponse),
77        (status = 401, description = "User not authenticated"),
78        (status = 404, description = "User session could not be found, or does not belong to authenticated user.", body = AuthMessageResponse),
79        (status = 500, description = "Internal Error.", body = AuthMessageResponse),
80        (status = 500, description = "Could not delete session.", body = AuthMessageResponse),
81    ),
82    tag = "Sessions",
83    security ( ("JWT" = []))
84))]
85#[delete("/sessions/{id}")]
86async fn destroy_session(
87    db: Data<Database>,
88    item_id: Path<ID>,
89    auth: Auth,
90) -> Result<HttpResponse> {
91    let result =
92        web::block(move || controller::destroy_session(&db, &auth, item_id.into_inner())).await?;
93
94    match result {
95        Ok(()) => Ok(
96            HttpResponse::build(StatusCode::OK).body(json!({"message": "Deleted."}).to_string())
97        ),
98        Err((status_code, error_message)) => Ok(HttpResponse::build(
99            StatusCode::from_u16(status_code).unwrap(),
100        )
101        .body(json!({ "message": error_message }).to_string())),
102    }
103}
104
105/// handler for DELETE requests at the .../sessions enpoint
106///
107/// requires auth
108///
109/// destroys all entries in the `user_session` table in [`db`](`Database`) owned
110/// by the User associated with [`auth`](`Auth`)
111#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
112    context_path = "/api/auth",
113    responses(
114        (status = 200, description = "Deleted", body = AuthMessageResponse),
115        (status = 401, description = "User not authenticated"),
116        (status = 500, description = "Could not delete sessions.", body = AuthMessageResponse),
117    ),
118    tag = "Sessions",
119    security ( ("JWT" = []))
120))]
121#[delete("/sessions")]
122async fn destroy_sessions(db: Data<Database>, auth: Auth) -> Result<HttpResponse, AWError> {
123    let result = web::block(move || controller::destroy_sessions(&db, &auth)).await?;
124
125    match result {
126        Ok(()) => Ok(
127            HttpResponse::build(StatusCode::OK).body(json!({"message": "Deleted."}).to_string())
128        ),
129        Err((status_code, error_message)) => Ok(HttpResponse::build(
130            StatusCode::from_u16(status_code).unwrap(),
131        )
132        .body(json!({ "message": error_message }).to_string())),
133    }
134}
135
136/// handler for POST requests at the .../login endpoint
137///
138/// creates a user session for the user associated with [`item`](`LoginInput`)
139/// in the request body (have the `content-type` header set to `application/json` and content that can be deserialized into [`LoginInput`])
140#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
141    context_path = "/api/auth",
142    request_body(content = LoginInput, content_type = "application/json"),
143    responses(
144        (status = 200, description = "session created", body = AuthTokenResponse),
145        (status = 400, description = "'device' cannot be longer than 256 characters.", body = AuthMessageResponse),
146        (status = 400, description = "Account has not been activated.", body = AuthMessageResponse),
147        (status = 401, description = "Invalid credentials.", body = AuthMessageResponse),
148        (status = 500, description = "An internal server error occurred.", body = AuthMessageResponse),
149        (status = 500, description = "Could not create a session.", body = AuthMessageResponse),
150    ),
151    tag = "Sessions",
152))]
153#[post("/login")]
154async fn login(db: Data<Database>, Json(item): Json<LoginInput>) -> Result<HttpResponse, AWError> {
155    let result = web::block(move || controller::login(&db, &item)).await?;
156
157    match result {
158        Ok((access_token, refresh_token)) => Ok(HttpResponse::build(StatusCode::OK)
159            .cookie(
160                Cookie::build(COOKIE_NAME, refresh_token)
161                    .secure(true)
162                    .http_only(true)
163                    .same_site(SameSite::Strict)
164                    .path("/")
165                    .finish(),
166            )
167            .body(json!({ "access_token": access_token }).to_string())),
168        Err((status_code, message)) => Ok(HttpResponse::build(
169            StatusCode::from_u16(status_code).unwrap(),
170        )
171        .body(json!({ "message": message }).to_string())),
172    }
173}
174
175#[cfg(feature = "plugin_auth-oidc")]
176#[get("/oidc/{provider}")]
177async fn oidc_login_redirect(
178    db: Data<Database>,
179    app_config: Data<AppConfig>,
180    auth_config: Data<AuthConfig>,
181    provider: Path<String>,
182) -> Result<HttpResponse, AWError> {
183    use actix_web::http::header::{HeaderValue, LOCATION};
184
185    let result = crate::auth::oidc::controller::oidc_login_url(
186        &db,
187        app_config.as_ref(),
188        auth_config.as_ref(),
189        provider.to_string(),
190    )
191    .await;
192
193    match result {
194        Ok(Some(url)) => {
195            let mut response = HttpResponse::SeeOther().body(());
196            response
197                .headers_mut()
198                .append(LOCATION, HeaderValue::from_str(url.as_str()).unwrap());
199
200            Ok(response)
201        }
202        Ok(None) => Ok(HttpResponse::NotImplemented().finish()),
203        Err(_) => Ok(HttpResponse::InternalServerError().finish()),
204    }
205}
206
207#[cfg(feature = "plugin_auth-oidc")]
208#[derive(serde::Deserialize)]
209pub struct OIDCLoginQueryParams {
210    code: Option<String>,
211    state: Option<String>,
212    error: Option<String>,
213}
214
215#[cfg(feature = "plugin_auth-oidc")]
216#[get("/oidc/{provider}/login")]
217/// TODO: return result (or specific http response) instead of panicking
218async fn oidc_login(
219    db: Data<Database>,
220    app_config: Data<AppConfig>,
221    auth_config: Data<AuthConfig>,
222    path_params: Path<String>,
223    query_params: Query<OIDCLoginQueryParams>,
224) -> HttpResponse {
225    use actix_web::http::header::{HeaderValue, LOCATION};
226    let provider_name = path_params.to_string();
227
228    let provider = if let Some(provider) = auth_config
229        .oidc_providers
230        .iter()
231        .find(|p| p.name.eq(&provider_name))
232    {
233        provider
234    } else {
235        return HttpResponse::InternalServerError().json(
236            json!({
237                "success": false,
238                "message": "Provider not configured",
239                "provider": &provider_name
240            })
241            .to_string(),
242        );
243    };
244
245    let query_params = query_params.into_inner();
246    let query_param_code = query_params.code;
247    let query_param_state = query_params.state;
248    let query_param_error = query_params.error;
249
250    let resp = crate::auth::oidc::controller::oauth_login(
251        &db,
252        &app_config,
253        &auth_config,
254        provider_name,
255        query_param_code,
256        query_param_error,
257        query_param_state,
258    )
259    .await;
260
261    let mut response = HttpResponse::SeeOther().body(());
262
263    match resp {
264        Ok((access_token, refresh_token)) => {
265            response.headers_mut().append(
266                LOCATION,
267                HeaderValue::from_str(&format!(
268                    "{}?access_token={}",
269                    provider.success_uri, access_token
270                ))
271                .expect("Invalid URL"),
272            );
273
274            response
275                .add_cookie(
276                    &Cookie::build(COOKIE_NAME, refresh_token)
277                        .secure(true)
278                        .http_only(true)
279                        .same_site(SameSite::Strict)
280                        .path("/")
281                        .finish(),
282                )
283                .expect("Could not add refresh_token cookie");
284        }
285        Err((status_code, message)) => response.headers_mut().append(
286            LOCATION,
287            HeaderValue::from_str(&format!(
288                "{}?status_code={}&message={}",
289                provider.error_uri, status_code, message
290            ))
291            .expect("Invalid URL"),
292        ),
293    }
294
295    response
296}
297
298/// handler for POST requests to the .../logout endpount
299///
300/// If this is successful, delete the cookie storing the refresh token
301///
302/// TODO: document that it creates a refresh_token
303#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
304    context_path = "/api/auth",
305    responses(
306        (status = 200, description = "deletes the \"refresh_token\" cookie"),
307        (status = 401, description = "Invalid session.", body = AuthMessageResponse),
308        (status = 401, description = "Could not delete session.", body = AuthMessageResponse),
309    ),
310    tag = "Sessions",
311))]
312#[post("/logout")]
313#[allow(clippy::future_not_send)] // safe because we're running blocking actions in a web::block
314async fn logout(db: Data<Database>, req: HttpRequest) -> Result<HttpResponse, AWError> {
315    let refresh_token = req
316        .cookie(COOKIE_NAME)
317        .map(|cookie| String::from(cookie.value()));
318
319    let result = web::block(move || {
320        controller::logout(&db, refresh_token.as_ref().map(std::convert::AsRef::as_ref))
321    })
322    .await?;
323
324    match result {
325        Ok(()) => {
326            let mut cookie = Cookie::named(COOKIE_NAME);
327            cookie.make_removal();
328
329            Ok(HttpResponse::Ok().cookie(cookie).finish())
330        }
331        Err((status_code, message)) => Ok(HttpResponse::build(
332            StatusCode::from_u16(status_code).unwrap(),
333        )
334        .body(json!({ "message": message }).to_string())),
335    }
336}
337
338/// handler for POST requests to the .../refresh endpoint
339///
340/// refreshes the user session associated with the clients refresh_token cookie
341///
342/// TODO: document that it needs a refresh_token cookie
343#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
344    context_path = "/api/auth",
345    responses(
346        (status = 200, description = "uses the \"refresh_token\" cookie to give the user a new session", body=AuthTokenResponse),
347        (status = 401, description = "Invalid session.", body = AuthMessageResponse),
348        (status = 401, description = "Invalid token.", body = AuthMessageResponse),
349    ),
350    tag = "Sessions",
351))]
352#[post("/refresh")]
353#[allow(clippy::future_not_send)] // safe because we're running blocking actions in a web::block
354async fn refresh(db: Data<Database>, req: HttpRequest) -> Result<HttpResponse, AWError> {
355    let refresh_token = req
356        .cookie(COOKIE_NAME)
357        .map(|cookie| String::from(cookie.value()));
358
359    let result = web::block(move || {
360        controller::refresh(&db, refresh_token.as_ref().map(std::convert::AsRef::as_ref))
361    })
362    .await?;
363
364    match result {
365        Ok((access_token, refresh_token)) => Ok(HttpResponse::build(StatusCode::OK)
366            .cookie(
367                Cookie::build(COOKIE_NAME, refresh_token)
368                    .secure(true)
369                    .http_only(true)
370                    .same_site(SameSite::Strict)
371                    .path("/")
372                    .finish(),
373            )
374            .body(json!({ "access_token": access_token }).to_string())),
375        Err((status_code, message)) => Ok(HttpResponse::build(
376            StatusCode::from_u16(status_code).unwrap(),
377        )
378        .body(json!({ "message": message }).to_string())),
379    }
380}
381
382/// handler for POST requests to the .../register endpoint
383///
384/// creates a new User with the information in [`item`](`RegisterInput`)
385///
386/// sends an email, using [`mailer`](`Mailer`), to the email address in [`item`](`RegisterInput`)
387/// that contains a unique link that allows the recipient to activate the account associated with
388/// that email address
389#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
390    context_path = "/api/auth",
391    request_body(content = RegisterInput, content_type = "application/json"),
392    responses(
393        (status = 200, description = "Success, sends an email to the user with a link that will let them activate their account", body=AuthMessageResponse),
394        (status = 400, description = "Already registered.", body = AuthMessageResponse),
395    ),
396    tag = "Users",
397))]
398#[post("/register")]
399async fn register(
400    db: Data<Database>,
401    Json(item): Json<RegisterInput>,
402    mailer: Data<Mailer>,
403) -> Result<HttpResponse, AWError> {
404    let result = controller::register(&db, &item, &mailer);
405
406    match result {
407        Ok(()) => Ok(HttpResponse::build(StatusCode::OK)
408            .body("{ \"message\": \"Registered! Check your email to activate your account.\" }")),
409        Err((status_code, message)) => Ok(HttpResponse::build(
410            StatusCode::from_u16(status_code).unwrap(),
411        )
412        .body(json!({ "message": message }).to_string())),
413    }
414}
415
416/// handler for GET requests to the .../activate endpoint
417///
418/// activates the account associated with the token in [`item`](`ActivationInput`)
419#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
420    context_path = "/api/auth",
421    params(ActivationInput),
422    responses(
423        (status = 200, description = "Success, account associated with activation_token is activated", body=AuthMessageResponse),
424        (status = 200, description = "Already activated.", body = AuthMessageResponse),
425        (status = 400, description =  "Invalid token.", body = AuthMessageResponse),
426        (status = 401, description =  "Invalid token", body = AuthMessageResponse),
427        (status = 500, description =  "Could not activate user. ", body = AuthMessageResponse),
428    ),
429    tag = "Users",
430))]
431#[get("/activate")]
432async fn activate(
433    db: Data<Database>,
434    Query(item): Query<ActivationInput>,
435    mailer: Data<Mailer>,
436) -> Result<HttpResponse, AWError> {
437    let result = controller::activate(&db, &item, &mailer);
438
439    match result {
440        Ok(()) => Ok(HttpResponse::build(StatusCode::OK).body("{ \"message\": \"Activated!\" }")),
441        Err((status_code, message)) => Ok(HttpResponse::build(
442            StatusCode::from_u16(status_code).unwrap(),
443        )
444        .body(json!({ "message": message }).to_string())),
445    }
446}
447
448/// handler for POST requests to the .../forgot endpoint
449///
450/// sends an email to the email in the ['ForgotInput'] Json in the request body
451/// that will allow the user associated with that email to change their password
452///
453/// sends an email, using [`mailer`](`Mailer`), to the email address in [`item`](`RegisterInput`)
454/// that contains a unique link that allows the recipient to reset the password
455/// of the account associated with that email address (or create a new account if there is
456/// no accound accosiated with the email address)
457#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
458    context_path = "/api/auth",
459    request_body(content = ForgotInput, content_type = "application/json"),
460    responses(
461        (status = 200, description = "Success, password reset email is sent to users email", body=AuthMessageResponse),
462    ),
463    tag = "Users",
464))]
465#[post("/forgot")]
466async fn forgot_password(
467    db: Data<Database>,
468    Json(item): Json<ForgotInput>,
469    mailer: Data<Mailer>,
470) -> Result<HttpResponse, AWError> {
471    let result = controller::forgot_password(&db, &item, &mailer);
472
473    match result {
474        Ok(()) => Ok(HttpResponse::build(StatusCode::OK)
475            .body("{ \"message\": \"Please check your email.\" }")),
476        Err((status_code, message)) => Ok(HttpResponse::build(
477            StatusCode::from_u16(status_code).unwrap(),
478        )
479        .body(json!({ "message": message }).to_string())),
480    }
481}
482
483/// handler for POST requests to the .../change endpoint
484///
485/// requires auth
486///
487/// change the password of the User associated with [`auth`](`Auth`)
488/// from [`item.old_password`](`ChangeInput`) to [`item.new_password`](`ChangeInput`)
489#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
490    context_path = "/api/auth",
491    request_body(content = ChangeInput, content_type = "application/json"),
492    responses(
493        (status = 200, description = "Success, password changed", body=AuthMessageResponse),
494        (status = 400, description = "Missing password.", body=AuthMessageResponse),
495        (status = 400, description = "The new password must be different.", body=AuthMessageResponse),
496        (status = 400, description = "Account has not been activated.", body=AuthMessageResponse),
497        (status = 400, description = "Invalid credentials.", body=AuthMessageResponse),
498        (status = 500, description = "Could not find user.", body=AuthMessageResponse),
499        (status = 500, description = "Could not update password.", body=AuthMessageResponse),
500    ),
501    tag = "Users",
502    security ( ("JWT" = []))
503))]
504#[post("/change")]
505async fn change_password(
506    db: Data<Database>,
507    Json(item): Json<ChangeInput>,
508    auth: Auth,
509    mailer: Data<Mailer>,
510) -> Result<HttpResponse, AWError> {
511    let result = controller::change_password(&db, &item, &auth, &mailer);
512
513    match result {
514        Ok(()) => Ok(HttpResponse::build(StatusCode::OK)
515            .body(json!({"message": "Password changed."}).to_string())),
516        Err((status_code, message)) => Ok(HttpResponse::build(
517            StatusCode::from_u16(status_code).unwrap(),
518        )
519        .body(json!({ "message": message }).to_string())),
520    }
521}
522
523/// handler for POST requests to the .../check endpoint
524///
525/// requires auth, but doesn't match it to a user
526#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
527    context_path = "/api/auth",
528    responses(
529        (status = 200, description = "Success, API is running"),
530    ),
531    tag = "Users",
532    security ( ("JWT" = []))
533))]
534#[post("/check")]
535async fn check(auth: Auth) -> HttpResponse {
536    controller::check(&auth);
537    HttpResponse::Ok().finish()
538}
539
540/// handler for POST requests to the .../reset endpoint
541///
542/// changes the password of the user associated with [`item.reset_token`](`ResetInput`)
543/// to [`item.new_password`](`ResetInput`)
544#[cfg_attr(feature = "plugin_utoipa", utoipa::path(
545    context_path = "/api/auth",
546    request_body(content = ResetInput, content_type = "application/json"),
547    responses(
548        (status = 200, description = "Password changed.", body=AuthMessageResponse),
549        (status = 400, description = "Invalid token.", body=AuthMessageResponse),
550        (status = 400, description = "Account has not been activated.", body=AuthMessageResponse),
551        (status = 400, description = "The new password must be different.", body=AuthMessageResponse),
552        (status = 401, description = "Invalid token.", body=AuthMessageResponse),
553        (status = 500, description = "Could not update password.", body=AuthMessageResponse),
554    ),
555    tag = "Users",
556))]
557#[post("/reset")]
558async fn reset_password(
559    db: Data<Database>,
560    Json(item): Json<ResetInput>,
561    mailer: Data<Mailer>,
562) -> Result<HttpResponse, AWError> {
563    let result = controller::reset_password(&db, &item, &mailer);
564
565    match result {
566        Ok(()) => Ok(HttpResponse::build(StatusCode::OK)
567            .body(json!({"message": "Password reset"}).to_string())),
568        Err((status_code, message)) => Ok(HttpResponse::build(
569            StatusCode::from_u16(status_code).unwrap(),
570        )
571        .body(json!({ "message": message }).to_string())),
572    }
573}
574
575/// returns the endpoints for the Auth service
576#[must_use]
577pub fn endpoints(scope: actix_web::Scope) -> actix_web::Scope {
578    let mut scope = scope
579        .service(sessions)
580        .service(destroy_session)
581        .service(destroy_sessions)
582        .service(login)
583        .service(logout)
584        .service(check)
585        .service(refresh)
586        .service(register)
587        .service(activate)
588        .service(forgot_password)
589        .service(change_password)
590        .service(reset_password);
591
592    #[cfg(feature = "plugin_auth-oidc")]
593    {
594        scope = scope.service(oidc_login_redirect);
595        scope = scope.service(oidc_login);
596    }
597
598    scope
599}
600
601// swagger
602#[cfg(feature = "plugin_utoipa")]
603#[derive(OpenApi)]
604#[openapi(
605    paths(sessions, destroy_session, destroy_sessions, login, logout, refresh, register, activate, forgot_password, change_password, check, reset_password),
606    components(
607        schemas(UserSessionResponse, UserSessionJson, AuthMessageResponse, AuthTokenResponse, LoginInput, RegisterInput, ForgotInput, ChangeInput, ResetInput)
608    ),
609    tags(
610        (name = "Auth", description = "users and user_sessions management endpoints"),
611        (name = "Sessions", description = "Endpoints for user_sessions management"),
612        (name = "Users", description = "Endpoints for useres management"),
613    ),
614    modifiers(&JwtSecurityAddon)
615)]
616pub struct ApiDoc;