Added system/info endpoint, implement tagging

This commit is contained in:
2025-04-13 09:11:52 -04:00
parent c354ea6d78
commit d5bc4cafb8
17 changed files with 143 additions and 22 deletions

95
api/src/account/auth.rs Normal file
View File

@@ -0,0 +1,95 @@
use std::future::Future;
use std::pin::Pin;
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http};
use serde::{Serialize, Deserialize};
use crate::{error::Error, users::User};
use super::{Session, SESSION_COOKIE_NAME};
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
pub session_id: Option<String>,
pub api_key: Option<String>,
pub user: User,
}
impl FromRequest for Auth {
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
// Check for API key
match req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
{
Some(key_id) => {
let fut = async move {
// Check if the Session API key exists
let api_key = match Session::get(&key_id).await {
Ok(session) => session,
Err(err) => {
log::error!("Invalid session auth attempt: {}", err);
return Err(Error::new(401, "API Key does not exist".to_string()).into());
}
};
match User::select(&api_key.email).await {
Some(user) => Ok(Auth {
session_id: None,
api_key: Some(key_id),
user,
}),
None => Err(Error::new(404, format!("User {} not found", api_key.email)).into()),
}
};
return Box::pin(fut);
}
None => {}
};
// Check for session
let session_id = match req
.cookie(SESSION_COOKIE_NAME)
.map(|c| c.value().to_string())
.or_else(|| {
req
.headers()
.get(http::header::AUTHORIZATION)
.map(|h| h.to_str().unwrap().split_at(7).1.to_string())
}) {
Some(id) => id,
None => {
let fut = async {
Err(
Error {
status: 401,
details: "No session ID found in the request".to_string(),
}
.into(),
)
};
return Box::pin(fut);
}
};
// Get IP address from request
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify the session
let fut = async move {
match Session::verify(&session_id, &ip_address).await {
Ok(session) => match User::select(&session.email).await {
Some(user) => Ok(Auth {
session_id: Some(session_id),
api_key: None,
user,
}),
None => Err(Error::new(404, format!("User {} not found", session.email)).into()),
},
Err(err) => Err(err.into()),
}
};
Box::pin(fut)
}
}

76
api/src/account/mod.rs Normal file
View File

@@ -0,0 +1,76 @@
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use rand::distr::Alphanumeric;
use rand::prelude::*;
use rand_chacha::ChaCha20Rng;
mod auth;
mod routes;
mod session;
pub use auth::*;
pub use session::*;
pub use routes::init_routes;
use crate::error::{Error, ApiResult};
pub fn csprng(take: usize) -> String {
// Generate a CSPRNG 128-bit (16 byte) ID using alphanumeric characters (a-z, A-Z, 0-9)
let rng = ChaCha20Rng::from_os_rng();
rng
.sample_iter(Alphanumeric)
.take(take)
.map(char::from)
.collect()
}
pub fn hash(string: &str) -> ApiResult<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(string.as_bytes(), &salt)?
.to_string();
Ok(hash)
}
pub fn verify_hash(string: &str, hashed_string: &str) -> bool {
let bytes = string.as_bytes();
let parsed_hash = match PasswordHash::new(hashed_string) {
Ok(h) => h,
Err(err) => {
log::error!(
"Failed to construct PasswordHash from '{}': {}",
hashed_string,
err
);
return false;
}
};
Argon2::default()
.verify_password(bytes, &parsed_hash)
.is_ok()
}
pub fn verify_role(auth: &Auth, role: &str) -> ApiResult<()> {
if auth.user.role == role {
Ok(())
} else {
Err(Error {
status: 403,
details: "User does not have permission to perform this action.".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash() {
let password = hash("password").unwrap();
assert!(!verify_hash(&password, "bad_password"));
assert!(verify_hash("password", &password));
}
}

232
api/src/account/routes.rs Normal file
View File

@@ -0,0 +1,232 @@
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{
account::{verify_hash, Session, SESSION_COOKIE_NAME},
error::Error,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use crate::account::Auth;
use crate::users::UpdateUser;
#[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
let email = register_user.email.clone();
let ip_address = req.peer_addr().unwrap().ip().to_string();
let insert_user: User = match register_user.to_user() {
Ok(user) => user,
Err(err) => return ResponseError::error_response(&err),
};
match insert_user.insert().await {
Ok(user) => {
let user_response: UserResponse = user.into();
log::info!(
"Successful user registration [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Created().json(user_response)
}
Err(err) => {
// Obfuscate the service error message to prevent leaking database details
if err.status == 409 {
log::warn!(
"Duplicate user registration attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Conflict().finish()
} else {
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
ResponseError::error_response(&err)
}
}
}
}
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
let query_user = match User::select(&email).await {
Some(query_user) => query_user,
None => return HttpResponse::Unauthorized().finish(),
};
if verify_hash(&request.password, &query_user.password_hash) {
// Create a session
let session = Session::default(&email, &ip_address);
let session_cookie = session.cookie();
// Save the session to the database
if let Err(err) = session.store().await {
log::error!(
"Login attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
log::info!(
"Successful login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
let user_response: UserResponse = query_user.into();
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
} else {
log::error!(
"Invalid login attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Unauthorized().finish()
}
}
#[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Delete the session from the store
match req.cookie(SESSION_COOKIE_NAME) {
Some(cookie) => {
let session_id = cookie.value().to_string();
if let Err(err) = Session::delete(&session_id).await {
log::error!(
"Logout attempt failure [Email: {}] [IP Address: {}]: {}",
email,
ip_address,
err
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
}
None => {
log::error!(
"Invalid logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
return ResponseError::error_response(&Error::new(400, "Invalid session".to_string()));
}
}
log::info!(
"Successful logout attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok().cookie(Session::empty_cookie()).finish()
}
#[get("/session")]
async fn validate_session(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
// Verify a session cookie exists
match req.cookie(SESSION_COOKIE_NAME) {
// Validate the session
Some(cookie) => {
let session_id = cookie.value().to_string();
let session = match Session::replace(&session_id, &ip_address).await {
Ok(session) => session,
Err(err) => {
log::error!(
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
session_id,
ip_address
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
}
};
let email = &session.email;
let query_user = match User::select(&email).await {
Some(query_user) => query_user,
None => {
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish()
}
};
let user_response: UserResponse = query_user.into();
let session_cookie = session.cookie();
log::info!(
"Successful session validate attempt [Email: {}] [IP Address: {}]",
email,
ip_address
);
HttpResponse::Ok()
.cookie(session_cookie)
.json(user_response)
}
None => HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish(),
}
}
#[put("/password")]
async fn change_password(
password: web::Json<String>,
req: HttpRequest,
auth: Auth,
) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let email = auth.user.email;
if let None = User::select(&email).await {
return HttpResponse::Unauthorized().finish();
};
let update_user = UpdateUser {
email: None,
password: Some(password.into_inner()),
role: None,
first_name: None,
last_name: None,
};
match update_user.update(&email).await {
Ok(user) => {
let response: UserResponse = user.into();
log::info!(
"Successful password change attempt [Email: {}] [IP Address: {}]",
&email,
ip_address
);
HttpResponse::Ok().json(response)
}
Err(err) => {
log::error!(
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
&email,
ip_address,
err
);
ResponseError::error_response(&Error::new(500, err.to_string()))
}
}
}
#[post("/password-reset")]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
let _ip_address = req.peer_addr().unwrap().ip().to_string();
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(
web::scope("account")
.service(register)
.service(login)
.service(logout)
.service(change_password)
.service(validate_session),
);
}

150
api/src/account/session.rs Normal file
View File

@@ -0,0 +1,150 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use crate::{
db::redis_async_connection,
error::{Error, ApiResult},
};
use super::{csprng, hash, verify_hash};
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub email: String,
pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn default(email: &str, ip_address: &str) -> Self {
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
email: email.to_string(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
None => None,
},
}
}
pub async fn store(&self) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let key = self.session_id.clone();
let value = serde_json::to_string(self)?;
let result: RedisResult<()> = match self.expires_at {
Some(expires_at) => {
let ttl = expires_at.timestamp() - Utc::now().timestamp();
conn.set_ex(key, &value, ttl as u64).await
}
None => conn.set(key, value).await,
};
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn get(session_id: &str) -> ApiResult<Self> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<Option<String>> = conn.get(session_id).await;
match result {
Ok(Some(value)) => Ok(serde_json::from_str(&value)?),
Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))),
Err(err) => Err(err.into()),
}
}
pub async fn replace(session_id: &str, ip_address: &str) -> ApiResult<Self> {
let mut session = Self::verify(session_id, ip_address).await?;
let session_id_owned = session_id.to_owned();
task::spawn(async move {
if let Err(err) = Self::delete(&session_id_owned).await {
log::error!(
"Error deleting old session in replace session call: {}",
err
);
};
});
session = Session::default(&session.email, ip_address);
session.store().await?;
Ok(session)
}
pub async fn delete(session_id: &str) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let result: RedisResult<()> = conn.del(session_id).await;
match result {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult<Self> {
let session = Self::get(session_id).await?;
// Check if the IP Address matches the Session's IP Address
if verify_hash(ip_address, &session.ip_address) {
Ok(session)
} else {
Err(Error::new(401, "IP Address does not match".to_string()))
}
}
pub fn cookie(&self) -> Cookie {
let expires_at = match self.expires_at {
Some(expires_at) => expires_at.timestamp(),
None => DEFAULT_SESSION_TTL,
};
let ttl = expires_at - Utc::now().timestamp();
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone())
.path("/")
.max_age(Duration::seconds(ttl))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Development cookie [Email: {}]: {}",
self.email,
self.session_id
);
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
pub fn empty_cookie() -> Cookie<'static> {
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
.path("/")
.max_age(Duration::seconds(-1))
.secure(true)
.http_only(true)
.finish();
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
cookie.set_secure(false);
cookie.set_http_only(false);
}
}
cookie
}
}