has/src-tauri/src/auth.rs

994 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
// Anti-DDoS: Connection concurrency semaphore to prevent opening too many simultaneous sockets from this client
static DB_CONCURRENCY_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
LazyLock::new(|| tokio::sync::Semaphore::new(6));
// Anti-DDoS: Connection rate history to prevent connection flooding / burst spikes
static DB_CONN_HISTORY: LazyLock<Mutex<Vec<Instant>>> = LazyLock::new(|| Mutex::new(Vec::new()));
// Anti-Brute-Force & Credential Stuffing tracker
struct LoginRateLimiter {
attempts: HashMap<String, (u32, Instant)>,
lockouts: HashMap<String, Instant>,
}
static LOGIN_LIMITER: LazyLock<Mutex<LoginRateLimiter>> = LazyLock::new(|| {
Mutex::new(LoginRateLimiter {
attempts: HashMap::new(),
lockouts: HashMap::new(),
})
});
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
pub host: String,
pub port: u16,
pub user: String,
pub password: Option<String>,
pub dbname: String,
pub ssl_mode: String,
pub users_table: String,
pub email_column: String,
pub password_column: String,
pub database_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbStatus {
pub connected: bool,
pub host: String,
pub dbname: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserDto {
pub email: String,
pub username: Option<String>,
pub role: String,
pub avatar: Option<String>,
pub bio: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
pub success: bool,
pub message: String,
pub user: Option<UserDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetRequestResponse {
pub success: bool,
pub message: String,
pub dev_code: Option<String>,
}
#[derive(Debug, Clone)]
struct ResetEntry {
code: String,
expires_at: std::time::Instant,
verified: bool,
}
pub fn load_env() {
// 1. Current directory .env
let _ = dotenvy::dotenv();
// 2. Parent directory .env (useful in tauri dev when cwd might be src-tauri)
let _ = dotenvy::from_filename("../.env");
// 3. Executable directory .env (useful in production bundle)
if let Ok(exe_path) = env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let _ = dotenvy::from_path(exe_dir.join(".env"));
if let Some(parent) = exe_dir.parent() {
let _ = dotenvy::from_path(parent.join(".env"));
if let Some(grandparent) = parent.parent() {
let _ = dotenvy::from_path(grandparent.join(".env"));
}
}
}
}
}
pub fn extract_clean_env(key: &str, default: &str) -> String {
env::var(key)
.map(|v| {
let trimmed = v.trim();
let clean = if let Some((c, _)) = trimmed.split_once('#') {
c.trim()
} else {
trimmed
};
if clean.is_empty() {
default.to_string()
} else {
clean.to_string()
}
})
.unwrap_or_else(|_| default.to_string())
}
pub fn get_db_config() -> DbConfig {
load_env();
let database_url = env::var("DATABASE_URL").ok().filter(|s| !s.trim().is_empty());
let host = extract_clean_env("DB_HOST", "80.237.77.141");
let port = extract_clean_env("DB_PORT", "5432")
.parse::<u16>()
.unwrap_or(5432);
let user = extract_clean_env("DB_USER", "has_app");
let pass_str = extract_clean_env("DB_PASSWORD", "Master1017%e");
let password = if !pass_str.is_empty() {
Some(pass_str)
} else {
None
};
let dbname = extract_clean_env("DB_NAME", "has");
let ssl_mode = extract_clean_env("DB_SSL_MODE", "disable").to_lowercase();
let users_table = extract_clean_env("DB_USERS_TABLE", "users");
let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email");
let password_column = extract_clean_env("DB_PASSWORD_COLUMN", "password");
DbConfig {
host,
port,
user,
password,
dbname,
ssl_mode,
users_table,
email_column,
password_column,
database_url,
}
}
fn build_conn_string(config: &DbConfig) -> String {
if let Some(url) = &config.database_url {
if !url.trim().is_empty() {
return url.clone();
}
}
let mut parts = vec![
format!("host={}", config.host),
format!("port={}", config.port),
format!("user={}", config.user),
format!("dbname={}", config.dbname),
];
if let Some(ref pwd) = config.password {
if !pwd.is_empty() {
parts.push(format!("password={}", pwd));
}
}
parts.join(" ")
}
pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client, String> {
// 1. Anti-Flood Rate Limiting: prevent rapid connection bursts
let now = Instant::now();
let should_throttle = {
let mut history = DB_CONN_HISTORY.lock().unwrap();
history.retain(|&t| now.duration_since(t) < Duration::from_secs(4));
if history.len() >= 20 {
true
} else {
history.push(now);
false
}
};
if should_throttle {
tokio::time::sleep(Duration::from_millis(500)).await;
let mut history = DB_CONN_HISTORY.lock().unwrap();
if history.len() >= 25 {
return Err("Слишком много запросов к базе данных. Защита от перегрузки активна, подождите пару секунд.".to_string());
}
history.push(Instant::now());
}
// 2. Concurrency Semaphore: max 6 simultaneous connection handshakes per client
let _permit = DB_CONCURRENCY_SEMAPHORE
.acquire()
.await
.map_err(|e| format!("Семафор БД: {}", e))?;
let conn_str = build_conn_string(config);
let connect_timeout = std::time::Duration::from_secs(10);
// Try SSL ONLY if mode is 'require', or if 'prefer' and not known to be NoTls
static KNOWN_NOTLS_SERVER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if config.ssl_mode == "require" || (config.ssl_mode == "prefer" && !KNOWN_NOTLS_SERVER.load(std::sync::atomic::Ordering::Relaxed)) {
let mut builder = native_tls::TlsConnector::builder();
builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS
if let Ok(tls_connector) = builder.build() {
let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector);
let conn_future = tokio_postgres::connect(&conn_str, tls);
let tls_timeout = if config.ssl_mode == "require" { connect_timeout } else { std::time::Duration::from_secs(2) };
match tokio::time::timeout(tls_timeout, conn_future).await {
Ok(Ok((client, connection))) => {
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("PostgreSQL connection error: {}", e);
}
});
return Ok(client);
}
Ok(Err(e)) => {
if config.ssl_mode == "require" {
return Err(format!("TLS connection failed: {}", e));
}
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// If prefer, fallback to NoTls below
}
Err(_) => {
if config.ssl_mode == "require" {
return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string());
}
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// If prefer, fallback to NoTls below
}
}
}
}
// Connect with NoTls
let conn_future = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls);
match tokio::time::timeout(connect_timeout, conn_future).await {
Ok(Ok((client, connection))) => {
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("PostgreSQL connection error: {}", e);
}
});
Ok(client)
}
Ok(Err(e)) => {
eprintln!("PostgreSQL connection failed: {e:#?}");
eprintln!("PostgreSQL error details: {e}");
Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string())
}
Err(_) => {
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 10 сек.".to_string())
}
}
}
pub fn verify_password(provided: &str, stored: &str) -> bool {
let stored = stored.trim();
// 1. Bcrypt hash check ($2a$, $2b$, $2y$)
if stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$") {
if let Ok(valid) = bcrypt::verify(provided, stored) {
return valid;
}
}
// 2. SHA-256 hash check (64 hex characters)
if stored.len() == 64 && stored.chars().all(|c| c.is_ascii_hexdigit()) {
let mut hasher = Sha256::new();
hasher.update(provided.as_bytes());
let hash = hex::encode(hasher.finalize());
if hash.eq_ignore_ascii_case(stored) {
return true;
}
}
// 3. Direct plaintext match
provided == stored
}
pub fn get_error_message(e: &tokio_postgres::Error) -> String {
if let Some(dbe) = e.as_db_error() {
dbe.message().to_string()
} else {
e.to_string()
}
}
pub async fn check_db_health() -> DbStatus {
let config = get_db_config();
match connect_to_db(&config).await {
Ok(client) => {
match client.simple_query("SELECT 1;").await {
Ok(_) => DbStatus {
connected: true,
host: "Protected".to_string(),
dbname: "Protected".to_string(),
message: "Соединение с сервером установлено (защищенный канал)".to_string(),
},
Err(_e) => DbStatus {
connected: false,
host: "Protected".to_string(),
dbname: "Protected".to_string(),
message: "Ошибка выполнения запроса к серверу".to_string(),
},
}
}
Err(e) => DbStatus {
connected: false,
host: "Protected".to_string(),
dbname: "Protected".to_string(),
message: e,
},
}
}
pub async fn authenticate_user(email: &str, password: &str) -> AuthResponse {
let clean_email = email.trim();
if clean_email.is_empty() || password.is_empty() {
return AuthResponse {
success: false,
message: "Email и пароль обязательны для заполнения".to_string(),
user: None,
};
}
// Anti-Brute-Force / Credential Stuffing check
let now = Instant::now();
{
let mut limiter = LOGIN_LIMITER.lock().unwrap();
if let Some(&lockout_until) = limiter.lockouts.get(clean_email) {
if now < lockout_until {
let remaining_secs = lockout_until.duration_since(now).as_secs() + 1;
return AuthResponse {
success: false,
message: format!(
"Слишком много неудачных попыток входа. В целях безопасности доступ заблокирован на {} сек.",
remaining_secs
),
user: None,
};
} else {
limiter.lockouts.remove(clean_email);
limiter.attempts.remove(clean_email);
}
}
}
let config = get_db_config();
let client = match connect_to_db(&config).await {
Ok(c) => c,
Err(_) => {
return AuthResponse {
success: false,
message: "Ошибка подключения к серверу авторизации. Попробуйте позже.".to_string(),
user: None,
};
}
};
// Sanitize identifier names (allow only alphanumeric + underscore to avoid injection in table/col names)
let is_safe_ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_');
if !is_safe_ident(&config.users_table) || !is_safe_ident(&config.email_column) || !is_safe_ident(&config.password_column) {
return AuthResponse {
success: false,
message: "Некорректные имена таблиц или колонок в конфигурации .env".to_string(),
user: None,
};
}
// Auto-create table if standard 'users' table
if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" {
let create_table_sql = "
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
username VARCHAR(255),
role VARCHAR(50) DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(50) DEFAULT 'user';
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
UPDATE users SET role = 'admin' WHERE id = 1 AND (role IS NULL OR role = '');
";
let _ = client.batch_execute(create_table_sql).await;
}
let query_with_bio = format!(
"SELECT {}, username, role, avatar, bio FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
config.password_column, config.users_table, config.email_column
);
let (query_result, has_avatar, has_bio) = match client.query_opt(&query_with_bio, &[&clean_email]).await {
Ok(row) => (Ok(row), true, true),
Err(_) => {
let query_with_avatar = format!(
"SELECT {}, username, role, avatar FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
config.password_column, config.users_table, config.email_column
);
match client.query_opt(&query_with_avatar, &[&clean_email]).await {
Ok(row) => (Ok(row), true, false),
Err(_) => {
let query_fallback = format!(
"SELECT {} FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
config.password_column, config.users_table, config.email_column
);
(client.query_opt(&query_fallback, &[&clean_email]).await, false, false)
}
}
}
};
match query_result {
Ok(Some(row)) => {
let stored_pwd: Result<String, _> = row.try_get(0);
let username: Option<String> = if has_avatar { row.try_get(1).ok() } else { None };
let role_opt: Option<String> = if has_avatar { row.try_get(2).ok() } else { None };
let avatar: Option<String> = if has_avatar { row.try_get(3).ok() } else { None };
let bio: Option<String> = if has_bio { row.try_get(4).ok() } else { None };
let role = role_opt.filter(|r| !r.trim().is_empty()).unwrap_or_else(|| "user".to_string());
match stored_pwd {
Ok(stored_password) => {
if verify_password(password, &stored_password) {
// Reset failed attempts on success
{
let mut limiter = LOGIN_LIMITER.lock().unwrap();
limiter.attempts.remove(clean_email);
limiter.lockouts.remove(clean_email);
}
// Update heartbeat/last_seen_at immediately on login
let _ = client.execute(
&format!("UPDATE {} SET last_seen_at = CURRENT_TIMESTAMP WHERE LOWER({}) = LOWER($1);", config.users_table, config.email_column),
&[&clean_email]
).await;
AuthResponse {
success: true,
message: "Успешная авторизация".to_string(),
user: Some(UserDto {
email: clean_email.to_string(),
username,
role,
avatar,
bio,
}),
}
} else {
// Track failed attempt
let lockout_applied = {
let mut limiter = LOGIN_LIMITER.lock().unwrap();
let entry = limiter.attempts.entry(clean_email.to_string()).or_insert((0, now));
if now.duration_since(entry.1) > Duration::from_secs(60) {
*entry = (1, now);
} else {
entry.0 += 1;
}
if entry.0 >= 5 {
limiter.lockouts.insert(clean_email.to_string(), now + Duration::from_secs(30));
true
} else {
false
}
};
AuthResponse {
success: false,
message: if lockout_applied {
"Слишком много неверных попыток. В целях безопасности доступ временно заблокирован на 30 секунд.".to_string()
} else {
"Неверный пароль".to_string()
},
user: None,
}
}
}
Err(e) => AuthResponse {
success: false,
message: format!("Ошибка чтения пароля из базы: {}", e),
user: None,
},
}
}
Ok(None) => AuthResponse {
success: false,
message: "Пользователь с таким email не найден".to_string(),
user: None,
},
Err(e) => {
let msg = get_error_message(&e);
if msg.contains("does not exist") || msg.contains("не существует") {
AuthResponse {
success: false,
message: "Ошибка авторизации: аккаунт не найден.".to_string(),
user: None,
}
} else if msg.contains("permission denied") || msg.contains("отказано в доступе") {
AuthResponse {
success: false,
message: "Ошибка доступа к серверу базы данных.".to_string(),
user: None,
}
} else {
AuthResponse {
success: false,
message: "Ошибка обработки запроса авторизации.".to_string(),
user: None,
}
}
}
}
}
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());
if clean_email.is_empty() || password.is_empty() {
return AuthResponse {
success: false,
message: "Email и пароль обязательны".to_string(),
user: None,
};
}
let config = get_db_config();
let client = match connect_to_db(&config).await {
Ok(c) => c,
Err(_) => {
return AuthResponse {
success: false,
message: "Ошибка подключения к серверу регистрации. Попробуйте позже.".to_string(),
user: None,
};
}
};
// Auto-create table if standard 'users' table
if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" {
let create_table_sql = "
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
username VARCHAR(255),
role VARCHAR(50) DEFAULT 'user',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(50) DEFAULT 'user';
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
UPDATE users SET role = 'admin' WHERE id = 1 AND (role IS NULL OR role = '');
";
let _ = client.batch_execute(create_table_sql).await;
}
// Determine initial role: if users table has 0 users, this first user is admin
let user_count: i64 = client
.query_one(&format!("SELECT COUNT(*) FROM {}", config.users_table), &[])
.await
.map(|r| r.get(0))
.unwrap_or(1);
let assigned_role = if user_count == 0 { "admin" } else { "user" };
// Hash password with bcrypt
let hashed_password = match bcrypt::hash(password, 10) {
Ok(h) => h,
Err(e) => return AuthResponse {
success: false,
message: format!("Ошибка хеширования пароля: {}", e),
user: None,
},
};
let insert_query = format!(
"INSERT INTO {} ({}, {}, username, role) VALUES ($1, $2, $3, $4)",
config.users_table, config.email_column, config.password_column
);
let insert_res = match client.execute(&insert_query, &[&clean_email, &hashed_password, &clean_username, &assigned_role]).await {
Ok(n) => Ok(n),
Err(_) => {
let fallback_insert = format!(
"INSERT INTO {} ({}, {}) VALUES ($1, $2)",
config.users_table, config.email_column, config.password_column
);
client.execute(&fallback_insert, &[&clean_email, &hashed_password]).await
}
};
match insert_res {
Ok(_) => AuthResponse {
success: true,
message: "Аккаунт успешно создан".to_string(),
user: Some(UserDto {
email: clean_email.to_string(),
username: clean_username.map(String::from),
role: assigned_role.to_string(),
avatar: None,
bio: None,
}),
},
Err(e) => {
let err_str = get_error_message(&e);
if err_str.contains("unique") || err_str.contains("duplicate") || err_str.contains("уже существует") {
AuthResponse {
success: false,
message: "Пользователь с таким email уже существует".to_string(),
user: None,
}
} else {
AuthResponse {
success: false,
message: format!("Ошибка сохранения: {}", err_str),
user: None,
}
}
}
}
}
static RESET_CODES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, ResetEntry>>> = std::sync::OnceLock::new();
fn get_reset_codes() -> &'static std::sync::Mutex<std::collections::HashMap<String, ResetEntry>> {
RESET_CODES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn generate_reset_code() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let num = (nanos % 900_000) + 100_000;
format!("{:06}", num)
}
pub async fn send_smtp_email(
host: &str,
port: u16,
user: &str,
pass: &str,
from: &str,
to: &str,
subject: &str,
body: &str,
) -> Result<(), String> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use base64::Engine;
let host = host.trim();
let user = user.trim();
let pass = pass.trim();
let sender = if !from.trim().is_empty() { from.trim() } else { user };
let tcp = tokio::net::TcpStream::connect((host, port))
.await
.map_err(|e| format!("Не удалось подключиться к SMTP {}:{}: {}", host, port, e))?;
let mut builder = native_tls::TlsConnector::builder();
builder.danger_accept_invalid_certs(true);
let tls_connector = builder.build().map_err(|e| e.to_string())?;
let tokio_tls = tokio_native_tls::TlsConnector::from(tls_connector);
let tls_stream = tokio_tls
.connect(host, tcp)
.await
.map_err(|e| format!("TLS ошибка SMTP {}:{}: {}", host, port, e))?;
let (reader, mut writer) = tokio::io::split(tls_stream);
let mut reader = BufReader::new(reader);
async fn read_reply<R: AsyncBufReadExt + Unpin>(reader: &mut R, expected: &str) -> Result<String, String> {
let mut last_line;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.map_err(|e| format!("Ошибка чтения ответа SMTP: {}", e))?;
if n == 0 {
return Err("SMTP соединение неожиданно закрыто сервером".to_string());
}
last_line = line.clone();
// Multi-line SMTP: lines start with "XYZ-", the last line is "XYZ "
if line.len() >= 4 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.as_bytes()[3] == b' ' {
break;
}
if line.len() >= 3 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.len() < 4 {
break;
}
}
if !last_line.starts_with(expected) {
return Err(format!("SMTP неожиданный ответ: {}", last_line.trim()));
}
Ok(last_line)
}
async fn send_cmd<W: AsyncWriteExt + Unpin, R: AsyncBufReadExt + Unpin>(
writer: &mut W,
reader: &mut R,
cmd: &str,
expected: &str,
) -> Result<String, String> {
writer.write_all(cmd.as_bytes()).await.map_err(|e| format!("Ошибка отправки команды SMTP: {}", e))?;
writer.flush().await.map_err(|e| format!("Ошибка flush SMTP: {}", e))?;
read_reply(reader, expected).await
}
// Read initial 220 banner (e.g. "220 smtp.mail.ru ESMTP ready")
read_reply(&mut reader, "220").await?;
// Send EHLO
send_cmd(&mut writer, &mut reader, "EHLO mail.ru\r\n", "250").await?;
// AUTH LOGIN if credentials given
if !user.is_empty() && !pass.is_empty() {
send_cmd(&mut writer, &mut reader, "AUTH LOGIN\r\n", "334").await?;
let user_b64 = base64::engine::general_purpose::STANDARD.encode(user);
send_cmd(&mut writer, &mut reader, &format!("{}\r\n", user_b64), "334").await?;
let pass_b64 = base64::engine::general_purpose::STANDARD.encode(pass);
send_cmd(&mut writer, &mut reader, &format!("{}\r\n", pass_b64), "235").await?;
}
send_cmd(&mut writer, &mut reader, &format!("MAIL FROM:<{}>\r\n", sender), "250").await?;
send_cmd(&mut writer, &mut reader, &format!("RCPT TO:<{}>\r\n", to), "250").await?;
send_cmd(&mut writer, &mut reader, "DATA\r\n", "354").await?;
let subj_b64 = base64::engine::general_purpose::STANDARD.encode(subject);
let msg = format!(
"From: <{}>\r\nTo: <{}>\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{}\r\n.\r\n",
sender, to, subj_b64, body
);
send_cmd(&mut writer, &mut reader, &msg, "250").await?;
let _ = send_cmd(&mut writer, &mut reader, "QUIT\r\n", "221").await;
Ok(())
}
pub async fn request_reset_code(email: &str) -> ResetRequestResponse {
let clean_email = email.trim();
if clean_email.is_empty() {
return ResetRequestResponse {
success: false,
message: "Пожалуйста, введите адрес электронной почты".to_string(),
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(_)) => {
let code = generate_reset_code();
let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(900); // 15 mins
{
let mut map = get_reset_codes().lock().unwrap();
map.insert(clean_email.to_lowercase(), ResetEntry {
code: code.clone(),
expires_at,
verified: false,
});
}
let smtp_host = extract_clean_env("SMTP_HOST", "smtp.mail.ru");
let smtp_port = extract_clean_env("SMTP_PORT", "465").parse::<u16>().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Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.",
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,
}
}
Ok(None) => ResetRequestResponse {
success: false,
message: "Пользователь с таким email не найден в базе данных".to_string(),
dev_code: None,
},
Err(e) => ResetRequestResponse {
success: false,
message: format!("Ошибка запроса к базе данных: {}", e),
dev_code: None,
},
}
}
pub fn verify_reset_code(email: &str, code: &str) -> AuthResponse {
let clean_email = email.trim().to_lowercase();
let clean_code = code.trim();
let mut map = get_reset_codes().lock().unwrap();
if let Some(entry) = map.get_mut(&clean_email) {
if std::time::Instant::now() > entry.expires_at {
return AuthResponse {
success: false,
message: "Срок действия кода истек. Запросите новый код.".to_string(),
user: None,
};
}
if entry.code == clean_code {
entry.verified = true;
AuthResponse {
success: true,
message: "Код подтвержден. Введите новый пароль.".to_string(),
user: None,
}
} else {
AuthResponse {
success: false,
message: "Неверный проверочный код".to_string(),
user: None,
}
}
} else {
AuthResponse {
success: false,
message: "Код подтверждения не запрашивался для этой почты".to_string(),
user: None,
}
}
}
pub async fn reset_password_with_code(email: &str, code: &str, new_password: &str) -> AuthResponse {
let clean_email = email.trim();
let clean_code = code.trim();
if new_password.trim().is_empty() {
return AuthResponse {
success: false,
message: "Новый пароль не может быть пустым".to_string(),
user: None,
};
}
{
let map = get_reset_codes().lock().unwrap();
if let Some(entry) = map.get(&clean_email.to_lowercase()) {
if std::time::Instant::now() > entry.expires_at {
return AuthResponse {
success: false,
message: "Срок действия кода истек. Пожалуйста, запросите код заново.".to_string(),
user: None,
};
}
if entry.code != clean_code && !entry.verified {
return AuthResponse {
success: false,
message: "Неверный проверочный код".to_string(),
user: None,
};
}
} else {
return AuthResponse {
success: false,
message: "Сессия сброса пароля не найдена. Начните сначала.".to_string(),
user: None,
};
}
}
let config = get_db_config();
let client = match connect_to_db(&config).await {
Ok(c) => c,
Err(_) => {
return AuthResponse {
success: false,
message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(),
user: None,
};
}
};
let hashed_password = match bcrypt::hash(new_password, 10) {
Ok(h) => h,
Err(e) => {
return AuthResponse {
success: false,
message: format!("Ошибка хеширования: {}", e),
user: None,
};
}
};
let update_query = format!(
"UPDATE {} SET {} = $1 WHERE LOWER({}) = LOWER($2)",
config.users_table, config.password_column, config.email_column
);
match client.execute(&update_query, &[&hashed_password, &clean_email]).await {
Ok(rows) if rows > 0 => {
let mut map = get_reset_codes().lock().unwrap();
map.remove(&clean_email.to_lowercase());
AuthResponse {
success: true,
message: "Пароль успешно изменен! Теперь вы можете войти с новым паролем.".to_string(),
user: Some(UserDto {
email: clean_email.to_string(),
username: None,
role: "user".to_string(),
avatar: None,
bio: None,
}),
}
}
Ok(_) => AuthResponse {
success: false,
message: "Пользователь не найден в базе данных".to_string(),
user: None,
},
Err(e) => AuthResponse {
success: false,
message: format!("Ошибка обновления пароля в базе: {}", e),
user: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_database_connection() {
let config = get_db_config();
println!("Testing DB connection to host: {}", config.host);
let client_res = connect_to_db(&config).await;
assert!(client_res.is_ok(), "Failed to connect to DB: {:?}", client_res.err());
let client = client_res.unwrap();
let query_res = client.simple_query("SELECT 1;").await;
assert!(query_res.is_ok(), "Failed to run simple query: {:?}", query_res.err());
}
}