Skip to main content

cloudillo_auth/
handler.rs

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
31/// Service worker encryption key variable name
32const SW_ENCRYPTION_KEY_VAR: &str = "sw_encryption_key";
33
34/// Generate a new 256-bit encryption key for SW token protection
35/// Uses URL-safe base64 encoding (no padding) for safe inclusion in URLs
36fn generate_sw_encryption_key() -> String {
37	let key: [u8; 32] = rand::rng().random();
38	BASE64_URL.encode(key)
39}
40
41/// # Login
42#[skip_serializing_none]
43#[derive(Serialize)]
44pub struct Login {
45	// auth data
46	#[serde(rename = "tnId")]
47	tn_id: TnId,
48	#[serde(rename = "idTag")]
49	id_tag: String,
50	roles: Option<Vec<String>>,
51	token: String,
52	// profile data
53	name: String,
54	#[serde(rename = "profilePic")]
55	profile_pic: String,
56	settings: Vec<(String, String)>,
57	// SW encryption key for secure token storage
58	#[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	// Fetch tenant data for name and profile_pic
88	// Use read_tenant since the user is logging into their own tenant
89	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	// Get or create SW encryption key for this tenant
97	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			// Generate new key
102			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/// # POST /api/auth/login
134#[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		// Penalize rate limit for failed login attempt
155		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
163/// # GET /api/auth/login-token
164pub 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		// No authentication - return empty result
184		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/// Request body for logout endpoint
191#[derive(Deserialize, Default)]
192pub struct LogoutReq {
193	/// Optional API key to delete on logout (for "stay logged in" cleanup)
194	#[serde(rename = "apiKey")]
195	api_key: Option<String>,
196}
197
198/// POST /auth/logout - Invalidate current access token
199pub 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	// Note: Token invalidation could be implemented with a token blacklist table
206	// For now, tokens remain valid until expiration (short-lived access tokens)
207
208	// If API key provided, validate it belongs to this user and delete it
209	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				// Invalid/expired key, ignore silently (might already be deleted)
227				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/// # POST /api/auth/password
240#[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	// Validate new password strength
256	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	// Verify current password using authenticated user's id_tag
271	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		// Penalize rate limit for failed password verification
278		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		// Delay to prevent timing attacks
282		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	// Update to new password
288	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/// # GET /api/auth/access-token
298/// Gets an access token for a subject.
299/// Can be called with either:
300/// 1. A token query parameter (action token to exchange)
301/// 2. A refId query parameter (share link to exchange for scoped token)
302/// 3. An apiKey query parameter (API key to exchange for access token)
303/// 4. Just subject parameter (uses authenticated session)
304#[derive(Deserialize)]
305pub struct GetAccessTokenQuery {
306	#[serde(default)]
307	token: Option<String>,
308	scope: Option<String>,
309	/// Share link ref_id to exchange for a scoped access token
310	#[serde(rename = "refId")]
311	ref_id: Option<String>,
312	/// API key to exchange for an access token
313	#[serde(rename = "apiKey")]
314	api_key: Option<String>,
315	/// If true with refId, use validate_ref instead of use_ref (for token refresh)
316	#[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 token is provided in query, verify it; otherwise use authenticated session
334	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		// Fetch profile roles from meta adapter and expand them
352		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		// Exchange share link ref for scoped access token (no auth required)
396		let is_refresh = query.refresh.unwrap_or(false);
397		info!("Exchanging ref_id {} for scoped access token (refresh={})", ref_id, is_refresh);
398
399		// For refresh: validate without decrementing counter
400		// For initial access: validate and decrement counter
401		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		// Validate ref belongs to this tenant
421		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		// Extract resource_id (file_id) and access_level
430		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		// Create scoped access token
436		// scope format: "file:{file_id}:{R|W}"
437		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, // Anonymous/guest access
447					r: None,   // No roles for share link access
448					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		// Exchange API key for access token (no auth required)
465		info!("Exchanging API key for access token");
466
467		// Validate the API key
468		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		// Verify API key belongs to this tenant
474		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		// Create access token with API key's scopes
488		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		// Create AuthLogin and use return_login for consistent response
505		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		// Use authenticated session token - requires auth
517		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		// Fetch profile roles from meta adapter and expand them
526		let profile_roles =
527			app.meta_adapter.read_profile_roles(tn_id, &auth.id_tag).await.ok().flatten();
528
529		// If user is the tenant owner, they implicitly have leader role
530		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/// # GET /api/auth/proxy-token
562/// Generate a proxy token for federation (allows this user to authenticate on behalf of the server)
563/// If `idTag` query parameter is provided and different from the current server, this will
564/// perform a federated token exchange with the target server.
565#[skip_serializing_none]
566#[derive(Serialize)]
567pub struct ProxyTokenRes {
568	token: String,
569	/// User's roles in this context (extracted from JWT for federated tokens)
570	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 target idTag is specified and different from own server, use federation
587	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			// Use federation flow: create action token and exchange at target
592			let token = app.request.create_proxy_token(auth.tn_id, target_id_tag, None).await?;
593
594			// Decode the JWT to extract the roles (r claim) for the frontend
595			#[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	// Default: create local access token (valid on own server)
619	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	// Return roles alongside token for local context
636	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/// # POST /auth/set-password
644/// Set password using a reference (welcome or password reset)
645/// This endpoint is used during registration (welcome ref) and password reset flows
646#[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	// Validate new password strength
660	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	// Use the ref - this validates type, expiration, counter, and decrements it
669	// Returns the tenant ID, id_tag, and ref data that owns this ref
670	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	// Update the password
691	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	// Create a login token for the user
700	let auth = app.auth_adapter.create_tenant_login(&id_tag).await?;
701
702	// Return login info using the existing return_login helper
703	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/// # POST /api/auth/forgot-password
710/// Request a password reset email (user-initiated)
711/// Always returns success to prevent email enumeration
712#[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	// Success response (always returned for security)
733	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	// Basic email validation
742	if !email.contains('@') || email.len() < 5 {
743		return Ok((StatusCode::OK, Json(success_response())));
744	}
745
746	// Look up tenant by email
747	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	// Find exact email match
758	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	// Rate limiting: check recent password reset refs for this tenant
769	// Allow max 1 per hour, 3 per day
770	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	// Get tenant meta data for the name
795	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	// Create password reset ref
803	let expires_at = Some(Timestamp(now + 86400)); // 24 hours
804	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	// Get tenant's preferred language
827	let lang = get_tenant_lang(&app.settings, tn_id).await;
828
829	// Get base_id_tag for sender name
830	let base_id_tag = app.opts.base_id_tag.as_ref().map(|s| s.as_ref()).unwrap_or("cloudillo");
831
832	// Schedule email
833	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		// Still return success to not reveal anything
854	} 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// vim: ts=4