Skip to main content

cloudillo_profile/
register.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Registration and email verification handlers
5
6use axum::{
7	extract::{Json, State},
8	http::StatusCode,
9};
10use regex::Regex;
11use serde_json::json;
12use serde_with::skip_serializing_none;
13
14use crate::prelude::*;
15use cloudillo_core::settings::SettingValue;
16use cloudillo_core::{
17	bootstrap_types::CreateCompleteTenantOptions,
18	dns::{create_recursive_resolver, resolve_domain_addresses, validate_domain_address},
19	extract::OptionalAuth,
20	CreateCompleteTenantFn,
21};
22use cloudillo_idp::registration::{IdpRegContent, IdpRegResponse};
23use cloudillo_types::action_types::CreateAction;
24use cloudillo_types::address::parse_address_type;
25use cloudillo_types::types::{ApiResponse, RegisterRequest, RegisterVerifyCheckRequest};
26
27/// Domain validation response (public for reuse in community profile creation)
28#[skip_serializing_none]
29#[derive(Debug, serde::Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct DomainValidationResponse {
32	pub address: Vec<String>,
33	#[serde(skip_serializing_if = "Option::is_none")]
34	pub address_type: Option<String>,
35	pub id_tag_error: String, // '' if no error, else 'invalid', 'used', 'nodns', 'address'
36	pub app_domain_error: String,
37	#[serde(skip_serializing_if = "Option::is_none")]
38	pub api_address: Option<String>,
39	#[serde(skip_serializing_if = "Option::is_none")]
40	pub app_address: Option<String>,
41	pub identity_providers: Vec<String>,
42}
43
44/// IDP availability check response
45#[derive(Debug, Clone, serde::Deserialize)]
46pub struct IdpAvailabilityResponse {
47	pub available: bool,
48	pub id_tag: String,
49}
50
51/// Get list of trusted identity providers from settings
52pub async fn get_identity_providers(app: &cloudillo_core::app::App, tn_id: TnId) -> Vec<String> {
53	match app.settings.get(tn_id, "idp.list").await {
54		Ok(SettingValue::String(list)) => {
55			// Parse comma-separated list and filter out empty strings
56			list.split(',')
57				.map(|s| s.trim().to_string())
58				.filter(|s| !s.is_empty())
59				.collect::<Vec<String>>()
60		}
61		Ok(_) => {
62			warn!("Invalid idp.list setting value (expected string)");
63			Vec::new()
64		}
65		Err(_) => {
66			// Setting not found or error, return empty list
67			Vec::new()
68		}
69	}
70}
71
72/// Verify domain and id_tag before registration
73pub async fn verify_register_data(
74	app: &cloudillo_core::app::App,
75	typ: &str,
76	id_tag: &str,
77	app_domain: Option<&str>,
78	identity_providers: Vec<String>,
79) -> ClResult<DomainValidationResponse> {
80	// Determine address type from local addresses (all same type, guaranteed by startup validation)
81	let address_type = if app.opts.local_address.is_empty() {
82		None
83	} else {
84		match parse_address_type(app.opts.local_address[0].as_ref()) {
85			Ok(addr_type) => Some(addr_type.to_string()),
86			Err(_) => None, // Should not happen due to startup validation
87		}
88	};
89
90	let mut response = DomainValidationResponse {
91		address: app.opts.local_address.iter().map(ToString::to_string).collect(),
92		address_type,
93		id_tag_error: String::new(),
94		app_domain_error: String::new(),
95		api_address: None,
96		app_address: None,
97		identity_providers,
98	};
99
100	// Validate format
101	match typ {
102		"domain" => {
103			// Regex for domain: alphanumeric and hyphens, with at least one dot
104			let domain_regex = Regex::new(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$")
105				.map_err(|e| Error::Internal(format!("domain regex compilation failed: {}", e)))?;
106
107			if !domain_regex.is_match(id_tag) {
108				response.id_tag_error = "invalid".to_string();
109			}
110
111			if let Some(app_domain) = app_domain {
112				if app_domain.starts_with("cl-o.") || !domain_regex.is_match(app_domain) {
113					response.app_domain_error = "invalid".to_string();
114				}
115			}
116
117			if !response.id_tag_error.is_empty() || !response.app_domain_error.is_empty() {
118				return Ok(response);
119			}
120
121			// DNS validation - use recursive resolver from root nameservers
122			let Ok(resolver) = create_recursive_resolver() else {
123				// If we can't create resolver, return nodns error
124				response.id_tag_error = "nodns".to_string();
125				return Ok(response);
126			};
127
128			// Check if id_tag already registered
129			match app.auth_adapter.read_tn_id(id_tag).await {
130				Ok(_) => response.id_tag_error = "used".to_string(),
131				Err(Error::NotFound) => {}
132				Err(e) => return Err(e),
133			}
134
135			// Check if app_domain certificate already exists
136			if let Some(_app_domain) = app_domain {
137				// Note: This would need a method to check cert by domain in auth adapter
138				// For now, we'll skip this check
139			}
140
141			// DNS lookups for API domain (cl-o.<id_tag>)
142			let api_domain = format!("cl-o.{}", id_tag);
143			match validate_domain_address(&api_domain, &app.opts.local_address, &resolver).await {
144				Ok((address, _addr_type)) => {
145					response.api_address = Some(address);
146				}
147				Err(Error::ValidationError(err_code)) => {
148					response.id_tag_error = err_code;
149					// Still show what was resolved so user can debug
150					if let Ok(Some(address)) =
151						resolve_domain_addresses(&api_domain, &resolver).await
152					{
153						response.api_address = Some(address);
154					}
155				}
156				Err(e) => return Err(e),
157			}
158
159			// DNS lookups for app domain
160			// Use provided app_domain or default to id_tag if not provided
161			let app_domain_to_validate = app_domain.unwrap_or(id_tag);
162			match validate_domain_address(
163				app_domain_to_validate,
164				&app.opts.local_address,
165				&resolver,
166			)
167			.await
168			{
169				Ok((address, _addr_type)) => {
170					response.app_address = Some(address);
171				}
172				Err(Error::ValidationError(err_code)) => {
173					response.app_domain_error = err_code;
174					// Still show what was resolved so user can debug
175					if let Ok(Some(address)) =
176						resolve_domain_addresses(app_domain_to_validate, &resolver).await
177					{
178						response.app_address = Some(address);
179					}
180				}
181				Err(e) => return Err(e),
182			}
183		}
184		"idp" => {
185			// Regex for idp: alphanumeric, hyphens, and dots, but must end with .cloudillo.net or similar
186			let idp_regex = Regex::new(r"^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$")
187				.map_err(|e| Error::Internal(format!("idp regex compilation failed: {}", e)))?;
188
189			if !idp_regex.is_match(id_tag) {
190				response.id_tag_error = "invalid".to_string();
191				return Ok(response);
192			}
193
194			// Check if id_tag already registered locally
195			match app.auth_adapter.read_tn_id(id_tag).await {
196				Ok(_) => {
197					response.id_tag_error = "used".to_string();
198					return Ok(response);
199				}
200				Err(Error::NotFound) => {}
201				Err(e) => return Err(e),
202			}
203
204			// Extract the IDP domain from id_tag
205			// Format: "alice.cloudillo.net" -> domain is "cloudillo.net"
206			if let Some(first_dot_pos) = id_tag.find('.') {
207				let idp_domain = &id_tag[first_dot_pos + 1..];
208
209				if idp_domain.is_empty() {
210					response.id_tag_error = "invalid".to_string();
211					return Ok(response);
212				}
213
214				// Make network request to IDP server to check availability
215				let check_path = format!("/idp/check-availability?idTag={}", id_tag);
216
217				match app
218					.request
219					.get_public::<ApiResponse<IdpAvailabilityResponse>>(idp_domain, &check_path)
220					.await
221				{
222					Ok(idp_response) => {
223						if !idp_response.data.available {
224							response.id_tag_error = "used".to_string();
225						}
226					}
227					Err(e) => {
228						warn!("Failed to check IDP availability for {}: {}", id_tag, e);
229						response.id_tag_error = "nodns".to_string();
230					}
231				}
232			} else {
233				response.id_tag_error = "invalid".to_string();
234			}
235		}
236		_ => {
237			return Err(Error::ValidationError("invalid registration type".into()));
238		}
239	}
240
241	Ok(response)
242}
243
244/// POST /api/profiles/verify - Validate domain/id_tag before profile creation
245/// Requires either authentication OR a valid registration token
246pub async fn post_verify_profile(
247	State(app): State<cloudillo_core::app::App>,
248	OptionalAuth(auth): OptionalAuth,
249	Json(req): Json<RegisterVerifyCheckRequest>,
250) -> ClResult<(StatusCode, Json<DomainValidationResponse>)> {
251	// Require either authentication OR valid token
252	let is_authenticated = auth.is_some();
253
254	if !is_authenticated {
255		// Token required for unauthenticated requests
256		let token = req.token.as_ref().ok_or_else(|| {
257			Error::ValidationError("Token required for unauthenticated requests".into())
258		})?;
259		// Validate the ref without consuming it
260		app.meta_adapter.validate_ref(token, &["register"]).await?;
261	}
262
263	let id_tag_lower = req.id_tag.to_lowercase();
264
265	// Get identity providers list (use TnId(1) for base tenant settings)
266	let providers = get_identity_providers(&app, TnId(1)).await;
267
268	// For "ref" type, just return identity providers
269	if req.typ == "ref" {
270		// Determine address type from local addresses
271		let address_type = if app.opts.local_address.is_empty() {
272			None
273		} else {
274			match parse_address_type(app.opts.local_address[0].as_ref()) {
275				Ok(addr_type) => Some(addr_type.to_string()),
276				Err(_) => None,
277			}
278		};
279
280		return Ok((
281			StatusCode::OK,
282			Json(DomainValidationResponse {
283				address: app.opts.local_address.iter().map(ToString::to_string).collect(),
284				address_type,
285				id_tag_error: String::new(),
286				app_domain_error: String::new(),
287				api_address: None,
288				app_address: None,
289				identity_providers: providers,
290			}),
291		));
292	}
293
294	// Validate domain/local and get validation errors
295	let validation_result =
296		verify_register_data(&app, &req.typ, &id_tag_lower, req.app_domain.as_deref(), providers)
297			.await?;
298
299	Ok((StatusCode::OK, Json(validation_result)))
300}
301
302/// Handle IDP registration flow
303async fn handle_idp_registration(
304	app: &cloudillo_core::app::App,
305	id_tag_lower: String,
306	email: String,
307	lang: Option<String>,
308) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
309	#[derive(serde::Serialize)]
310	struct InboxRequest {
311		token: String,
312	}
313
314	// Extract the IDP domain from id_tag (e.g., "alice.cloudillo.net" -> "cloudillo.net")
315	let idp_domain = match id_tag_lower.find('.') {
316		Some(pos) => &id_tag_lower[pos + 1..],
317		None => {
318			return Err(Error::ValidationError("Invalid IDP id_tag format".to_string()));
319		}
320	};
321
322	// Get the BASE_ID_TAG (this host's identifier)
323	let base_id_tag = app
324		.opts
325		.base_id_tag
326		.as_ref()
327		.ok_or_else(|| Error::ConfigError("BASE_ID_TAG not configured".into()))?;
328
329	let expires_at = Timestamp::now().add_seconds(86400 * 30); // 30 days
330															// Include all local addresses from the app configuration (comma-separated)
331	let address = if app.opts.local_address.is_empty() {
332		None
333	} else {
334		Some(app.opts.local_address.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(","))
335	};
336	let reg_content = IdpRegContent {
337		id_tag: id_tag_lower.clone(),
338		email: Some(email.clone()),
339		owner_id_tag: None,
340		issuer: None, // Default to registrar
341		address,
342		lang: lang.clone(), // Pass language preference to IDP
343	};
344
345	// Create action to generate JWT token
346	let action = CreateAction {
347		typ: "IDP:REG".into(),
348		sub_typ: None,
349		parent_id: None,
350		audience_tag: Some(idp_domain.to_string().into()),
351		content: Some(serde_json::to_value(&reg_content)?),
352		attachments: None,
353		subject: None,
354		expires_at: Some(expires_at),
355		visibility: None,
356		flags: None,
357		x: None,
358		..Default::default()
359	};
360
361	// Generate action JWT token
362	let action_token = app.auth_adapter.create_action_token(TnId(1), action).await?;
363
364	let inbox_request = InboxRequest { token: action_token.to_string() };
365
366	// POST to IDP provider's /inbox/sync endpoint
367	info!(
368		id_tag = %id_tag_lower,
369		idp_domain = %idp_domain,
370		base_id_tag = %base_id_tag,
371		"Posting IDP:REG action token to identity provider"
372	);
373
374	let idp_response: cloudillo_types::types::ApiResponse<serde_json::Value> = app
375		.request
376		.post_public(idp_domain, "/inbox/sync", &inbox_request)
377		.await
378		.map_err(|e| {
379			warn!(
380				error = %e,
381				idp_domain = %idp_domain,
382				"Failed to register with identity provider"
383			);
384			Error::ValidationError(
385				"Identity provider registration failed - please try again later".to_string(),
386			)
387		})?;
388
389	// Parse the IDP response
390	let idp_reg_result: IdpRegResponse =
391		serde_json::from_value(idp_response.data).map_err(|e| {
392			warn!(
393				error = %e,
394				"Failed to parse IDP registration response"
395			);
396			Error::Internal(format!("IDP response parsing failed: {}", e))
397		})?;
398
399	// Check if registration was successful
400	if !idp_reg_result.success {
401		warn!(
402			id_tag = %id_tag_lower,
403			message = %idp_reg_result.message,
404			"IDP registration failed"
405		);
406		return Err(Error::ValidationError(idp_reg_result.message));
407	}
408
409	info!(
410		id_tag = %id_tag_lower,
411		activation_ref = ?idp_reg_result.activation_ref,
412		"IDP registration successful, creating local tenant"
413	);
414
415	// IMPORTANT: Create tenant first to get the tn_id, then create the welcome ref
416	// We need to do this in two steps because create_ref_internal needs the tn_id
417
418	// Derive display name from id_tag (capitalize first letter of prefix)
419	let display_name = if id_tag_lower.contains('.') {
420		let parts: Vec<&str> = id_tag_lower.split('.').collect();
421		if parts.is_empty() {
422			id_tag_lower.clone()
423		} else {
424			let name = parts[0];
425			format!("{}{}", name.chars().next().unwrap_or('U').to_uppercase(), &name[1..])
426		}
427	} else {
428		id_tag_lower.clone()
429	};
430
431	// Create tenant via extension function
432	let create_tenant = app.ext::<CreateCompleteTenantFn>()?;
433	let tn_id = create_tenant(
434		app,
435		CreateCompleteTenantOptions {
436			id_tag: &id_tag_lower,
437			email: Some(&email),
438			password: None,
439			roles: None,
440			display_name: Some(&display_name),
441			create_acme_cert: app.opts.acme_email.is_some(),
442			acme_email: app.opts.acme_email.as_deref(),
443			app_domain: None,
444		},
445	)
446	.await?;
447
448	info!(
449		id_tag = %id_tag_lower,
450		tn_id = ?tn_id,
451		"Tenant created successfully for IDP registration"
452	);
453
454	// Save language preference if provided
455	if let Some(ref lang_code) = lang {
456		// Use empty roles - PermissionLevel::User always allows any authenticated user
457		let empty_roles: &[&str] = &[];
458		if let Err(e) = app
459			.settings
460			.set(tn_id, "profile.lang", SettingValue::String(lang_code.clone()), empty_roles)
461			.await
462		{
463			warn!(
464				error = %e,
465				tn_id = ?tn_id,
466				lang = %lang_code,
467				"Failed to save language preference, continuing registration"
468			);
469		}
470	}
471
472	// Now create the welcome reference using the new create_ref_internal function
473	let (_ref_id, welcome_link) = cloudillo_ref::service::create_ref_internal(
474		app,
475		tn_id,
476		cloudillo_ref::service::CreateRefInternalParams {
477			id_tag: &id_tag_lower,
478			typ: "welcome",
479			description: Some("Welcome to Cloudillo"),
480			expires_at: Some(Timestamp::now().add_seconds(86400 * 30)), // 30 days
481			path_prefix: "/onboarding/welcome",
482			resource_id: None,
483			count: None,
484			params: None,
485		},
486	)
487	.await?;
488
489	// Profile is already created by create_tenant in meta adapter
490	// Send welcome email with the welcome link
491	let template_vars = serde_json::json!({
492		"identity_tag": id_tag_lower,
493		"base_id_tag": base_id_tag.as_ref(),
494		"instance_name": "Cloudillo",
495		"welcome_link": welcome_link,
496	});
497
498	match cloudillo_email::EmailModule::schedule_email_task(
499		&app.scheduler,
500		&app.settings,
501		tn_id,
502		cloudillo_email::EmailTaskParams {
503			to: email.clone(),
504			subject: None, // Subject is defined in the template frontmatter
505			template_name: "welcome".to_string(),
506			template_vars,
507			lang: lang.clone(),
508			custom_key: None,
509			from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
510		},
511	)
512	.await
513	{
514		Ok(()) => {
515			info!(
516				email = %email,
517				id_tag = %id_tag_lower,
518				lang = ?lang,
519				"Welcome email queued for IDP registration"
520			);
521		}
522		Err(e) => {
523			warn!(
524				error = %e,
525				email = %email,
526				id_tag = %id_tag_lower,
527				"Failed to queue welcome email, continuing registration"
528			);
529		}
530	}
531
532	// Store IDP API key if provided
533	if let Some(api_key) = &idp_reg_result.api_key {
534		info!(
535			id_tag = %id_tag_lower,
536			"Storing IDP API key for federated identity"
537		);
538		if let Err(e) = app.auth_adapter.update_idp_api_key(&id_tag_lower, api_key).await {
539			warn!(
540				error = %e,
541				id_tag = %id_tag_lower,
542				"Failed to store IDP API key - continuing anyway"
543			);
544			// Continue anyway - this is not critical for basic functionality
545		}
546	}
547
548	// Return empty response
549	let response = json!({});
550	Ok((StatusCode::CREATED, Json(response)))
551}
552
553/// Handle domain registration flow
554async fn handle_domain_registration(
555	app: &cloudillo_core::app::App,
556	id_tag_lower: String,
557	app_domain: Option<String>,
558	email: String,
559	providers: Vec<String>,
560	lang: Option<String>,
561) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
562	// Validate domain again before creating account
563	let validation_result =
564		verify_register_data(app, "domain", &id_tag_lower, app_domain.as_deref(), providers)
565			.await?;
566
567	// Check for validation errors
568	if !validation_result.id_tag_error.is_empty() || !validation_result.app_domain_error.is_empty()
569	{
570		return Err(Error::ValidationError("invalid id_tag or app_domain".into()));
571	}
572
573	// Derive display name from id_tag (capitalize first letter of prefix)
574	let display_name = if id_tag_lower.contains('.') {
575		let parts: Vec<&str> = id_tag_lower.split('.').collect();
576		if parts.is_empty() {
577			id_tag_lower.clone()
578		} else {
579			let name = parts[0];
580			format!("{}{}", name.chars().next().unwrap_or('U').to_uppercase(), &name[1..])
581		}
582	} else {
583		id_tag_lower.clone()
584	};
585
586	// Create tenant via extension function
587	let create_tenant = app.ext::<CreateCompleteTenantFn>()?;
588	let tn_id = create_tenant(
589		app,
590		CreateCompleteTenantOptions {
591			id_tag: &id_tag_lower,
592			email: Some(&email),
593			password: None,
594			roles: None,
595			display_name: Some(&display_name),
596			create_acme_cert: app.opts.acme_email.is_some(),
597			acme_email: app.opts.acme_email.as_deref(),
598			app_domain: app_domain.as_deref(),
599		},
600	)
601	.await?;
602
603	info!(
604		id_tag = %id_tag_lower,
605		tn_id = ?tn_id,
606		"Tenant created successfully for domain registration"
607	);
608
609	// Save language preference if provided
610	if let Some(ref lang_code) = lang {
611		// Use empty roles - PermissionLevel::User always allows any authenticated user
612		let empty_roles: &[&str] = &[];
613		if let Err(e) = app
614			.settings
615			.set(tn_id, "profile.lang", SettingValue::String(lang_code.clone()), empty_roles)
616			.await
617		{
618			warn!(
619				error = %e,
620				tn_id = ?tn_id,
621				lang = %lang_code,
622				"Failed to save language preference, continuing registration"
623			);
624		}
625	}
626
627	// Create welcome reference using the new create_ref_internal function
628	let (_ref_id, welcome_link) = cloudillo_ref::service::create_ref_internal(
629		app,
630		tn_id,
631		cloudillo_ref::service::CreateRefInternalParams {
632			id_tag: &id_tag_lower,
633			typ: "welcome",
634			description: Some("Welcome to Cloudillo"),
635			expires_at: Some(Timestamp::now().add_seconds(86400 * 30)), // 30 days
636			path_prefix: "/onboarding/welcome",
637			resource_id: None,
638			count: None,
639			params: None,
640		},
641	)
642	.await?;
643
644	// Profile is already created by create_tenant in meta adapter
645	// Send welcome email with the welcome link
646	let base_id_tag = app
647		.opts
648		.base_id_tag
649		.as_ref()
650		.ok_or_else(|| Error::ConfigError("BASE_ID_TAG not configured".into()))?;
651
652	let template_vars = serde_json::json!({
653		"identity_tag": id_tag_lower,
654		"base_id_tag": base_id_tag.as_ref(),
655		"instance_name": "Cloudillo",
656		"welcome_link": welcome_link,
657	});
658
659	match cloudillo_email::EmailModule::schedule_email_task(
660		&app.scheduler,
661		&app.settings,
662		tn_id,
663		cloudillo_email::EmailTaskParams {
664			to: email.clone(),
665			subject: None, // Subject is defined in the template frontmatter
666			template_name: "welcome".to_string(),
667			template_vars,
668			lang: lang.clone(),
669			custom_key: None,
670			from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
671		},
672	)
673	.await
674	{
675		Ok(()) => {
676			info!(
677				email = %email,
678				id_tag = %id_tag_lower,
679				lang = ?lang,
680				"Welcome email queued for domain registration"
681			);
682		}
683		Err(e) => {
684			warn!(
685				error = %e,
686				email = %email,
687				id_tag = %id_tag_lower,
688				"Failed to queue welcome email, continuing registration"
689			);
690		}
691	}
692
693	// Return empty response (user must login separately)
694	let response = json!({});
695	Ok((StatusCode::CREATED, Json(response)))
696}
697
698/// POST /api/profiles/register - Create profile after validation
699/// Requires a valid registration token (invitation ref)
700pub async fn post_register(
701	State(app): State<cloudillo_core::app::App>,
702	Json(req): Json<RegisterRequest>,
703) -> ClResult<(StatusCode, Json<serde_json::Value>)> {
704	// Validate request fields
705	if req.id_tag.is_empty() || req.token.is_empty() || req.email.is_empty() {
706		return Err(Error::ValidationError("id_tag, token, and email are required".into()));
707	}
708
709	// Validate the registration token (ref) before processing
710	app.meta_adapter.validate_ref(&req.token, &["register"]).await?;
711
712	let id_tag_lower = req.id_tag.to_lowercase();
713	let app_domain = req.app_domain.map(|d| d.to_lowercase());
714
715	// Get identity providers list (use TnId(1) as default for global settings)
716	let providers = get_identity_providers(&app, TnId(1)).await;
717
718	// Route to appropriate registration handler
719	let result = if req.typ == "idp" {
720		handle_idp_registration(&app, id_tag_lower, req.email, req.lang).await
721	} else {
722		handle_domain_registration(&app, id_tag_lower, app_domain, req.email, providers, req.lang)
723			.await
724	};
725
726	// If registration succeeded, consume the token
727	if result.is_ok() {
728		if let Err(e) = app.meta_adapter.use_ref(&req.token, &["register"]).await {
729			warn!(
730				error = %e,
731				"Failed to consume registration token after successful registration"
732			);
733			// Continue anyway - registration already succeeded
734		}
735	}
736
737	result
738}
739
740// vim: ts=4