1use axum::{
5 extract::{ConnectInfo, Query, State},
6 http::{HeaderMap, StatusCode},
7 Json,
8};
9use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL, Engine};
10use rand::RngExt;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use serde_with::skip_serializing_none;
14use std::net::SocketAddr;
15
16use cloudillo_core::{
17 extract::{IdTag, OptionalAuth, OptionalRequestId},
18 rate_limit::{PenaltyReason, RateLimitApi},
19 roles::expand_roles,
20 ActionVerifyFn, Auth,
21};
22use cloudillo_email::{get_tenant_lang, EmailModule, EmailTaskParams};
23use cloudillo_ref::service::{create_ref_internal, CreateRefInternalParams};
24use cloudillo_types::{
25 action_types::ACCESS_TOKEN_EXPIRY,
26 auth_adapter::{self, ListTenantsOptions},
27 meta_adapter::ListRefsOptions,
28 types::ApiResponse,
29 utils::decode_jwt_no_verify,
30};
31
32use crate::prelude::*;
33
34const SW_ENCRYPTION_KEY_VAR: &str = "sw_encryption_key";
36
37fn generate_sw_encryption_key() -> String {
40 let key: [u8; 32] = rand::rng().random();
41 BASE64_URL.encode(key)
42}
43
44#[skip_serializing_none]
46#[derive(Clone, Serialize)]
47pub struct Login {
48 #[serde(rename = "tnId")]
50 tn_id: TnId,
51 #[serde(rename = "idTag")]
52 id_tag: String,
53 roles: Option<Vec<String>>,
54 token: String,
55 name: String,
57 #[serde(rename = "profilePic")]
58 profile_pic: String,
59 settings: Vec<(String, String)>,
60 #[serde(rename = "swEncryptionKey")]
62 sw_encryption_key: Option<String>,
63}
64
65#[derive(Serialize)]
66pub struct IdTagRes {
67 #[serde(rename = "idTag")]
68 id_tag: String,
69}
70
71pub async fn get_id_tag(
72 State(app): State<App>,
73 OptionalRequestId(_req_id): OptionalRequestId,
74 req: axum::http::Request<axum::body::Body>,
75) -> ClResult<(StatusCode, Json<IdTagRes>)> {
76 let host = req
77 .uri()
78 .host()
79 .or_else(|| req.headers().get(axum::http::header::HOST).and_then(|h| h.to_str().ok()))
80 .unwrap_or_default();
81 let cert_data = app.auth_adapter.read_cert_by_domain(host).await?;
82
83 Ok((StatusCode::OK, Json(IdTagRes { id_tag: cert_data.id_tag.to_string() })))
84}
85
86pub async fn return_login(
87 app: &App,
88 auth: auth_adapter::AuthLogin,
89) -> ClResult<(StatusCode, Json<Login>)> {
90 let tenant = app.meta_adapter.read_tenant(auth.tn_id).await.ok();
93
94 let (name, profile_pic) = match tenant {
95 Some(t) => (t.name.to_string(), t.profile_pic.map(|p| p.to_string())),
96 None => (auth.id_tag.to_string(), None),
97 };
98
99 let sw_encryption_key = match app.auth_adapter.read_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR).await
101 {
102 Ok(key) => Some(key.to_string()),
103 Err(Error::NotFound) => {
104 let key = generate_sw_encryption_key();
106 if let Err(e) =
107 app.auth_adapter.update_var(auth.tn_id, SW_ENCRYPTION_KEY_VAR, &key).await
108 {
109 warn!("Failed to store SW encryption key: {}", e);
110 None
111 } else {
112 info!("Generated new SW encryption key for tenant {}", auth.tn_id.0);
113 Some(key)
114 }
115 }
116 Err(e) => {
117 warn!("Failed to read SW encryption key: {}", e);
118 None
119 }
120 };
121
122 let login = Login {
123 tn_id: auth.tn_id,
124 id_tag: auth.id_tag.to_string(),
125 roles: auth.roles.map(|roles| roles.iter().map(ToString::to_string).collect()),
126 token: auth.token.to_string(),
127 name,
128 profile_pic: profile_pic.unwrap_or_default(),
129 settings: vec![],
130 sw_encryption_key,
131 };
132
133 Ok((StatusCode::OK, Json(login)))
134}
135
136#[derive(Deserialize)]
138pub struct LoginReq {
139 #[serde(rename = "idTag")]
140 id_tag: String,
141 password: String,
142}
143
144pub async fn post_login(
145 State(app): State<App>,
146 ConnectInfo(addr): ConnectInfo<SocketAddr>,
147 OptionalRequestId(req_id): OptionalRequestId,
148 Json(login): Json<LoginReq>,
149) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
150 let auth = app.auth_adapter.check_tenant_password(&login.id_tag, &login.password).await;
151
152 if let Ok(auth) = auth {
153 let (_status, Json(login_data)) = return_login(&app, auth).await?;
154 let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
155 Ok((StatusCode::OK, Json(response)))
156 } else {
157 if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
159 warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
160 }
161 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
162 Err(Error::PermissionDenied)
163 }
164}
165
166pub async fn get_login_token(
168 State(app): State<App>,
169 OptionalAuth(auth): OptionalAuth,
170 OptionalRequestId(req_id): OptionalRequestId,
171) -> ClResult<(StatusCode, Json<ApiResponse<Option<Login>>>)> {
172 if let Some(auth) = auth {
173 info!("login-token for {}", &auth.id_tag);
174 let auth = app.auth_adapter.create_tenant_login(&auth.id_tag).await;
175 if let Ok(auth) = auth {
176 info!("token: {}", &auth.token);
177 let (_status, Json(login_data)) = return_login(&app, auth).await?;
178 let response =
179 ApiResponse::new(Some(login_data)).with_req_id(req_id.unwrap_or_default());
180 Ok((StatusCode::OK, Json(response)))
181 } else {
182 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
183 Err(Error::PermissionDenied)
184 }
185 } else {
186 info!("login-token called without authentication");
188 let response = ApiResponse::new(None).with_req_id(req_id.unwrap_or_default());
189 Ok((StatusCode::OK, Json(response)))
190 }
191}
192
193#[derive(Deserialize, Default)]
195pub struct LogoutReq {
196 #[serde(rename = "apiKey")]
198 api_key: Option<String>,
199}
200
201pub async fn post_logout(
203 State(app): State<App>,
204 Auth(auth): Auth,
205 OptionalRequestId(req_id): OptionalRequestId,
206 Json(req): Json<LogoutReq>,
207) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
208 if let Some(ref api_key) = req.api_key {
213 match app.auth_adapter.validate_api_key(api_key).await {
214 Ok(validation) if validation.tn_id == auth.tn_id => {
215 if let Err(e) = app.auth_adapter.delete_api_key(auth.tn_id, validation.key_id).await
216 {
217 warn!("Failed to delete API key {} on logout: {:?}", validation.key_id, e);
218 } else {
219 info!(
220 "Deleted API key {} for user {} on logout",
221 validation.key_id, auth.id_tag
222 );
223 }
224 }
225 Ok(_) => {
226 warn!("API key provided at logout does not belong to user {}", auth.id_tag);
227 }
228 Err(e) => {
229 debug!("API key validation failed on logout: {:?}", e);
231 }
232 }
233 }
234
235 info!("User {} logged out", auth.id_tag);
236
237 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
238
239 Ok((StatusCode::OK, Json(response)))
240}
241
242#[derive(Deserialize)]
244pub struct PasswordReq {
245 #[serde(rename = "currentPassword")]
246 current_password: String,
247 #[serde(rename = "newPassword")]
248 new_password: String,
249}
250
251pub async fn post_password(
252 State(app): State<App>,
253 ConnectInfo(addr): ConnectInfo<SocketAddr>,
254 Auth(auth): Auth,
255 OptionalRequestId(req_id): OptionalRequestId,
256 Json(req): Json<PasswordReq>,
257) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
258 if req.new_password.trim().is_empty() {
260 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
261 }
262
263 if req.new_password.len() < 8 {
264 return Err(Error::ValidationError("Password must be at least 8 characters".into()));
265 }
266
267 if req.new_password == req.current_password {
268 return Err(Error::ValidationError(
269 "New password must be different from current password".into(),
270 ));
271 }
272
273 let verification = app
275 .auth_adapter
276 .check_tenant_password(&auth.id_tag, &req.current_password)
277 .await;
278
279 if verification.is_err() {
280 if let Err(e) = app.rate_limiter.penalize(&addr.ip(), PenaltyReason::AuthFailure, 1) {
282 warn!("Failed to record auth penalty for {}: {}", addr.ip(), e);
283 }
284 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
286 warn!("Failed password verification for user {}", auth.id_tag);
287 return Err(Error::PermissionDenied);
288 }
289
290 app.auth_adapter.update_tenant_password(&auth.id_tag, &req.new_password).await?;
292
293 info!("User {} successfully changed their password", auth.id_tag);
294
295 let response = ApiResponse::new(()).with_req_id(req_id.unwrap_or_default());
296
297 Ok((StatusCode::OK, Json(response)))
298}
299
300#[derive(Deserialize)]
309pub struct GetAccessTokenQuery {
310 #[serde(default)]
311 token: Option<String>,
312 scope: Option<String>,
313 #[serde(rename = "refId")]
315 ref_id: Option<String>,
316 #[serde(rename = "apiKey")]
318 api_key: Option<String>,
319 #[serde(default)]
321 refresh: Option<bool>,
322 via: Option<String>,
324}
325
326pub async fn get_access_token(
327 State(app): State<App>,
328 tn_id: TnId,
329 id_tag: IdTag,
330 ConnectInfo(addr): ConnectInfo<SocketAddr>,
331 OptionalAuth(maybe_auth): OptionalAuth,
332 Query(query): Query<GetAccessTokenQuery>,
333 OptionalRequestId(req_id): OptionalRequestId,
334) -> ClResult<(StatusCode, Json<ApiResponse<serde_json::Value>>)> {
335 use tracing::warn;
336
337 info!("Got access token request for id_tag={} with scope={:?}", id_tag.0, query.scope);
338
339 if let Some(ref via_file_id) = query.via {
341 use cloudillo_types::types::{AccessLevel, TokenScope};
342
343 let scope_str = query
345 .scope
346 .as_deref()
347 .ok_or_else(|| Error::ValidationError("scope parameter required with via".into()))?;
348
349 let token_scope = TokenScope::parse(scope_str)
350 .ok_or_else(|| Error::ValidationError("Invalid scope format".into()))?;
351
352 let TokenScope::File { file_id: ref target_file_id, access: requested_access } =
353 token_scope
354 else {
355 return Err(Error::ValidationError("scope must be a file scope".into()));
356 };
357
358 info!(
359 "Via token request: via={}, target={}, access={:?}",
360 via_file_id, target_file_id, requested_access
361 );
362
363 let auth = maybe_auth.as_ref().ok_or(Error::Unauthorized)?;
365
366 let via_bare_file_id =
368 via_file_id.split_once(':').map_or(via_file_id.as_str(), |(_, fid)| fid);
369
370 let caller_has_via_access = if let Some(ref caller_scope) = auth.scope {
372 if let Some(TokenScope::File { file_id: ref scope_fid, access: scope_access }) =
374 TokenScope::parse(caller_scope)
375 {
376 scope_fid == via_bare_file_id && scope_access != AccessLevel::None
377 } else {
378 false
379 }
380 } else {
381 use cloudillo_core::file_access::{self, FileAccessCtx};
383 let ctx = FileAccessCtx {
384 user_id_tag: &auth.id_tag,
385 tenant_id_tag: &id_tag.0,
386 user_roles: &auth.roles,
387 };
388 file_access::check_file_access_with_scope(
389 &app,
390 tn_id,
391 via_bare_file_id,
392 &ctx,
393 None,
394 None,
395 )
396 .await
397 .is_ok()
398 };
399
400 if !caller_has_via_access {
401 warn!("Via token denied: caller has no access to source file {}", via_file_id);
402 return Err(Error::PermissionDenied);
403 }
404
405 let link_perm = app
407 .meta_adapter
408 .check_share_access(tn_id, 'F', target_file_id, 'F', via_bare_file_id)
409 .await?
410 .ok_or_else(|| {
411 warn!(
412 "Via token denied: no file link from {} to {}",
413 via_bare_file_id, target_file_id
414 );
415 Error::PermissionDenied
416 })?;
417
418 let effective_access = requested_access.min(AccessLevel::from_perm_char(link_perm));
420
421 let access_char = match effective_access {
423 AccessLevel::Write | AccessLevel::Admin => 'W',
424 AccessLevel::Comment => 'C',
425 _ => 'R',
426 };
427 let target_scope = format!("file:{}:{}", target_file_id, access_char);
428
429 let token_result = app
430 .auth_adapter
431 .create_access_token(
432 tn_id,
433 &auth_adapter::AccessToken {
434 iss: &id_tag.0,
435 sub: auth.scope.as_ref().map_or(Some(&*auth.id_tag), |_| None),
436 r: None,
437 scope: Some(&target_scope),
438 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
439 },
440 )
441 .await?;
442
443 info!("Created via token for {} with scope {}", target_file_id, target_scope);
444 let response = ApiResponse::new(json!({
445 "token": token_result,
446 "scope": target_scope,
447 "resourceId": target_file_id,
448 "accessLevel": effective_access.as_str(),
449 }))
450 .with_req_id(req_id.unwrap_or_default());
451 return Ok((StatusCode::OK, Json(response)));
452 }
453
454 if let Some(token_param) = query.token {
456 info!("Verifying action token from query parameter");
457 let verify_fn = app.ext::<ActionVerifyFn>()?;
458 let auth_action = verify_fn(&app, tn_id, &token_param, Some(&addr.ip())).await?;
459 if *auth_action.aud.as_ref().ok_or(Error::PermissionDenied)?.as_ref() != *id_tag.0 {
460 warn!("Auth action issuer {} doesn't match id_tag {}", auth_action.iss, id_tag.0);
461 return Err(Error::PermissionDenied);
462 }
463 info!("Got auth action: {:?}", &auth_action);
464
465 info!(
466 "Creating access token with t={}, u={}, scope={:?}",
467 id_tag.0,
468 auth_action.iss,
469 query.scope.as_deref()
470 );
471
472 let profile_roles = match app.meta_adapter.read_profile_roles(tn_id, &auth_action.iss).await
474 {
475 Ok(roles) => {
476 info!(
477 "Found profile roles for {} in tn_id {:?}: {:?}",
478 auth_action.iss, tn_id, roles
479 );
480 roles
481 }
482 Err(e) => {
483 warn!(
484 "Failed to read profile roles for {} in tn_id {:?}: {}",
485 auth_action.iss, tn_id, e
486 );
487 None
488 }
489 };
490
491 let expanded_roles = profile_roles
492 .as_ref()
493 .map(|roles| expand_roles(roles))
494 .filter(|s| !s.is_empty());
495
496 info!("Expanded roles for access token: {:?}", expanded_roles);
497
498 let token_result = app
499 .auth_adapter
500 .create_access_token(
501 tn_id,
502 &auth_adapter::AccessToken {
503 iss: &id_tag.0,
504 sub: Some(&auth_action.iss),
505 r: expanded_roles.as_deref(),
506 scope: query.scope.as_deref(),
507 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
508 },
509 )
510 .await?;
511 info!("Got access token: {}", &token_result);
512 let response = ApiResponse::new(json!({ "token": token_result }))
513 .with_req_id(req_id.unwrap_or_default());
514 Ok((StatusCode::OK, Json(response)))
515 } else if let Some(ref_id) = query.ref_id {
516 let is_refresh = query.refresh.unwrap_or(false);
518 info!("Exchanging ref_id {} for scoped access token (refresh={})", ref_id, is_refresh);
519
520 let (ref_tn_id, _ref_id_tag, ref_data) = if is_refresh {
523 app.meta_adapter.validate_ref(&ref_id, &["share.file"]).await
524 } else {
525 app.meta_adapter.use_ref(&ref_id, &["share.file"]).await
526 }
527 .map_err(|e| {
528 warn!(
529 "Failed to {} ref {}: {}",
530 if is_refresh { "validate" } else { "use" },
531 ref_id,
532 e
533 );
534 match e {
535 Error::NotFound => Error::ValidationError("Invalid or expired share link".into()),
536 Error::ValidationError(_) => e,
537 _ => Error::ValidationError("Invalid share link".into()),
538 }
539 })?;
540
541 if ref_tn_id != tn_id {
543 warn!(
544 "Ref tenant mismatch: ref belongs to {:?} but request is for {:?}",
545 ref_tn_id, tn_id
546 );
547 return Err(Error::PermissionDenied);
548 }
549
550 let file_id = ref_data
552 .resource_id
553 .ok_or_else(|| Error::ValidationError("Share link missing resource_id".into()))?;
554 let access_level = ref_data.access_level.unwrap_or('R');
555
556 let scope = format!("file:{}:{}", file_id, access_level);
559 info!("Creating scoped access token with scope={}", scope);
560
561 let token_result = app
562 .auth_adapter
563 .create_access_token(
564 tn_id,
565 &auth_adapter::AccessToken {
566 iss: &id_tag.0,
567 sub: None, r: None, scope: Some(&scope),
570 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
571 },
572 )
573 .await?;
574
575 info!("Got scoped access token for share link");
576 let mut result = json!({
577 "token": token_result,
578 "scope": scope,
579 "resourceId": file_id.to_string(),
580 "accessLevel": match access_level {
581 'W' | 'A' => "write",
582 'C' => "comment",
583 _ => "read",
584 },
585 });
586 if let Some(ref params) = ref_data.params {
587 result["params"] = json!(params);
588 }
589 let response = ApiResponse::new(result).with_req_id(req_id.unwrap_or_default());
590 Ok((StatusCode::OK, Json(response)))
591 } else if let Some(api_key) = query.api_key {
592 info!("Exchanging API key for access token");
594
595 let validation = app.auth_adapter.validate_api_key(&api_key).await.map_err(|e| {
597 warn!("API key validation failed: {:?}", e);
598 Error::PermissionDenied
599 })?;
600
601 if validation.tn_id != tn_id {
603 warn!(
604 "API key tenant mismatch: key belongs to {:?} but request is for {:?}",
605 validation.tn_id, tn_id
606 );
607 return Err(Error::PermissionDenied);
608 }
609
610 info!(
611 "Creating access token from API key for id_tag={}, scopes={:?}",
612 validation.id_tag, validation.scopes
613 );
614
615 let token_result = app
617 .auth_adapter
618 .create_access_token(
619 tn_id,
620 &auth_adapter::AccessToken {
621 iss: &id_tag.0,
622 sub: Some(&validation.id_tag),
623 r: validation.roles.as_deref(),
624 scope: validation.scopes.as_deref(),
625 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
626 },
627 )
628 .await?;
629
630 info!("Got access token from API key: {}", &token_result);
631
632 let auth_login = auth_adapter::AuthLogin {
634 tn_id,
635 id_tag: validation.id_tag,
636 roles: validation.roles.map(|r| r.split(',').map(Into::into).collect()),
637 token: token_result,
638 };
639 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
640 let response = ApiResponse::new(serde_json::to_value(login_data)?)
641 .with_req_id(req_id.unwrap_or_default());
642 Ok((StatusCode::OK, Json(response)))
643 } else {
644 let auth = maybe_auth.ok_or(Error::Unauthorized)?;
646
647 info!(
648 "Using authenticated session for id_tag={}, scope={:?}",
649 auth.id_tag,
650 query.scope.as_deref()
651 );
652
653 let profile_roles =
655 app.meta_adapter.read_profile_roles(tn_id, &auth.id_tag).await.ok().flatten();
656
657 let profile_roles: Option<Box<[Box<str>]>> = if auth.id_tag == id_tag.0 {
659 Some(vec!["leader".into()].into_boxed_slice())
660 } else {
661 profile_roles
662 };
663
664 let expanded_roles = profile_roles
665 .as_ref()
666 .map(|roles| expand_roles(roles))
667 .filter(|s: &String| !s.is_empty());
668
669 let token_result = app
670 .auth_adapter
671 .create_access_token(
672 tn_id,
673 &auth_adapter::AccessToken {
674 iss: &id_tag.0,
675 sub: Some(&auth.id_tag),
676 r: expanded_roles.as_deref(),
677 scope: query.scope.as_deref(),
678 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
679 },
680 )
681 .await?;
682 info!("Got access token from session: {}", &token_result);
683 let response = ApiResponse::new(json!({ "token": token_result }))
684 .with_req_id(req_id.unwrap_or_default());
685 Ok((StatusCode::OK, Json(response)))
686 }
687}
688
689#[skip_serializing_none]
694#[derive(Serialize)]
695pub struct ProxyTokenRes {
696 token: String,
697 roles: Option<Vec<String>>,
699}
700
701#[derive(Deserialize)]
702pub struct ProxyTokenQuery {
703 #[serde(rename = "idTag")]
704 id_tag: Option<String>,
705}
706
707pub async fn get_proxy_token(
708 State(app): State<App>,
709 IdTag(own_id_tag): IdTag,
710 Auth(auth): Auth,
711 Query(query): Query<ProxyTokenQuery>,
712 OptionalRequestId(req_id): OptionalRequestId,
713) -> ClResult<(StatusCode, Json<ApiResponse<ProxyTokenRes>>)> {
714 if let Some(ref target_id_tag) = query.id_tag {
716 if target_id_tag != own_id_tag.as_ref() {
717 #[derive(Deserialize)]
718 struct AccessTokenClaims {
719 r: Option<String>,
720 }
721
722 info!("Getting federated proxy token for {} -> {}", &auth.id_tag, target_id_tag);
723
724 let token = app.request.create_proxy_token(auth.tn_id, target_id_tag, None).await?;
726
727 let roles: Option<Vec<String>> = match decode_jwt_no_verify::<AccessTokenClaims>(&token)
728 {
729 Ok(claims) => {
730 info!("Decoded federated token, roles claim: {:?}", claims.r);
731 claims.r.map(|r| r.split(',').map(String::from).collect())
732 }
733 Err(e) => {
734 warn!("Failed to decode federated token for roles: {:?}", e);
735 None
736 }
737 };
738
739 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles })
740 .with_req_id(req_id.unwrap_or_default());
741 return Ok((StatusCode::OK, Json(response)));
742 }
743 }
744
745 info!("Generating local access token for {}", &auth.id_tag);
747 let roles_str: String = auth.roles.iter().map(AsRef::as_ref).collect::<Vec<&str>>().join(",");
748 let token = app
749 .auth_adapter
750 .create_access_token(
751 auth.tn_id,
752 &auth_adapter::AccessToken {
753 iss: &own_id_tag,
754 sub: Some(&auth.id_tag),
755 r: if roles_str.is_empty() { None } else { Some(&roles_str) },
756 scope: None,
757 exp: Timestamp::from_now(ACCESS_TOKEN_EXPIRY),
758 },
759 )
760 .await?;
761
762 let roles: Vec<String> = auth.roles.iter().map(ToString::to_string).collect();
764 let response = ApiResponse::new(ProxyTokenRes { token: token.to_string(), roles: Some(roles) })
765 .with_req_id(req_id.unwrap_or_default());
766
767 Ok((StatusCode::OK, Json(response)))
768}
769
770#[derive(Deserialize)]
774pub struct SetPasswordReq {
775 #[serde(rename = "refId")]
776 ref_id: String,
777 #[serde(rename = "newPassword")]
778 new_password: String,
779}
780
781pub async fn post_set_password(
782 State(app): State<App>,
783 OptionalRequestId(req_id): OptionalRequestId,
784 Json(req): Json<SetPasswordReq>,
785) -> ClResult<(StatusCode, Json<ApiResponse<Login>>)> {
786 if req.new_password.trim().is_empty() {
788 return Err(Error::ValidationError("Password cannot be empty or only whitespace".into()));
789 }
790
791 if req.new_password.len() < 8 {
792 return Err(Error::ValidationError("Password must be at least 8 characters".into()));
793 }
794
795 let (tn_id, id_tag, _ref_data) = app
798 .meta_adapter
799 .use_ref(&req.ref_id, &["welcome", "password"])
800 .await
801 .map_err(|e| {
802 warn!("Failed to use ref {}: {}", req.ref_id, e);
803 match e {
804 Error::NotFound => Error::ValidationError("Invalid or expired reference".into()),
805 Error::ValidationError(_) => e,
806 _ => Error::ValidationError("Invalid reference".into()),
807 }
808 })?;
809
810 info!(
811 tn_id = ?tn_id,
812 id_tag = %id_tag,
813 ref_id = %req.ref_id,
814 "Setting password via reference"
815 );
816
817 app.auth_adapter.update_tenant_password(&id_tag, &req.new_password).await?;
819
820 info!(
821 tn_id = ?tn_id,
822 id_tag = %id_tag,
823 "Password set successfully, generating login token"
824 );
825
826 let auth = app.auth_adapter.create_tenant_login(&id_tag).await?;
828
829 let (_status, Json(login_data)) = return_login(&app, auth).await?;
831 let response = ApiResponse::new(login_data).with_req_id(req_id.unwrap_or_default());
832
833 Ok((StatusCode::OK, Json(response)))
834}
835
836#[derive(Deserialize)]
840pub struct ForgotPasswordReq {
841 email: String,
842}
843
844#[derive(Serialize)]
845pub struct ForgotPasswordRes {
846 message: String,
847}
848
849pub async fn post_forgot_password(
850 State(app): State<App>,
851 ConnectInfo(addr): ConnectInfo<SocketAddr>,
852 OptionalRequestId(req_id): OptionalRequestId,
853 Json(req): Json<ForgotPasswordReq>,
854) -> ClResult<(StatusCode, Json<ApiResponse<ForgotPasswordRes>>)> {
855 let email = req.email.trim().to_lowercase();
856
857 info!(email = %email, ip = %addr.ip(), "Password reset requested");
858
859 let success_response = || {
861 ApiResponse::new(ForgotPasswordRes {
862 message: "If an account with this email exists, a password reset link has been sent."
863 .to_string(),
864 })
865 .with_req_id(req_id.clone().unwrap_or_default())
866 };
867
868 if !email.contains('@') || email.len() < 5 {
870 return Ok((StatusCode::OK, Json(success_response())));
871 }
872
873 let auth_opts =
875 ListTenantsOptions { status: None, q: Some(&email), limit: Some(10), offset: None };
876 let tenants = match app.auth_adapter.list_tenants(&auth_opts).await {
877 Ok(t) => t,
878 Err(e) => {
879 warn!(email = %email, error = ?e, "Failed to look up tenant by email");
880 return Ok((StatusCode::OK, Json(success_response())));
881 }
882 };
883
884 let tenant = tenants.into_iter().find(|t| t.email.as_deref() == Some(email.as_str()));
886
887 let Some(tenant) = tenant else {
888 info!(email = %email, "No tenant found for email (not revealing)");
889 return Ok((StatusCode::OK, Json(success_response())));
890 };
891
892 let tn_id = tenant.tn_id;
893 let id_tag = tenant.id_tag.to_string();
894
895 let opts = ListRefsOptions {
898 typ: Some("password".to_string()),
899 filter: Some("all".to_string()),
900 resource_id: None,
901 };
902 let recent_refs = app.meta_adapter.list_refs(tn_id, &opts).await.unwrap_or_default();
903
904 let now = Timestamp::now().0;
905 let one_hour_ago = now - 3600;
906 let one_day_ago = now - 86400;
907
908 let hourly_count = recent_refs.iter().filter(|r| r.created_at.0 > one_hour_ago).count();
909 let daily_count = recent_refs.iter().filter(|r| r.created_at.0 > one_day_ago).count();
910
911 if hourly_count >= 1 {
912 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (hourly)");
913 return Ok((StatusCode::OK, Json(success_response())));
914 }
915
916 if daily_count >= 3 {
917 info!(tn_id = ?tn_id, id_tag = %id_tag, "Password reset rate limited (daily)");
918 return Ok((StatusCode::OK, Json(success_response())));
919 }
920
921 let user_name = app
923 .meta_adapter
924 .read_tenant(tn_id)
925 .await
926 .map_or_else(|_| id_tag.clone(), |t| t.name.to_string());
927
928 let expires_at = Some(Timestamp(now + 86400)); let (ref_id, reset_url) = match create_ref_internal(
931 &app,
932 tn_id,
933 CreateRefInternalParams {
934 id_tag: &id_tag,
935 typ: "password",
936 description: Some("User-initiated password reset"),
937 expires_at,
938 path_prefix: "/reset-password",
939 resource_id: None,
940 count: None,
941 params: None,
942 },
943 )
944 .await
945 {
946 Ok(result) => result,
947 Err(e) => {
948 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to create password reset ref");
949 return Ok((StatusCode::OK, Json(success_response())));
950 }
951 };
952
953 let lang = get_tenant_lang(&app.settings, tn_id).await;
955
956 let base_id_tag = app.opts.base_id_tag.as_ref().map_or("cloudillo", AsRef::as_ref);
958
959 let email_params = EmailTaskParams {
961 to: email.clone(),
962 subject: None,
963 template_name: "password_reset".to_string(),
964 template_vars: serde_json::json!({
965 "identity_tag": user_name,
966 "base_id_tag": base_id_tag,
967 "instance_name": "Cloudillo",
968 "reset_link": reset_url,
969 "expire_hours": 24,
970 }),
971 lang,
972 custom_key: Some(format!("pw-reset:{}:{}", tn_id.0, now)),
973 from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
974 };
975
976 if let Err(e) =
977 EmailModule::schedule_email_task(&app.scheduler, &app.settings, tn_id, email_params).await
978 {
979 warn!(tn_id = ?tn_id, id_tag = %id_tag, error = ?e, "Failed to schedule password reset email");
980 } else {
982 info!(
983 tn_id = ?tn_id,
984 id_tag = %id_tag,
985 ref_id = %ref_id,
986 "Password reset email scheduled"
987 );
988 }
989
990 Ok((StatusCode::OK, Json(success_response())))
991}
992
993#[derive(Serialize)]
998#[serde(tag = "status")]
999pub enum LoginInitResponse {
1000 #[serde(rename = "authenticated")]
1001 Authenticated { login: Login },
1002 #[serde(rename = "unauthenticated")]
1003 Unauthenticated {
1004 #[serde(rename = "qrLogin")]
1005 qr_login: crate::qr_login::InitResponse,
1006 #[serde(rename = "webAuthn")]
1007 web_authn: Option<crate::webauthn::LoginChallengeRes>,
1008 },
1009}
1010
1011pub async fn post_login_init(
1012 State(app): State<App>,
1013 OptionalAuth(auth): OptionalAuth,
1014 tn_id: TnId,
1015 id_tag: IdTag,
1016 ConnectInfo(addr): ConnectInfo<SocketAddr>,
1017 OptionalRequestId(req_id): OptionalRequestId,
1018 headers: HeaderMap,
1019) -> ClResult<(StatusCode, Json<ApiResponse<LoginInitResponse>>)> {
1020 if let Some(auth) = auth {
1021 info!("login-init for authenticated user {}", &auth.id_tag);
1023 let auth_login = app.auth_adapter.create_tenant_login(&auth.id_tag).await?;
1024 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
1025 let response = ApiResponse::new(LoginInitResponse::Authenticated { login: login_data })
1026 .with_req_id(req_id.unwrap_or_default());
1027 Ok((StatusCode::OK, Json(response)))
1028 } else {
1029 debug!("login-init for unauthenticated user");
1031 let qr_result = crate::qr_login::create_session(&app, tn_id, &addr, &headers)?;
1032 let wa_result = crate::webauthn::try_login_challenge(&app, &id_tag, tn_id).await;
1033 let response = ApiResponse::new(LoginInitResponse::Unauthenticated {
1034 qr_login: qr_result,
1035 web_authn: wa_result,
1036 })
1037 .with_req_id(req_id.unwrap_or_default());
1038 Ok((StatusCode::OK, Json(response)))
1039 }
1040}
1041
1042