1use axum::{
2 extract::{ConnectInfo, Query, State},
3 http::StatusCode,
4 Json,
5};
6use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL, Engine};
7use rand::RngExt;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use serde_with::skip_serializing_none;
11use std::net::SocketAddr;
12
13use cloudillo_core::{
14 extract::{IdTag, OptionalAuth, OptionalRequestId},
15 rate_limit::{PenaltyReason, RateLimitApi},
16 roles::expand_roles,
17 ActionVerifyFn, Auth,
18};
19use cloudillo_email::{get_tenant_lang, EmailModule, EmailTaskParams};
20use cloudillo_ref::service::{create_ref_internal, CreateRefInternalParams};
21use cloudillo_types::{
22 action_types::ACCESS_TOKEN_EXPIRY,
23 auth_adapter::{self, ListTenantsOptions},
24 meta_adapter::ListRefsOptions,
25 types::ApiResponse,
26 utils::decode_jwt_no_verify,
27};
28
29use crate::prelude::*;
30
31const SW_ENCRYPTION_KEY_VAR: &str = "sw_encryption_key";
33
34fn generate_sw_encryption_key() -> String {
37 let key: [u8; 32] = rand::rng().random();
38 BASE64_URL.encode(key)
39}
40
41#[skip_serializing_none]
43#[derive(Serialize)]
44pub struct Login {
45 #[serde(rename = "tnId")]
47 tn_id: TnId,
48 #[serde(rename = "idTag")]
49 id_tag: String,
50 roles: Option<Vec<String>>,
51 token: String,
52 name: String,
54 #[serde(rename = "profilePic")]
55 profile_pic: String,
56 settings: Vec<(String, String)>,
57 #[serde(rename = "swEncryptionKey")]
59 sw_encryption_key: Option<String>,
60}
61
62#[derive(Serialize)]
63pub struct IdTagRes {
64 #[serde(rename = "idTag")]
65 id_tag: String,
66}
67
68pub async fn get_id_tag(
69 State(app): State<App>,
70 OptionalRequestId(_req_id): OptionalRequestId,
71 req: axum::http::Request<axum::body::Body>,
72) -> ClResult<(StatusCode, Json<IdTagRes>)> {
73 let host = req
74 .uri()
75 .host()
76 .or_else(|| req.headers().get(axum::http::header::HOST).and_then(|h| h.to_str().ok()))
77 .unwrap_or_default();
78 let cert_data = app.auth_adapter.read_cert_by_domain(host).await?;
79
80 Ok((StatusCode::OK, Json(IdTagRes { id_tag: cert_data.id_tag.to_string() })))
81}
82
83pub async fn return_login(
84 app: &App,
85 auth: auth_adapter::AuthLogin,
86) -> ClResult<(StatusCode, Json<Login>)> {
87 let tenant = app.meta_adapter.read_tenant(auth.tn_id).await.ok();
90
91 let (name, profile_pic) = match tenant {
92 Some(t) => (t.name.to_string(), t.profile_pic.map(|p| p.to_string())),
93 None => (auth.id_tag.to_string(), None),
94 };
95
96 let sw_encryption_key = match app.auth_adapter.read_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR).await
98 {
99 Ok(key) => Some(key.to_string()),
100 Err(Error::NotFound) => {
101 let key = generate_sw_encryption_key();
103 if let Err(e) =
104 app.auth_adapter.update_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR, &key).await
105 {
106 warn!("Failed to store SW encryption key: {}", e);
107 None
108 } else {
109 info!("Generated new SW encryption key for tenant {}", auth.tn_id.0);
110 Some(key)
111 }
112 }
113 Err(e) => {
114 warn!("Failed to read SW encryption key: {}", e);
115 None
116 }
117 };
118
119 let login = Login {
120 tn_id: auth.tn_id,
121 id_tag: auth.id_tag.to_string(),
122 roles: auth.roles.map(|roles| roles.iter().map(|r| r.to_string()).collect()),
123 token: auth.token.to_string(),
124 name,
125 profile_pic: profile_pic.unwrap_or_default(),
126 settings: vec![],
127 sw_encryption_key,
128 };
129
130 Ok((StatusCode::OK, Json(login)))
131}
132
133#[derive(Deserialize)]
135pub struct LoginReq {
136 #[serde(rename = "idTag")]
137 id_tag: String,
138 password: String,
139}
140
141pub async fn post_login(
142 State(app): State<App>,
143 ConnectInfo(addr): ConnectInfo<SocketAddr>,
144 OptionalRequestId(req_id): OptionalRequestId,
145 Json(login): Json<LoginReq>,
146) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
147 let auth = app.auth_adapter.check_tenant_password(&login.id_tag, &login.password).await;
148
149 if let Ok(auth) = auth {
150 let (_status, Json(login_data)) = return_login(&app, auth).await?;
151 let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
152 Ok((StatusCode::OK, Json(response)))
153 } else {
154 if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
156 warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
157 }
158 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
159 Err(Error::PermissionDenied)
160 }
161}
162
163pub async fn get_login_token(
165 State(app): State<App>,
166 OptionalAuth(auth): OptionalAuth,
167 OptionalRequestId(req_id): OptionalRequestId,
168) -> ClResult<(StatusCode, Json<ApiResponse<Option<Login>>>)> {
169 if let Some(auth) = auth {
170 info!("login-token for {}", &auth.id_tag);
171 let auth = app.auth_adapter.create_tenant_login(&auth.id_tag).await;
172 if let Ok(auth) = auth {
173 info!("token: {}", &auth.token);
174 let (_status, Json(login_data)) = return_login(&app, auth).await?;
175 let response =
176 ApiResponse::new(Some(login_data)).with_req_id(req_id.unwrap_or_default());
177 Ok((StatusCode::OK, Json(response)))
178 } else {
179 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
180 Err(Error::PermissionDenied)
181 }
182 } else {
183 info!("login-token called without authentication");
185 let response = ApiResponse::new(None).with_req_id(req_id.unwrap_or_default());
186 Ok((StatusCode::OK, Json(response)))
187 }
188}
189
190#[derive(Deserialize, Default)]
192pub struct LogoutReq {
193 #[serde(rename = "apiKey")]
195 api_key: Option<String>,
196}
197
198pub async fn post_logout(
200 State(app): State<App>,
201 Auth(auth): Auth,
202 OptionalRequestId(req_id): OptionalRequestId,
203 Json(req): Json<LogoutReq>,
204) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
205 if let Some(ref api_key) = req.api_key {
210 match app.auth_adapter.validate_api_key(api_key).await {
211 Ok(validation) if validation.tn_id == auth.tn_id => {
212 if let Err(e) = app.auth_adapter.delete_api_key(auth.tn_id, validation.key_id).await
213 {
214 warn!("Failed to delete API key {} on logout: {:?}", validation.key_id, e);
215 } else {
216 info!(
217 "Deleted API key {} for user {} on logout",
218 validation.key_id, auth.id_tag
219 );
220 }
221 }
222 Ok(_) => {
223 warn!("API key provided at logout does not belong to user {}", auth.id_tag);
224 }
225 Err(e) => {
226 debug!("API key validation failed on logout: {:?}", e);
228 }
229 }
230 }
231
232 info!("User {} logged out", auth.id_tag);
233
234 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
235
236 Ok((StatusCode::OK, Json(response)))
237}
238
239#[derive(Deserialize)]
241pub struct PasswordReq {
242 #[serde(rename = "currentPassword")]
243 current_password: String,
244 #[serde(rename = "newPassword")]
245 new_password: String,
246}
247
248pub async fn post_password(
249 State(app): State<App>,
250 ConnectInfo(addr): ConnectInfo<SocketAddr>,
251 Auth(auth): Auth,
252 OptionalRequestId(req_id): OptionalRequestId,
253 Json(req): Json<PasswordReq>,
254) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
255 if req.new_password.len() < 8 {
257 return Err(Error::ValidationError("Password must be at least 8 characters".into()));
258 }
259
260 if req.new_password.trim().is_empty() {
261 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
262 }
263
264 if req.new_password == req.current_password {
265 return Err(Error::ValidationError(
266 "New password must be different from current password".into(),
267 ));
268 }
269
270 let verification = app
272 .auth_adapter
273 .check_tenant_password(&auth.id_tag, &req.current_password)
274 .await;
275
276 if verification.is_err() {
277 if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
279 warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
280 }
281 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
283 warn!("Failed password verification for user {}", auth.id_tag);
284 return Err(Error::PermissionDenied);
285 }
286
287 app.auth_adapter.update_tenant_password(&auth.id_tag, &req.new_password).await?;
289
290 info!("User {} successfully changed their password", auth.id_tag);
291
292 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
293
294 Ok((StatusCode::OK, Json(response)))
295}
296
297#[derive(Deserialize)]
305pub struct GetAccessTokenQuery {
306 #[serde(default)]
307 token: Option<String>,
308 scope: Option<String>,
309 #[serde(rename = "refId")]
311 ref_id: Option<String>,
312 #[serde(rename = "apiKey")]
314 api_key: Option<String>,
315 #[serde(default)]
317 refresh: Option<bool>,
318}
319
320pub async fn get_access_token(
321 State(app): State<App>,
322 tn_id: TnId,
323 id_tag: IdTag,
324 ConnectInfo(addr): ConnectInfo<SocketAddr>,
325 OptionalAuth(maybe_auth): OptionalAuth,
326 Query(query): Query<GetAccessTokenQuery>,
327 OptionalRequestId(req_id): OptionalRequestId,
328) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
329 use tracing::warn;
330
331 info!("Got access token request for id_tag={} with scope={:?}", id_tag.0, query.scope);
332
333 if let Some(token_param) = query.token {
335 info!("Verifying action token from query parameter");
336 let verify_fn = app.ext::<ActionVerifyFn>()?;
337 let auth_action = verify_fn(&app, tn_id, &token_param, Some(&addr.ip())).await?;
338 if *auth_action.aud.as_ref().ok_or(Error::PermissionDenied)?.as_ref() != *id_tag.0 {
339 warn!("Auth action issuer {} doesn't match id_tag {}", auth_action.iss, id_tag.0);
340 return Err(Error::PermissionDenied);
341 }
342 info!("Got auth action: {:?}", &auth_action);
343
344 info!(
345 "Creating access token with t={}, u={}, scope={:?}",
346 id_tag.0,
347 auth_action.iss,
348 query.scope.as_deref()
349 );
350
351 let profile_roles = match app.meta_adapter.read_profile_roles(tn_id, &auth_action.iss).await
353 {
354 Ok(roles) => {
355 info!(
356 "Found profile roles for {} in tn_id {:?}: {:?}",
357 auth_action.iss, tn_id, roles
358 );
359 roles
360 }
361 Err(e) => {
362 warn!(
363 "Failed to read profile roles for {} in tn_id {:?}: {}",
364 auth_action.iss, tn_id, e
365 );
366 None
367 }
368 };
369
370 let expanded_roles = profile_roles
371 .as_ref()
372 .map(|roles| expand_roles(roles))
373 .filter(|s| !s.is_empty());
374
375 info!("Expanded roles for access token: {:?}", expanded_roles);
376
377 let token_result = app
378 .auth_adapter
379 .create_access_token(
380 tn_id,
381 &auth_adapter::AccessToken {
382 iss: &id_tag.0,
383 sub: Some(&auth_action.iss),
384 r: expanded_roles.as_deref(),
385 scope: query.scope.as_deref(),
386 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
387 },
388 )
389 .await?;
390 info!("Got access token: {}", &token_result);
391 let response = ApiResponse::new(json!({ "token": token_result }))
392 .with_req_id(req_id.unwrap_or_default());
393 Ok((StatusCode::OK, Json(response)))
394 } else if let Some(ref_id) = query.ref_id {
395 let is_refresh = query.refresh.unwrap_or(false);
397 info!("Exchanging ref_id {} for scoped access token (refresh={})", ref_id, is_refresh);
398
399 let (ref_tn_id, _ref_id_tag, ref_data) = if is_refresh {
402 app.meta_adapter.validate_ref(&ref_id, &["share.file"]).await
403 } else {
404 app.meta_adapter.use_ref(&ref_id, &["share.file"]).await
405 }
406 .map_err(|e| {
407 warn!(
408 "Failed to {} ref {}: {}",
409 if is_refresh { "validate" } else { "use" },
410 ref_id,
411 e
412 );
413 match e {
414 Error::NotFound => Error::ValidationError("Invalid or expired share link".into()),
415 Error::ValidationError(_) => e,
416 _ => Error::ValidationError("Invalid share link".into()),
417 }
418 })?;
419
420 if ref_tn_id != tn_id {
422 warn!(
423 "Ref tenant mismatch: ref belongs to {:?} but request is for {:?}",
424 ref_tn_id, tn_id
425 );
426 return Err(Error::PermissionDenied);
427 }
428
429 let file_id = ref_data
431 .resource_id
432 .ok_or_else(|| Error::ValidationError("Share link missing resource_id".into()))?;
433 let access_level = ref_data.access_level.unwrap_or('R');
434
435 let scope = format!("file:{}:{}", file_id, access_level);
438 info!("Creating scoped access token with scope={}", scope);
439
440 let token_result = app
441 .auth_adapter
442 .create_access_token(
443 tn_id,
444 &auth_adapter::AccessToken {
445 iss: &id_tag.0,
446 sub: None, r: None, scope: Some(&scope),
449 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
450 },
451 )
452 .await?;
453
454 info!("Got scoped access token for share link");
455 let response = ApiResponse::new(json!({
456 "token": token_result,
457 "scope": scope,
458 "resourceId": file_id.to_string(),
459 "accessLevel": if access_level == 'W' { "write" } else { "read" }
460 }))
461 .with_req_id(req_id.unwrap_or_default());
462 Ok((StatusCode::OK, Json(response)))
463 } else if let Some(api_key) = query.api_key {
464 info!("Exchanging API key for access token");
466
467 let validation = app.auth_adapter.validate_api_key(&api_key).await.map_err(|e| {
469 warn!("API key validation failed: {:?}", e);
470 Error::PermissionDenied
471 })?;
472
473 if validation.tn_id != tn_id {
475 warn!(
476 "API key tenant mismatch: key belongs to {:?} but request is for {:?}",
477 validation.tn_id, tn_id
478 );
479 return Err(Error::PermissionDenied);
480 }
481
482 info!(
483 "Creating access token from API key for id_tag={}, scopes={:?}",
484 validation.id_tag, validation.scopes
485 );
486
487 let token_result = app
489 .auth_adapter
490 .create_access_token(
491 tn_id,
492 &auth_adapter::AccessToken {
493 iss: &id_tag.0,
494 sub: Some(&validation.id_tag),
495 r: validation.roles.as_deref(),
496 scope: validation.scopes.as_deref(),
497 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
498 },
499 )
500 .await?;
501
502 info!("Got access token from API key: {}", &token_result);
503
504 let auth_login = auth_adapter::AuthLogin {
506 tn_id,
507 id_tag: validation.id_tag,
508 roles: validation.roles.map(|r| r.split(',').map(|s| s.into()).collect()),
509 token: token_result,
510 };
511 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
512 let response = ApiResponse::new(serde_json::to_value(login_data)?)
513 .with_req_id(req_id.unwrap_or_default());
514 Ok((StatusCode::OK, Json(response)))
515 } else {
516 let auth = maybe_auth.ok_or(Error::Unauthorized)?;
518
519 info!(
520 "Using authenticated session for id_tag={}, scope={:?}",
521 auth.id_tag,
522 query.scope.as_deref()
523 );
524
525 let profile_roles =
527 app.meta_adapter.read_profile_roles(tn_id, &auth.id_tag).await.ok().flatten();
528
529 let profile_roles: Option<Box<[Box<str>]>> = if auth.id_tag == id_tag.0 {
531 Some(vec!["leader".into()].into_boxed_slice())
532 } else {
533 profile_roles
534 };
535
536 let expanded_roles = profile_roles
537 .as_ref()
538 .map(|roles| expand_roles(roles))
539 .filter(|s: &String| !s.is_empty());
540
541 let token_result = app
542 .auth_adapter
543 .create_access_token(
544 tn_id,
545 &auth_adapter::AccessToken {
546 iss: &id_tag.0,
547 sub: Some(&auth.id_tag),
548 r: expanded_roles.as_deref(),
549 scope: query.scope.as_deref(),
550 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
551 },
552 )
553 .await?;
554 info!("Got access token from session: {}", &token_result);
555 let response = ApiResponse::new(json!({ "token": token_result }))
556 .with_req_id(req_id.unwrap_or_default());
557 Ok((StatusCode::OK, Json(response)))
558 }
559}
560
561#[skip_serializing_none]
566#[derive(Serialize)]
567pub struct ProxyTokenRes {
568 token: String,
569 roles: Option<Vec<String>>,
571}
572
573#[derive(Deserialize)]
574pub struct ProxyTokenQuery {
575 #[serde(rename = "idTag")]
576 id_tag: Option<String>,
577}
578
579pub async fn get_proxy_token(
580 State(app): State<App>,
581 IdTag(own_id_tag): IdTag,
582 Auth(auth): Auth,
583 Query(query): Query<ProxyTokenQuery>,
584 OptionalRequestId(req_id): OptionalRequestId,
585) -> ClResult<(StatusCode, Json<ApiResponse<ProxyTokenRes>>)> {
586 if let Some(ref target_id_tag) = query.id_tag {
588 if target_id_tag != own_id_tag.as_ref() {
589 info!("Getting federated proxy token for {} -> {}", &auth.id_tag, target_id_tag);
590
591 let token = app.request.create_proxy_token(auth.tn_id, target_id_tag, None).await?;
593
594 #[derive(Deserialize)]
596 struct AccessTokenClaims {
597 r: Option<String>,
598 }
599
600 let roles: Option<Vec<String>> = match decode_jwt_no_verify::<AccessTokenClaims>(&token)
601 {
602 Ok(claims) => {
603 info!("Decoded federated token, roles claim: {:?}", claims.r);
604 claims.r.map(|r| r.split(',').map(String::from).collect())
605 }
606 Err(e) => {
607 warn!("Failed to decode federated token for roles: {:?}", e);
608 None
609 }
610 };
611
612 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles })
613 .with_req_id(req_id.unwrap_or_default());
614 return Ok((StatusCode::OK, Json(response)));
615 }
616 }
617
618 info!("Generating local access token for {}", &auth.id_tag);
620 let roles_str: String = auth.roles.iter().map(|r| r.as_ref()).collect::<Vec<&str>>().join(",");
621 let token = app
622 .auth_adapter
623 .create_access_token(
624 auth.tn_id,
625 &auth_adapter::AccessToken {
626 iss: &own_id_tag,
627 sub: Some(&auth.id_tag),
628 r: if roles_str.is_empty() { None } else { Some(&roles_str) },
629 scope: None,
630 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
631 },
632 )
633 .await?;
634
635 let roles: Vec<String> = auth.roles.iter().map(|r| r.to_string()).collect();
637 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles: Some(roles) })
638 .with_req_id(req_id.unwrap_or_default());
639
640 Ok((StatusCode::OK, Json(response)))
641}
642
643#[derive(Deserialize)]
647pub struct SetPasswordReq {
648 #[serde(rename = "refId")]
649 ref_id: String,
650 #[serde(rename = "newPassword")]
651 new_password: String,
652}
653
654pub async fn post_set_password(
655 State(app): State<App>,
656 OptionalRequestId(req_id): OptionalRequestId,
657 Json(req): Json<SetPasswordReq>,
658) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
659 if req.new_password.len() < 8 {
661 return Err(Error::ValidationError("Password must be at least 8 characters".into()));
662 }
663
664 if req.new_password.trim().is_empty() {
665 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
666 }
667
668 let (tn_id, id_tag, _ref_data) = app
671 .meta_adapter
672 .use_ref(&req.ref_id, &["welcome", "password"])
673 .await
674 .map_err(|e| {
675 warn!("Failed to use ref {}: {}", req.ref_id, e);
676 match e {
677 Error::NotFound => Error::ValidationError("Invalid or expired reference".into()),
678 Error::ValidationError(_) => e,
679 _ => Error::ValidationError("Invalid reference".into()),
680 }
681 })?;
682
683 info!(
684 tn_id = ?tn_id,
685 id_tag = %id_tag,
686 ref_id = %req.ref_id,
687 "Setting password via reference"
688 );
689
690 app.auth_adapter.update_tenant_password(&id_tag, &req.new_password).await?;
692
693 info!(
694 tn_id = ?tn_id,
695 id_tag = %id_tag,
696 "Password set successfully, generating login token"
697 );
698
699 let auth = app.auth_adapter.create_tenant_login(&id_tag).await?;
701
702 let (_status, Json(login_data)) = return_login(&app, auth).await?;
704 let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
705
706 Ok((StatusCode::OK, Json(response)))
707}
708
709#[derive(Deserialize)]
713pub struct ForgotPasswordReq {
714 email: String,
715}
716
717#[derive(Serialize)]
718pub struct ForgotPasswordRes {
719 message: String,
720}
721
722pub async fn post_forgot_password(
723 State(app): State<App>,
724 ConnectInfo(addr): ConnectInfo<SocketAddr>,
725 OptionalRequestId(req_id): OptionalRequestId,
726 Json(req): Json<ForgotPasswordReq>,
727) -> ClResult<(StatusCode, Json<ApiResponse<ForgotPasswordRes>>)> {
728 let email = req.email.trim().to_lowercase();
729
730 info!(email = %email, ip = %addr.ip(), "Password reset requested");
731
732 let success_response = || {
734 ApiResponse::new(ForgotPasswordRes {
735 message: "If an account with this email exists, a password reset link has been sent."
736 .to_string(),
737 })
738 .with_req_id(req_id.clone().unwrap_or_default())
739 };
740
741 if !email.contains('@') || email.len() < 5 {
743 return Ok((StatusCode::OK, Json(success_response())));
744 }
745
746 let auth_opts =
748 ListTenantsOptions { status: None, q: Some(&email), limit: Some(10), offset: None };
749 let tenants = match app.auth_adapter.list_tenants(&auth_opts).await {
750 Ok(t) => t,
751 Err(e) => {
752 warn!(email = %email, error = ?e, "Failed to look up tenant by email");
753 return Ok((StatusCode::OK, Json(success_response())));
754 }
755 };
756
757 let tenant = tenants.into_iter().find(|t| t.email.as_deref() == Some(email.as_str()));
759
760 let Some(tenant) = tenant else {
761 info!(email = %email, "No tenant found for email (not revealing)");
762 return Ok((StatusCode::OK, Json(success_response())));
763 };
764
765 let tn_id = tenant.tn_id;
766 let id_tag = tenant.id_tag.to_string();
767
768 let opts = ListRefsOptions {
771 typ: Some("password".to_string()),
772 filter: Some("all".to_string()),
773 resource_id: None,
774 };
775 let recent_refs = app.meta_adapter.list_refs(tn_id, &opts).await.unwrap_or_default();
776
777 let now = Timestamp::now().0;
778 let one_hour_ago = now - 3600;
779 let one_day_ago = now - 86400;
780
781 let hourly_count = recent_refs.iter().filter(|r| r.created_at.0 > one_hour_ago).count();
782 let daily_count = recent_refs.iter().filter(|r| r.created_at.0 > one_day_ago).count();
783
784 if hourly_count >= 1 {
785 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (hourly)");
786 return Ok((StatusCode::OK, Json(success_response())));
787 }
788
789 if daily_count >= 3 {
790 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (daily)");
791 return Ok((StatusCode::OK, Json(success_response())));
792 }
793
794 let user_name = app
796 .meta_adapter
797 .read_tenant(tn_id)
798 .await
799 .map(|t| t.name.to_string())
800 .unwrap_or_else(|_| id_tag.clone());
801
802 let expires_at = Some(Timestamp(now + 86400)); let (ref_id, reset_url) = match create_ref_internal(
805 &app,
806 tn_id,
807 CreateRefInternalParams {
808 id_tag: &id_tag,
809 typ: "password",
810 description: Some("User-initiated password reset"),
811 expires_at,
812 path_prefix: "/reset-password",
813 resource_id: None,
814 count: None,
815 },
816 )
817 .await
818 {
819 Ok(result) => result,
820 Err(e) => {
821 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to create password reset ref");
822 return Ok((StatusCode::OK, Json(success_response())));
823 }
824 };
825
826 let lang = get_tenant_lang(&app.settings, tn_id).await;
828
829 let base_id_tag = app.opts.base_id_tag.as_ref().map(|s| s.as_ref()).unwrap_or("cloudillo");
831
832 let email_params = EmailTaskParams {
834 to: email.clone(),
835 subject: None,
836 template_name: "password_reset".to_string(),
837 template_vars: serde_json::json!({
838 "identity_tag": user_name,
839 "base_id_tag": base_id_tag,
840 "instance_name": "Cloudillo",
841 "reset_link": reset_url,
842 "expire_hours": 24,
843 }),
844 lang,
845 custom_key: Some(format!("pw-reset:{}:{}", tn_id.0, now)),
846 from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
847 };
848
849 if let Err(e) =
850 EmailModule::schedule_email_task(&app.scheduler, &app.settings, tn_id, email_params).await
851 {
852 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to schedule password reset email");
853 } else {
855 info!(
856 tn_id = ?tn_id,
857 id_tag = %id_tag,
858 ref_id = %ref_id,
859 "Password reset email scheduled"
860 );
861 }
862
863 Ok((StatusCode::OK, Json(success_response())))
864}
865
866