1use axum::{
2 extract::{ConnectInfo, Query, State},
3 http::{HeaderMap, 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(Clone, 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(ToString::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.trim().is_empty() {
257 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
258 }
259
260 if req.new_password.len() < 8 {
261 return Err(Error::ValidationError("Password must be at least 8 characters".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)]
306pub struct GetAccessTokenQuery {
307 #[serde(default)]
308 token: Option<String>,
309 scope: Option<String>,
310 #[serde(rename = "refId")]
312 ref_id: Option<String>,
313 #[serde(rename = "apiKey")]
315 api_key: Option<String>,
316 #[serde(default)]
318 refresh: Option<bool>,
319 via: Option<String>,
321}
322
323pub async fn get_access_token(
324 State(app): State<App>,
325 tn_id: TnId,
326 id_tag: IdTag,
327 ConnectInfo(addr): ConnectInfo<SocketAddr>,
328 OptionalAuth(maybe_auth): OptionalAuth,
329 Query(query): Query<GetAccessTokenQuery>,
330 OptionalRequestId(req_id): OptionalRequestId,
331) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
332 use tracing::warn;
333
334 info!("Got access token request for id_tag={} with scope={:?}", id_tag.0, query.scope);
335
336 if let Some(ref via_file_id) = query.via {
338 use cloudillo_types::types::{AccessLevel, TokenScope};
339
340 let scope_str = query
342 .scope
343 .as_deref()
344 .ok_or_else(|| Error::ValidationError("scope parameter required with via".into()))?;
345
346 let token_scope = TokenScope::parse(scope_str)
347 .ok_or_else(|| Error::ValidationError("Invalid scope format".into()))?;
348
349 let TokenScope::File { file_id: ref target_file_id, access: requested_access } =
350 token_scope
351 else {
352 return Err(Error::ValidationError("scope must be a file scope".into()));
353 };
354
355 info!(
356 "Via token request: via={}, target={}, access={:?}",
357 via_file_id, target_file_id, requested_access
358 );
359
360 let auth = maybe_auth.as_ref().ok_or(Error::Unauthorized)?;
362
363 let via_bare_file_id =
365 via_file_id.split_once(':').map_or(via_file_id.as_str(), |(_, fid)| fid);
366
367 let caller_has_via_access = if let Some(ref caller_scope) = auth.scope {
369 if let Some(TokenScope::File { file_id: ref scope_fid, access: scope_access }) =
371 TokenScope::parse(caller_scope)
372 {
373 scope_fid == via_bare_file_id && scope_access != AccessLevel::None
374 } else {
375 false
376 }
377 } else {
378 use cloudillo_core::file_access::{self, FileAccessCtx};
380 let ctx = FileAccessCtx {
381 user_id_tag: &auth.id_tag,
382 tenant_id_tag: &id_tag.0,
383 user_roles: &auth.roles,
384 };
385 file_access::check_file_access_with_scope(
386 &app,
387 tn_id,
388 via_bare_file_id,
389 &ctx,
390 None,
391 None,
392 )
393 .await
394 .is_ok()
395 };
396
397 if !caller_has_via_access {
398 warn!("Via token denied: caller has no access to source file {}", via_file_id);
399 return Err(Error::PermissionDenied);
400 }
401
402 let link_perm = app
404 .meta_adapter
405 .check_share_access(tn_id, 'F', target_file_id, 'F', via_bare_file_id)
406 .await?
407 .ok_or_else(|| {
408 warn!(
409 "Via token denied: no file link from {} to {}",
410 via_bare_file_id, target_file_id
411 );
412 Error::PermissionDenied
413 })?;
414
415 let effective_access = requested_access.min(AccessLevel::from_perm_char(link_perm));
417
418 let access_char = if effective_access == AccessLevel::Write { 'W' } else { 'R' };
420 let target_scope = format!("file:{}:{}", target_file_id, access_char);
421
422 let token_result = app
423 .auth_adapter
424 .create_access_token(
425 tn_id,
426 &auth_adapter::AccessToken {
427 iss: &id_tag.0,
428 sub: auth.scope.as_ref().map_or(Some(&*auth.id_tag), |_| None),
429 r: None,
430 scope: Some(&target_scope),
431 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
432 },
433 )
434 .await?;
435
436 info!("Created via token for {} with scope {}", target_file_id, target_scope);
437 let response = ApiResponse::new(json!({
438 "token": token_result,
439 "scope": target_scope,
440 "resourceId": target_file_id,
441 "accessLevel": effective_access.as_str(),
442 }))
443 .with_req_id(req_id.unwrap_or_default());
444 return Ok((StatusCode::OK, Json(response)));
445 }
446
447 if let Some(token_param) = query.token {
449 info!("Verifying action token from query parameter");
450 let verify_fn = app.ext::<ActionVerifyFn>()?;
451 let auth_action = verify_fn(&app, tn_id, &token_param, Some(&addr.ip())).await?;
452 if *auth_action.aud.as_ref().ok_or(Error::PermissionDenied)?.as_ref() != *id_tag.0 {
453 warn!("Auth action issuer {} doesn't match id_tag {}", auth_action.iss, id_tag.0);
454 return Err(Error::PermissionDenied);
455 }
456 info!("Got auth action: {:?}", &auth_action);
457
458 info!(
459 "Creating access token with t={}, u={}, scope={:?}",
460 id_tag.0,
461 auth_action.iss,
462 query.scope.as_deref()
463 );
464
465 let profile_roles = match app.meta_adapter.read_profile_roles(tn_id, &auth_action.iss).await
467 {
468 Ok(roles) => {
469 info!(
470 "Found profile roles for {} in tn_id {:?}: {:?}",
471 auth_action.iss, tn_id, roles
472 );
473 roles
474 }
475 Err(e) => {
476 warn!(
477 "Failed to read profile roles for {} in tn_id {:?}: {}",
478 auth_action.iss, tn_id, e
479 );
480 None
481 }
482 };
483
484 let expanded_roles = profile_roles
485 .as_ref()
486 .map(|roles| expand_roles(roles))
487 .filter(|s| !s.is_empty());
488
489 info!("Expanded roles for access token: {:?}", expanded_roles);
490
491 let token_result = app
492 .auth_adapter
493 .create_access_token(
494 tn_id,
495 &auth_adapter::AccessToken {
496 iss: &id_tag.0,
497 sub: Some(&auth_action.iss),
498 r: expanded_roles.as_deref(),
499 scope: query.scope.as_deref(),
500 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
501 },
502 )
503 .await?;
504 info!("Got access token: {}", &token_result);
505 let response = ApiResponse::new(json!({ "token": token_result }))
506 .with_req_id(req_id.unwrap_or_default());
507 Ok((StatusCode::OK, Json(response)))
508 } else if let Some(ref_id) = query.ref_id {
509 let is_refresh = query.refresh.unwrap_or(false);
511 info!("Exchanging ref_id {} for scoped access token (refresh={})", ref_id, is_refresh);
512
513 let (ref_tn_id, _ref_id_tag, ref_data) = if is_refresh {
516 app.meta_adapter.validate_ref(&ref_id, &["share.file"]).await
517 } else {
518 app.meta_adapter.use_ref(&ref_id, &["share.file"]).await
519 }
520 .map_err(|e| {
521 warn!(
522 "Failed to {} ref {}: {}",
523 if is_refresh { "validate" } else { "use" },
524 ref_id,
525 e
526 );
527 match e {
528 Error::NotFound => Error::ValidationError("Invalid or expired share link".into()),
529 Error::ValidationError(_) => e,
530 _ => Error::ValidationError("Invalid share link".into()),
531 }
532 })?;
533
534 if ref_tn_id != tn_id {
536 warn!(
537 "Ref tenant mismatch: ref belongs to {:?} but request is for {:?}",
538 ref_tn_id, tn_id
539 );
540 return Err(Error::PermissionDenied);
541 }
542
543 let file_id = ref_data
545 .resource_id
546 .ok_or_else(|| Error::ValidationError("Share link missing resource_id".into()))?;
547 let access_level = ref_data.access_level.unwrap_or('R');
548
549 let scope = format!("file:{}:{}", file_id, access_level);
552 info!("Creating scoped access token with scope={}", scope);
553
554 let token_result = app
555 .auth_adapter
556 .create_access_token(
557 tn_id,
558 &auth_adapter::AccessToken {
559 iss: &id_tag.0,
560 sub: None, r: None, scope: Some(&scope),
563 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
564 },
565 )
566 .await?;
567
568 info!("Got scoped access token for share link");
569 let response = ApiResponse::new(json!({
570 "token": token_result,
571 "scope": scope,
572 "resourceId": file_id.to_string(),
573 "accessLevel": if access_level == 'W' { "write" } else { "read" }
574 }))
575 .with_req_id(req_id.unwrap_or_default());
576 Ok((StatusCode::OK, Json(response)))
577 } else if let Some(api_key) = query.api_key {
578 info!("Exchanging API key for access token");
580
581 let validation = app.auth_adapter.validate_api_key(&api_key).await.map_err(|e| {
583 warn!("API key validation failed: {:?}", e);
584 Error::PermissionDenied
585 })?;
586
587 if validation.tn_id != tn_id {
589 warn!(
590 "API key tenant mismatch: key belongs to {:?} but request is for {:?}",
591 validation.tn_id, tn_id
592 );
593 return Err(Error::PermissionDenied);
594 }
595
596 info!(
597 "Creating access token from API key for id_tag={}, scopes={:?}",
598 validation.id_tag, validation.scopes
599 );
600
601 let token_result = app
603 .auth_adapter
604 .create_access_token(
605 tn_id,
606 &auth_adapter::AccessToken {
607 iss: &id_tag.0,
608 sub: Some(&validation.id_tag),
609 r: validation.roles.as_deref(),
610 scope: validation.scopes.as_deref(),
611 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
612 },
613 )
614 .await?;
615
616 info!("Got access token from API key: {}", &token_result);
617
618 let auth_login = auth_adapter::AuthLogin {
620 tn_id,
621 id_tag: validation.id_tag,
622 roles: validation.roles.map(|r| r.split(',').map(Into::into).collect()),
623 token: token_result,
624 };
625 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
626 let response = ApiResponse::new(serde_json::to_value(login_data)?)
627 .with_req_id(req_id.unwrap_or_default());
628 Ok((StatusCode::OK, Json(response)))
629 } else {
630 let auth = maybe_auth.ok_or(Error::Unauthorized)?;
632
633 info!(
634 "Using authenticated session for id_tag={}, scope={:?}",
635 auth.id_tag,
636 query.scope.as_deref()
637 );
638
639 let profile_roles =
641 app.meta_adapter.read_profile_roles(tn_id, &auth.id_tag).await.ok().flatten();
642
643 let profile_roles: Option<Box<[Box<str>]>> = if auth.id_tag == id_tag.0 {
645 Some(vec!["leader".into()].into_boxed_slice())
646 } else {
647 profile_roles
648 };
649
650 let expanded_roles = profile_roles
651 .as_ref()
652 .map(|roles| expand_roles(roles))
653 .filter(|s: &String| !s.is_empty());
654
655 let token_result = app
656 .auth_adapter
657 .create_access_token(
658 tn_id,
659 &auth_adapter::AccessToken {
660 iss: &id_tag.0,
661 sub: Some(&auth.id_tag),
662 r: expanded_roles.as_deref(),
663 scope: query.scope.as_deref(),
664 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
665 },
666 )
667 .await?;
668 info!("Got access token from session: {}", &token_result);
669 let response = ApiResponse::new(json!({ "token": token_result }))
670 .with_req_id(req_id.unwrap_or_default());
671 Ok((StatusCode::OK, Json(response)))
672 }
673}
674
675#[skip_serializing_none]
680#[derive(Serialize)]
681pub struct ProxyTokenRes {
682 token: String,
683 roles: Option<Vec<String>>,
685}
686
687#[derive(Deserialize)]
688pub struct ProxyTokenQuery {
689 #[serde(rename = "idTag")]
690 id_tag: Option<String>,
691}
692
693pub async fn get_proxy_token(
694 State(app): State<App>,
695 IdTag(own_id_tag): IdTag,
696 Auth(auth): Auth,
697 Query(query): Query<ProxyTokenQuery>,
698 OptionalRequestId(req_id): OptionalRequestId,
699) -> ClResult<(StatusCode, Json<ApiResponse<ProxyTokenRes>>)> {
700 if let Some(ref target_id_tag) = query.id_tag {
702 if target_id_tag != own_id_tag.as_ref() {
703 #[derive(Deserialize)]
704 struct AccessTokenClaims {
705 r: Option<String>,
706 }
707
708 info!("Getting federated proxy token for {} -> {}", &auth.id_tag, target_id_tag);
709
710 let token = app.request.create_proxy_token(auth.tn_id, target_id_tag, None).await?;
712
713 let roles: Option<Vec<String>> = match decode_jwt_no_verify::<AccessTokenClaims>(&token)
714 {
715 Ok(claims) => {
716 info!("Decoded federated token, roles claim: {:?}", claims.r);
717 claims.r.map(|r| r.split(',').map(String::from).collect())
718 }
719 Err(e) => {
720 warn!("Failed to decode federated token for roles: {:?}", e);
721 None
722 }
723 };
724
725 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles })
726 .with_req_id(req_id.unwrap_or_default());
727 return Ok((StatusCode::OK, Json(response)));
728 }
729 }
730
731 info!("Generating local access token for {}", &auth.id_tag);
733 let roles_str: String = auth.roles.iter().map(AsRef::as_ref).collect::<Vec<&str>>().join(",");
734 let token = app
735 .auth_adapter
736 .create_access_token(
737 auth.tn_id,
738 &auth_adapter::AccessToken {
739 iss: &own_id_tag,
740 sub: Some(&auth.id_tag),
741 r: if roles_str.is_empty() { None } else { Some(&roles_str) },
742 scope: None,
743 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
744 },
745 )
746 .await?;
747
748 let roles: Vec<String> = auth.roles.iter().map(ToString::to_string).collect();
750 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles: Some(roles) })
751 .with_req_id(req_id.unwrap_or_default());
752
753 Ok((StatusCode::OK, Json(response)))
754}
755
756#[derive(Deserialize)]
760pub struct SetPasswordReq {
761 #[serde(rename = "refId")]
762 ref_id: String,
763 #[serde(rename = "newPassword")]
764 new_password: String,
765}
766
767pub async fn post_set_password(
768 State(app): State<App>,
769 OptionalRequestId(req_id): OptionalRequestId,
770 Json(req): Json<SetPasswordReq>,
771) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
772 if req.new_password.trim().is_empty() {
774 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
775 }
776
777 if req.new_password.len() < 8 {
778 return Err(Error::ValidationError("Password must be at least 8 characters".into()));
779 }
780
781 let (tn_id, id_tag, _ref_data) = app
784 .meta_adapter
785 .use_ref(&req.ref_id, &["welcome", "password"])
786 .await
787 .map_err(|e| {
788 warn!("Failed to use ref {}: {}", req.ref_id, e);
789 match e {
790 Error::NotFound => Error::ValidationError("Invalid or expired reference".into()),
791 Error::ValidationError(_) => e,
792 _ => Error::ValidationError("Invalid reference".into()),
793 }
794 })?;
795
796 info!(
797 tn_id = ?tn_id,
798 id_tag = %id_tag,
799 ref_id = %req.ref_id,
800 "Setting password via reference"
801 );
802
803 app.auth_adapter.update_tenant_password(&id_tag, &req.new_password).await?;
805
806 info!(
807 tn_id = ?tn_id,
808 id_tag = %id_tag,
809 "Password set successfully, generating login token"
810 );
811
812 let auth = app.auth_adapter.create_tenant_login(&id_tag).await?;
814
815 let (_status, Json(login_data)) = return_login(&app, auth).await?;
817 let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
818
819 Ok((StatusCode::OK, Json(response)))
820}
821
822#[derive(Deserialize)]
826pub struct ForgotPasswordReq {
827 email: String,
828}
829
830#[derive(Serialize)]
831pub struct ForgotPasswordRes {
832 message: String,
833}
834
835pub async fn post_forgot_password(
836 State(app): State<App>,
837 ConnectInfo(addr): ConnectInfo<SocketAddr>,
838 OptionalRequestId(req_id): OptionalRequestId,
839 Json(req): Json<ForgotPasswordReq>,
840) -> ClResult<(StatusCode, Json<ApiResponse<ForgotPasswordRes>>)> {
841 let email = req.email.trim().to_lowercase();
842
843 info!(email = %email, ip = %addr.ip(), "Password reset requested");
844
845 let success_response = || {
847 ApiResponse::new(ForgotPasswordRes {
848 message: "If an account with this email exists, a password reset link has been sent."
849 .to_string(),
850 })
851 .with_req_id(req_id.clone().unwrap_or_default())
852 };
853
854 if !email.contains('@') || email.len() < 5 {
856 return Ok((StatusCode::OK, Json(success_response())));
857 }
858
859 let auth_opts =
861 ListTenantsOptions { status: None, q: Some(&email), limit: Some(10), offset: None };
862 let tenants = match app.auth_adapter.list_tenants(&auth_opts).await {
863 Ok(t) => t,
864 Err(e) => {
865 warn!(email = %email, error = ?e, "Failed to look up tenant by email");
866 return Ok((StatusCode::OK, Json(success_response())));
867 }
868 };
869
870 let tenant = tenants.into_iter().find(|t| t.email.as_deref() == Some(email.as_str()));
872
873 let Some(tenant) = tenant else {
874 info!(email = %email, "No tenant found for email (not revealing)");
875 return Ok((StatusCode::OK, Json(success_response())));
876 };
877
878 let tn_id = tenant.tn_id;
879 let id_tag = tenant.id_tag.to_string();
880
881 let opts = ListRefsOptions {
884 typ: Some("password".to_string()),
885 filter: Some("all".to_string()),
886 resource_id: None,
887 };
888 let recent_refs = app.meta_adapter.list_refs(tn_id, &opts).await.unwrap_or_default();
889
890 let now = Timestamp::now().0;
891 let one_hour_ago = now - 3600;
892 let one_day_ago = now - 86400;
893
894 let hourly_count = recent_refs.iter().filter(|r| r.created_at.0 > one_hour_ago).count();
895 let daily_count = recent_refs.iter().filter(|r| r.created_at.0 > one_day_ago).count();
896
897 if hourly_count >= 1 {
898 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (hourly)");
899 return Ok((StatusCode::OK, Json(success_response())));
900 }
901
902 if daily_count >= 3 {
903 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (daily)");
904 return Ok((StatusCode::OK, Json(success_response())));
905 }
906
907 let user_name = app
909 .meta_adapter
910 .read_tenant(tn_id)
911 .await
912 .map_or_else(|_| id_tag.clone(), |t| t.name.to_string());
913
914 let expires_at = Some(Timestamp(now + 86400)); let (ref_id, reset_url) = match create_ref_internal(
917 &app,
918 tn_id,
919 CreateRefInternalParams {
920 id_tag: &id_tag,
921 typ: "password",
922 description: Some("User-initiated password reset"),
923 expires_at,
924 path_prefix: "/reset-password",
925 resource_id: None,
926 count: None,
927 },
928 )
929 .await
930 {
931 Ok(result) => result,
932 Err(e) => {
933 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to create password reset ref");
934 return Ok((StatusCode::OK, Json(success_response())));
935 }
936 };
937
938 let lang = get_tenant_lang(&app.settings, tn_id).await;
940
941 let base_id_tag = app.opts.base_id_tag.as_ref().map_or("cloudillo", AsRef::as_ref);
943
944 let email_params = EmailTaskParams {
946 to: email.clone(),
947 subject: None,
948 template_name: "password_reset".to_string(),
949 template_vars: serde_json::json!({
950 "identity_tag": user_name,
951 "base_id_tag": base_id_tag,
952 "instance_name": "Cloudillo",
953 "reset_link": reset_url,
954 "expire_hours": 24,
955 }),
956 lang,
957 custom_key: Some(format!("pw-reset:{}:{}", tn_id.0, now)),
958 from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
959 };
960
961 if let Err(e) =
962 EmailModule::schedule_email_task(&app.scheduler, &app.settings, tn_id, email_params).await
963 {
964 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to schedule password reset email");
965 } else {
967 info!(
968 tn_id = ?tn_id,
969 id_tag = %id_tag,
970 ref_id = %ref_id,
971 "Password reset email scheduled"
972 );
973 }
974
975 Ok((StatusCode::OK, Json(success_response())))
976}
977
978#[derive(Serialize)]
983#[serde(tag = "status")]
984pub enum LoginInitResponse {
985 #[serde(rename = "authenticated")]
986 Authenticated { login: Login },
987 #[serde(rename = "unauthenticated")]
988 Unauthenticated {
989 #[serde(rename = "qrLogin")]
990 qr_login: crate::qr_login::InitResponse,
991 #[serde(rename = "webAuthn")]
992 web_authn: Option<crate::webauthn::LoginChallengeRes>,
993 },
994}
995
996pub async fn post_login_init(
997 State(app): State<App>,
998 OptionalAuth(auth): OptionalAuth,
999 tn_id: TnId,
1000 id_tag: IdTag,
1001 ConnectInfo(addr): ConnectInfo<SocketAddr>,
1002 OptionalRequestId(req_id): OptionalRequestId,
1003 headers: HeaderMap,
1004) -> ClResult<(StatusCode, Json<ApiResponse<LoginInitResponse>>)> {
1005 if let Some(auth) = auth {
1006 info!("login-init for authenticated user {}", &auth.id_tag);
1008 let auth_login = app.auth_adapter.create_tenant_login(&auth.id_tag).await?;
1009 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
1010 let response = ApiResponse::new(LoginInitResponse::Authenticated { login: login_data })
1011 .with_req_id(req_id.unwrap_or_default());
1012 Ok((StatusCode::OK, Json(response)))
1013 } else {
1014 debug!("login-init for unauthenticated user");
1016 let qr_result = crate::qr_login::create_session(&app, tn_id, &addr, &headers)?;
1017 let wa_result = crate::webauthn::try_login_challenge(&app, &id_tag, tn_id).await;
1018 let response = ApiResponse::new(LoginInitResponse::Unauthenticated {
1019 qr_login: qr_result,
1020 web_authn: wa_result,
1021 })
1022 .with_req_id(req_id.unwrap_or_default());
1023 Ok((StatusCode::OK, Json(response)))
1024 }
1025}
1026
1027