Working on emails, updated swagger, added geometry column to airports

This commit is contained in:
2025-05-15 09:16:22 -04:00
parent e46e8ab9b4
commit 3674623691
13 changed files with 449 additions and 135 deletions

View File

@@ -0,0 +1,161 @@
use crate::account::hash;
use crate::db::redis_async_connection;
use crate::error::{ApiResult, Error};
use crate::smtp;
use chrono::{Datelike, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::{env, fs};
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailToken {
pub email: String,
pub token: String,
pub ip_address: String,
}
impl EmailToken {
pub fn new(email: String, token: String, ip_address: &str) -> Self {
Self {
email,
token,
ip_address: hash(&ip_address).unwrap(),
}
}
pub async fn store(&self, ttl_secs: i64) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.token.clone();
let value = serde_json::to_string(self)?;
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_secs);
let ttl = expires_at.timestamp() - now.timestamp();
let result: RedisResult<()> = conn.set_ex(key, &value, ttl as u64).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(token: &str) -> ApiResult<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(token).await;
match result {
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
Ok(None) => Err(Error::new(404, format!("Missing email token {}", token))),
Err(err) => Err(err.into()),
}
}
pub async fn delete(token: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(token).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
}
#[derive(Serialize)]
pub struct SimpleEmailCtx {
pub logo_url: String,
pub link: String,
pub domain: String,
pub year: i32,
}
pub fn send_password_reset_email(
email: &str,
email_token: &EmailToken,
ip_address: &str,
) -> ApiResult<()> {
let base_url = env::var("EXTERNAL_URL")?;
let link = format!("{base_url}/profile/reset?token={}", email_token.token);
let subject = "Reset your password";
let plain = format!(
"Hello,\n\n\
We received a password reset request. Click the link below:\n\n\
{link}\n\n\
This link expires in 24 hours. If you didn't request this, please ignore.\n\n\
Cheers,\n\
The Aviation Data Team",
link = link
);
let ctx = SimpleEmailCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_dir = env::var("TEMPLATE_DIR")?;
let tpl_path = Path::new(&template_dir).join("password_reset.html");
let template_html = fs::read_to_string(&tpl_path)?;
let html = smtp::registry()
.render_template(&template_html, &ctx)
.unwrap();
match smtp::send_email(&email, subject, plain, html) {
Ok(_) => Ok(()),
Err(err) => {
log::error!(
"Invalid password reset attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
Err(err.into())
}
}
}
pub fn send_confirm_email(
email: &str,
email_token: &EmailToken,
ip_address: &str,
) -> ApiResult<()> {
let base_url = env::var("EXTERNAL_URL")?;
let link = format!("{base_url}/profile/confirm?token={}", email_token.token);
let subject = "Confirm your email address";
let plain = format!(
"Hello,\n\n\
Thanks for registering! Click the link below to confirm your email address:\n\n\
{link}\n\n\
If you didnt sign up for an Aviation Data account, please ignore this.\n\n\
Cheers,\n\
The Aviation Data Team",
link = link
);
let ctx = SimpleEmailCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_dir = env::var("TEMPLATE_DIR")?;
let tpl_path = Path::new(&template_dir).join("confirm_email.html");
let template_html = fs::read_to_string(&tpl_path)?;
let html = smtp::registry()
.render_template(&template_html, &ctx)
.unwrap();
match smtp::send_email(&email, subject, plain, html) {
Ok(_) => Ok(()),
Err(err) => {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
Err(err.into())
}
}
}

View File

@@ -7,6 +7,7 @@ use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
mod auth;
mod email_token;
mod routes;
mod session;

View File

@@ -1,13 +1,16 @@
use crate::{
account::{SESSION_COOKIE_NAME, Session, verify_hash},
error::Error,
smtp,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
use serde::Deserialize;
use std::fs;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use crate::account::email_token::{EmailToken, send_confirm_email, send_password_reset_email};
use crate::account::{Auth, csprng};
use crate::users::UpdateUser;
@@ -17,8 +20,8 @@ use crate::users::UpdateUser;
content = RegisterRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 409, description = ""),
(status = 200, description = "Successful Response", body = UserResponse),
(status = 409, description = "Conflict"),
)
)]
#[post("/register")]
@@ -39,6 +42,17 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
email,
ip_address
);
// Send confirmation email
let token = csprng(128);
let email_token = EmailToken::new(email.clone(), token, &ip_address);
if let Err(err) = email_token.store(86400).await {
return ResponseError::error_response(&err);
}
if let Err(err) = send_confirm_email(&email, &email_token, &ip_address) {
return ResponseError::error_response(&Error::new(500, err.to_string()));
};
HttpResponse::Created().json(user_response)
}
Err(err) => {
@@ -64,7 +78,7 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
content = LoginRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 200, description = "Successful Response", body = UserResponse),
),
)]
#[post("/login")]
@@ -118,9 +132,8 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
(status = 500, description = ""),
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
@@ -168,8 +181,8 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
@@ -229,17 +242,86 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct TokenRequest {
token: String,
}
#[utoipa::path(
tag = "Account",
request_body(
content = TokenRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response", body = UserResponse),
(status = 404, description = "Not Found"),
),
)]
#[post("/profile/confirm")]
async fn confirm_profile(request: web::Json<TokenRequest>, req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let email_token = match EmailToken::get(token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(_) => {
return HttpResponse::NotFound().finish();
}
};
match User::select_by_email(&email_token.email).await {
Some(user) => {
let update_user = UpdateUser {
email: None,
email_verified: Some(true),
password: None,
role: None,
first_name: None,
last_name: None,
avatar: None,
};
match update_user.update(&user.id).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful email confirmation attempt [Email: {}] [IP Address: {}]",
&email_token.email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid email confirmation attempt [Email: {}] [IP Address: {}]: {}",
&email_token.email,
ip_address,
err
);
ResponseError::error_response(&err)
}
}
}
None => HttpResponse::NotFound().finish(),
}
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
(status = 200, description = "Successful Response"),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
)
)]
#[get("/session")]
#[post("/session")]
async fn session_refresh(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
@@ -282,14 +364,19 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct PasswordRequest {
password: String,
}
#[utoipa::path(
tag = "Account",
request_body(
content = String, content_type = "application/json"
content = PasswordRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
(status = 200, description = "Successful Response", body = UserResponse),
(status = 401, description = "Unauthorized"),
),
security(
("session_auth" = [])
@@ -297,7 +384,7 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
)]
#[put("/password")]
async fn change_password(
password: web::Json<String>,
request: web::Json<PasswordRequest>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
@@ -311,7 +398,7 @@ async fn change_password(
let update_user = UpdateUser {
email: None,
email_verified: None,
password: Some(password.into_inner()),
password: Some(request.password.clone()),
role: None,
first_name: None,
last_name: None,
@@ -340,35 +427,72 @@ async fn change_password(
}
}
#[derive(Debug, Deserialize, ToSchema)]
struct EmailRequest {
email: String,
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
request_body(
content = EmailRequest, content_type = "application/json"
),
security(
("session_auth" = [])
responses(
(status = 200, description = "Successful Response"),
)
)]
#[post("/password/reset")]
async fn reset_password(req: HttpRequest, auth: Auth) -> HttpResponse {
async fn reset_password(request: web::Json<EmailRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let id = auth.user.id;
let email = auth.user.email;
let token = csprng(128);
match smtp::send_password_reset(&email, &token) {
Ok(_) => HttpResponse::Ok().finish(),
// Silently return if the user does not exist
if let None = User::select_by_email(&email).await {
return HttpResponse::Ok().finish();
};
let email_token = EmailToken::new(email.clone(), token, &ip_address);
if let Err(err) = email_token.store(86400).await {
return ResponseError::error_response(&err);
}
if let Err(err) = send_password_reset_email(email, &email_token, &ip_address) {
return ResponseError::error_response(&Error::new(500, err.to_string()));
};
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "Account",
request_body(
content = TokenRequest, content_type = "application/json"
),
responses(
(status = 200, description = "Successful Response"),
(status = 404, description = "Not Found"),
)
)]
#[post("/password/validate")]
async fn validate_reset_password(
request: web::Json<TokenRequest>,
req: HttpRequest,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let token = &request.token;
let email_token = match EmailToken::get(token).await {
Ok(password_reset) => {
if let Err(err) = EmailToken::delete(&password_reset.token).await {
return ResponseError::error_response(&err);
};
password_reset
}
Err(err) => {
log::error!(
"Invalid password reset attempt [ID: {}] [IP Address: {}]: {}",
&id,
ip_address,
err
);
ResponseError::error_response(&err)
return HttpResponse::NotFound().json(err);
}
};
HttpResponse::Ok().finish()
}
@@ -379,8 +503,10 @@ pub fn init_routes(config: &mut ServiceConfig) {
.service(login)
.service(logout)
.service(get_profile)
.service(confirm_profile)
.service(session_refresh)
.service(change_password)
.service(reset_password),
.service(reset_password)
.service(validate_reset_password),
);
}