Added system/info endpoint, implement tagging
This commit is contained in:
95
api/src/account/auth.rs
Normal file
95
api/src/account/auth.rs
Normal 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
76
api/src/account/mod.rs
Normal 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
232
api/src/account/routes.rs
Normal 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
150
api/src/account/session.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user