From fd1fcffef7e5d745fa4084f2b3ac225735c40a0b Mon Sep 17 00:00:00 2001 From: Autli Date: Tue, 27 Oct 2026 14:56:12 +0500 Subject: [PATCH] feat: verify actual email address with DNS lookup and 6-digit verification code in 0.0.9 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/auth.rs | 291 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 25 +++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 461 +++++++++++++++++++++++++------------- 7 files changed, 620 insertions(+), 165 deletions(-) diff --git a/package.json b/package.json index 7b18741..13fdec6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "has", "private": true, - "version": "0.0.8", + "version": "0.0.9", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f1dbeeb..140bd01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "has" -version = "0.0.8" +version = "0.0.9" dependencies = [ "base64 0.22.1", "bcrypt", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2fc3e1a..ceadbae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "has" -version = "0.0.8" +version = "0.0.9" description = "has" authors = ["autli"] edition = "2024" diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 152c39d..2640ee9 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -527,6 +527,91 @@ pub async fn authenticate_user(email: &str, password: &str) -> AuthResponse { } } +pub fn validate_email_syntax(email: &str) -> Result<(&str, &str), String> { + let clean = email.trim(); + if clean.is_empty() { + return Err("Адрес электронной почты не может быть пустым".to_string()); + } + if clean.len() > 254 { + return Err("Слишком длинный адрес электронной почты".to_string()); + } + let parts: Vec<&str> = clean.split('@').collect(); + if parts.len() != 2 { + return Err("Некорректный формат email: адрес должен содержать ровно один символ '@'".to_string()); + } + let local = parts[0]; + let domain = parts[1]; + + if local.is_empty() || local.len() > 64 { + return Err("Некорректная первая часть email адреса".to_string()); + } + if domain.is_empty() || domain.len() > 253 { + return Err("Некорректный домен email адреса".to_string()); + } + + if local.starts_with('.') || local.ends_with('.') || local.contains("..") { + return Err("Email не должен начинаться/заканчиваться точкой или содержать две точки подряд".to_string()); + } + if !local.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '%') { + return Err("Email содержит недопустимые символы в имени пользователя".to_string()); + } + + if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") || !domain.contains('.') { + return Err("Некорректный домен почты (пример: name@mail.ru)".to_string()); + } + + let domain_parts: Vec<&str> = domain.split('.').collect(); + let tld = match domain_parts.last() { + Some(t) => *t, + None => return Err("Отсутствует доменная зона почты".to_string()), + }; + if tld.len() < 2 || !tld.chars().all(|c| c.is_ascii_alphabetic()) { + return Err("Некорректная доменная зона почты (например: .ru, .com)".to_string()); + } + + for part in &domain_parts { + if part.is_empty() || part.starts_with('-') || part.ends_with('-') { + return Err("Некорректное имя почтового сервера".to_string()); + } + if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err("Домен почты содержит недопустимые символы".to_string()); + } + } + + const DISPOSABLE_DOMAINS: &[&str] = &[ + "tempmail.com", "10minutemail.com", "guerrillamail.com", "mailinator.com", + "trashmail.com", "yopmail.com", "temp-mail.org", "dispostable.com", + "getnada.com", "throwawaymail.com", "fakeinbox.com", "sharklasers.com", + "guerrillamailblock.com", "grr.la", "mohmal.com", "dropmail.me", + "tempail.com", "burnermail.io", "crazymailing.com", "generator.email" + ]; + let domain_lower = domain.to_lowercase(); + if DISPOSABLE_DOMAINS.iter().any(|&d| domain_lower == d || domain_lower.ends_with(&format!(".{}", d))) { + return Err("Временные и одноразовые почтовые ящики не поддерживаются. Пожалуйста, используйте надежный адрес.".to_string()); + } + + Ok((local, domain)) +} + +pub async fn verify_domain_exists(domain: &str) -> Result<(), String> { + let domain = domain.trim(); + let resolves = match tokio::net::lookup_host(format!("{}:25", domain)).await { + Ok(mut addrs) => addrs.next().is_some(), + Err(_) => match tokio::net::lookup_host(format!("{}:443", domain)).await { + Ok(mut addrs) => addrs.next().is_some(), + Err(_) => match tokio::net::lookup_host(format!("{}:80", domain)).await { + Ok(mut addrs) => addrs.next().is_some(), + Err(_) => false, + }, + }, + }; + + if !resolves { + return Err(format!("Почтовый домен '{}' не найден в сети Интернет. Проверьте правильность адреса.", domain)); + } + Ok(()) +} + pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse { let clean_email = email.trim(); let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty()); @@ -538,6 +623,25 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>) }; } + let (_, domain) = match validate_email_syntax(clean_email) { + Ok(d) => d, + Err(err) => { + return AuthResponse { + success: false, + message: err, + user: None, + }; + } + }; + + if let Err(err) = verify_domain_exists(domain).await { + return AuthResponse { + success: false, + message: err, + user: None, + }; + } + let config = get_db_config(); let client = match connect_to_db(&config).await { Ok(c) => c, @@ -976,10 +1080,197 @@ pub async fn reset_password_with_code(email: &str, code: &str, new_password: &st } } +#[derive(Debug, Clone)] +struct RegistrationEntry { + code: String, + expires_at: std::time::Instant, +} + +static REGISTRATION_CODES: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +fn get_registration_codes() -> &'static std::sync::Mutex> { + REGISTRATION_CODES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +pub async fn request_registration_code(email: &str, _username: Option<&str>) -> ResetRequestResponse { + let clean_email = email.trim(); + let (_, domain) = match validate_email_syntax(clean_email) { + Ok(res) => res, + Err(e) => { + return ResetRequestResponse { + success: false, + message: e, + dev_code: None, + }; + } + }; + + if let Err(e) = verify_domain_exists(domain).await { + return ResetRequestResponse { + success: false, + message: e, + dev_code: None, + }; + } + + let config = get_db_config(); + let client = match connect_to_db(&config).await { + Ok(c) => c, + Err(_) => { + return ResetRequestResponse { + success: false, + message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(), + dev_code: None, + }; + } + }; + + let check_query = format!( + "SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1", + config.users_table, config.email_column + ); + + match client.query_opt(&check_query, &[&clean_email]).await { + Ok(Some(_)) => ResetRequestResponse { + success: false, + message: "Пользователь с таким адресом электронной почты уже зарегистрирован".to_string(), + dev_code: None, + }, + Ok(None) => { + let code = generate_reset_code(); + let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(900); // 15 mins + + { + let mut map = get_registration_codes().lock().unwrap(); + map.insert(clean_email.to_lowercase(), RegistrationEntry { + code: code.clone(), + expires_at, + }); + } + + let smtp_host = extract_clean_env("SMTP_HOST", "smtp.mail.ru"); + let smtp_port = extract_clean_env("SMTP_PORT", "465").parse::().unwrap_or(465); + let smtp_user = extract_clean_env("SMTP_USER", "scorporation@internet.ru"); + let smtp_pass = extract_clean_env("SMTP_PASSWORD", "87AhqOfdlC5LPb5eIX3N"); + let smtp_from = extract_clean_env("SMTP_FROM", &smtp_user); + + let dev_code; + if !smtp_host.is_empty() { + let subject = "Home Autli Server: Код подтверждения регистрации"; + let body = format!( + "Здравствуйте!\n\nВаш проверочный код для подтверждения почты и завершения регистрации в Home Autli Server: {}\n\nКод действителен в течение 15 минут.\nЕсли вы не регистрировались в Home Autli Server, просто проигнорируйте это письмо.", + code + ); + match send_smtp_email(&smtp_host, smtp_port, &smtp_user, &smtp_pass, &smtp_from, clean_email, subject, &body).await { + Ok(_) => { + return ResetRequestResponse { + success: true, + message: format!("Код подтверждения успешно отправлен на почту {}", clean_email), + dev_code: None, + }; + } + Err(e) => { + eprintln!("Не удалось отправить email через SMTP: {}. Тестовый код: {}", e, code); + dev_code = Some(code.clone()); + } + } + } else { + dev_code = Some(code.clone()); + } + + ResetRequestResponse { + success: true, + message: if dev_code.is_some() { + format!("Проверочный код сгенерирован (тестовый режим: {})", code) + } else { + format!("Код подтверждения отправлен на {}", clean_email) + }, + dev_code, + } + } + Err(e) => ResetRequestResponse { + success: false, + message: format!("Ошибка проверки почты: {}", e), + dev_code: None, + }, + } +} + +pub async fn register_with_code( + email: &str, + code: &str, + password: &str, + username: Option<&str>, +) -> AuthResponse { + let clean_email = email.trim(); + let clean_code = code.trim(); + + if clean_code.is_empty() { + return AuthResponse { + success: false, + message: "Пожалуйста, введите проверочный код из письма".to_string(), + user: None, + }; + } + + { + let mut map = get_registration_codes().lock().unwrap(); + let key = clean_email.to_lowercase(); + if let Some(entry) = map.get(&key) { + if std::time::Instant::now() > entry.expires_at { + return AuthResponse { + success: false, + message: "Срок действия кода подтверждения истек. Запросите код заново.".to_string(), + user: None, + }; + } + if entry.code != clean_code { + return AuthResponse { + success: false, + message: "Неверный проверочный код. Проверьте введенные цифры.".to_string(), + user: None, + }; + } + map.remove(&key); + } else { + return AuthResponse { + success: false, + message: "Код подтверждения для данного адреса не найден или устарел. Запросите код снова.".to_string(), + user: None, + }; + } + } + + register_user(clean_email, password, username).await +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_validate_email_syntax() { + assert!(validate_email_syntax("user@gmail.com").is_ok()); + assert!(validate_email_syntax("test.user+tag@mail.ru").is_ok()); + assert!(validate_email_syntax("alex_99@yandex.ru").is_ok()); + + assert!(validate_email_syntax("").is_err()); + assert!(validate_email_syntax("invalid").is_err()); + assert!(validate_email_syntax("invalid@").is_err()); + assert!(validate_email_syntax("@domain.com").is_err()); + assert!(validate_email_syntax("user@.com").is_err()); + assert!(validate_email_syntax("user@domain..com").is_err()); + assert!(validate_email_syntax("user@tempmail.com").is_err()); + assert!(validate_email_syntax("user@mailinator.com").is_err()); + } + + #[tokio::test] + async fn test_verify_domain_exists() { + assert!(verify_domain_exists("gmail.com").await.is_ok()); + assert!(verify_domain_exists("mail.ru").await.is_ok()); + assert!(verify_domain_exists("this-domain-surely-does-not-exist-123456789.ru").await.is_err()); + } + #[tokio::test] async fn test_database_connection() { let config = get_db_config(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9baa8bd..0e3781b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,28 @@ async fn register(email: String, password: String, username: Option) -> Ok(auth::register_user(&email, &password, username.as_deref()).await) } +#[tauri::command] +async fn request_registration_code(email: String, username: Option) -> Result { + Ok(auth::request_registration_code(&email, username.as_deref()).await) +} + +#[tauri::command] +async fn register_with_code( + email: String, + code: String, + password: String, + username: Option, +) -> Result { + Ok(auth::register_with_code(&email, &code, &password, username.as_deref()).await) +} + +#[tauri::command] +async fn validate_email(email: String) -> Result { + let (_, domain) = auth::validate_email_syntax(&email)?; + auth::verify_domain_exists(domain).await?; + Ok(true) +} + #[tauri::command] async fn request_reset_code(email: String) -> Result { Ok(auth::request_reset_code(&email).await) @@ -194,6 +216,9 @@ pub fn run() { check_db, login, register, + request_registration_code, + register_with_code, + validate_email, request_reset_code, verify_reset_code, reset_password, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5074f2a..4539933 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "has", - "version": "0.0.8", + "version": "0.0.9", "identifier": "com.has.tauri-app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index ce119f7..81a70c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,11 @@ export default function App() { const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(true); + // Registration verification code state + const [regStage, setRegStage] = useState<1 | 2>(1); + const [regCode, setRegCode] = useState(""); + const [regCooldown, setRegCooldown] = useState(0); + // Password reset state const [resetStage, setResetStage] = useState<1 | 2 | 3>(1); const [resetEmail, setResetEmail] = useState(""); @@ -51,6 +56,13 @@ export default function App() { const [showForgotModal, setShowForgotModal] = useState(false); + useEffect(() => { + if (regCooldown > 0) { + const timer = setTimeout(() => setRegCooldown(regCooldown - 1), 1000); + return () => clearTimeout(timer); + } + }, [regCooldown]); + // Check saved session on mount useEffect(() => { const savedUser = localStorage.getItem("has_remembered_user"); @@ -160,16 +172,26 @@ export default function App() { } }; - const handleRegister = async (e: React.FormEvent) => { - e.preventDefault(); + const isValidEmailFormat = (val: string) => { + return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(val.trim()); + }; + + const handleRequestRegCode = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); setError(null); setSuccess(null); - if (!email.trim() || !password) { + const cleanEmail = email.trim(); + if (!cleanEmail || !password) { setError("Пожалуйста, укажите email и пароль"); return; } + if (!isValidEmailFormat(cleanEmail)) { + setError("Пожалуйста, введите корректный адрес электронной почты (например, name@mail.ru)"); + return; + } + if (!username.trim()) { setError("Пожалуйста, укажите имя пользователя"); return; @@ -180,24 +202,71 @@ export default function App() { return; } + if (password.length < 6) { + setError("Пароль должен содержать минимум 6 символов"); + return; + } + if (!isTauri()) { - setError("Для регистрации пользователя в PostgreSQL запустите через Tauri ('npm run tauri dev')"); + setError("Для регистрации пользователя запустите приложение в десктопном режиме"); return; } setLoading(true); try { - const res = await invoke("register", { + const res = await invoke("request_registration_code", { + email: cleanEmail, + username: username.trim(), + }); + + if (res.success) { + setRegStage(2); + setRegCooldown(60); + setSuccess(res.message); + if (res.dev_code) { + setRegCode(res.dev_code); + } + } else { + setError(res.message || "Ошибка отправки кода подтверждения на указанную почту."); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleCompleteRegistration = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!regCode.trim()) { + setError("Пожалуйста, введите проверочный код из письма"); + return; + } + + if (!isTauri()) { + setError("Для регистрации пользователя запустите десктопное приложение"); + return; + } + + setLoading(true); + try { + const res = await invoke("register_with_code", { email: email.trim(), + code: regCode.trim(), password, username: username.trim(), }); if (res.success) { - setSuccess("Аккаунт успешно создан! Теперь вы можете войти."); + setSuccess("Аккаунт успешно создан и подтвержден! Теперь вы можете войти."); + setRegStage(1); + setRegCode(""); setMode("login"); } else { - setError(res.message || "Ошибка при создании аккаунта."); + setError(res.message || "Ошибка подтверждения кода или создания аккаунта."); } } catch (err: any) { setError(`Ошибка: ${err?.message || err}`); @@ -631,194 +700,264 @@ export default function App() { )} {/* Form */} -
- {/* Username (Register mode only) */} - {mode === "register" && ( + {mode === "register" && regStage === 2 ? ( + +
+
+ На почту {email.trim()} отправлен 6-значный проверочный код. + Введите его для подтверждения адреса: +
+
+ + setRegCode(code)} + disabled={loading} + /> + + + +
+ + + +
+ + ) : ( +
+ {/* Username (Register mode only) */} + {mode === "register" && ( +
+ +
+
+ +
+ setUsername(e.target.value)} + required + autoComplete="name" + /> +
+
+ )} + + {/* Email Address */}
-
- )} - {/* Email Address */} -
- -
-
- -
- setEmail(e.target.value)} - required - autoComplete="email" - /> -
-
- - {/* Password */} -
- -
-
- -
- setPassword(e.target.value)} - required - autoComplete={mode === "login" ? "current-password" : "new-password"} - /> - -
-
- - {/* Confirm Password (Register mode) */} - {mode === "register" && ( + {/* Password */}
-
- )} - {/* Remember Me & Forgot Password (Login mode) */} - {mode === "login" && ( -
- - - { - setMode("reset"); - setResetStage(1); - setResetEmail(email.trim()); - setError(null); - setSuccess(null); - }} - > - Забыли пароль? - -
- )} - - {/* Submit Button */} - - {/* Switch Mode Link */} -
- - {mode === "login" ? ( + {/* Remember Me & Forgot Password (Login mode) */} + {mode === "login" && ( +
+ + + { + setMode("reset"); + setResetStage(1); + setResetEmail(email.trim()); + setError(null); + setSuccess(null); + }} + > + Забыли пароль? + +
+ )} + + {/* Submit Button */} +
-
+ + + {/* Switch Mode Link */} +
+ + {mode === "login" ? ( + <> + Нет аккаунта?{" "} + { + setMode("register"); + setRegStage(1); + setRegCode(""); + setUsername(""); + setError(null); + setSuccess(null); + }} + > + Зарегистрироваться + + + ) : ( + <> + Уже есть аккаунт?{" "} + { + setMode("login"); + setRegStage(1); + setRegCode(""); + setUsername(""); + setError(null); + setSuccess(null); + }} + > + Войти + + + )} + +
+ + )} )}