diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index 615e6f4..6d1af0b 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -6,6 +6,8 @@ DATABASE_NAME=siren DATABASE_HOST=localhost DATABASE_PORT=5432 +JWT_SECRET= + REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/service/Cargo.toml b/service/Cargo.toml index bc5f973..dd001db 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -16,8 +16,6 @@ actix-web = "4.4.0" actix-rt = "2.9.0" actix-cors = "0.6.4" actix-web-httpauth = "0.8.1" -actix-identity = "0.6.0" -actix-session = { version = "0.8.0", features = ["redis-actor-session", "cookie-session"] } chrono = { version = "0.4.31", features = ["serde"] } dotenv = "0.15.0" serde_json = "1.0.107" diff --git a/service/src/auth/mod.rs b/service/src/auth/mod.rs new file mode 100644 index 0000000..fa0d527 --- /dev/null +++ b/service/src/auth/mod.rs @@ -0,0 +1,79 @@ +use std::env; + +use actix_web::{dev::ServiceRequest, Error as ActixError}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, encode, decode, Validation, Algorithm}; +use serde::{Deserialize, Serialize}; +use siren::ServiceError; + +mod model; +mod routes; + +pub use model::*; +pub use routes::init_routes; + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, +} + +// https://github.com/Sirneij/rust-auth/blob/main/backend/src/routes/users/login.rs +// https://dev.to/sirneij/authentication-system-using-rust-actix-web-and-sveltekit-user-registration-580h +// https://github.com/actix/actix-extras/blob/master/actix-session/examples/basic.rs +// maybe https://github.com/actix/actix-extras/blob/master/actix-identity/examples/identity.rs +// https://www.lpalmieri.com/posts/session-based-authentication-in-rust/#3-3-1-postgres + +pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { + let token = credentials.token(); + match validate_token(token) { + Ok(_) => Ok(req), + Err(err) => { + Err((ActixError::from(actix_web::error::ErrorUnauthorized(err)), req)) + } + } +} + +fn validate_token(token: &str) -> Result<(), ServiceError> { + let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + match decode::(token, &DecodingKey::from_secret(jwt_secret.as_ref()), &Validation::new(Algorithm::HS256)) { + Ok(token_data) => { + println!("{:?}", token_data.claims); + if token_data.claims.exp < chrono::Utc::now().timestamp() as usize { + return Err(ServiceError { + status: 401, + message: "Token expired".to_string() + }); + } + Ok(()) + }, + Err(err) => { + Err(ServiceError { + status: 401, + message: err.to_string() + }) + } + } +} + +pub fn create_token(email: &str) -> String { + let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + let exp = chrono::Utc::now().checked_add_signed(chrono::Duration::seconds(3600)).expect("valid timestamp").timestamp(); + let claims = Claims { + sub: email.to_owned(), + exp: exp as usize, + }; + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(jwt_secret.as_ref())).unwrap(); + token +} + +pub fn hash_password(password: &[u8]) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default().hash_password(password, &salt)?.to_string()) +} + +pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), HashError> { + let parsed_hash = PasswordHash::new(hash)?; + Ok(Argon2::default().verify_password(password, &parsed_hash)?) +} \ No newline at end of file diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs new file mode 100644 index 0000000..b67c28a --- /dev/null +++ b/service/src/auth/model.rs @@ -0,0 +1,81 @@ +use diesel::prelude::*; +use serde::{Serialize, Deserialize}; +use siren::ServiceError; + +use crate::db::schema::users; + +use super::hash_password; + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterUser { + pub email: String, + pub password: String, + pub first_name: String, + pub last_name: String, +} + +impl RegisterUser { + pub fn convert_to_insert(self) -> Result { + let hash = hash_password(self.password.as_bytes())?; + Ok(InsertUser { + email: self.email.to_lowercase(), + hash, + role: "user".to_string(), + first_name: self.first_name, + last_name: self.last_name, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginResponse { + pub token: String, +} + +#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)] +#[diesel(table_name = users)] +pub struct QueryUser { + pub email: String, + pub hash: String, + pub role: String, + pub first_name: String, + pub last_name: String, +} + +impl QueryUser { + pub fn get_by_email(email: &str) -> Result { + let mut conn = crate::db::connection()?; + // Check if the user exists by email, case insensitive + + let user = users::table + .filter(users::email.eq(email.to_lowercase())) + .first(&mut conn)?; + Ok(user) + } +} + +#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)] +#[diesel(table_name = users)] +pub struct InsertUser { + pub email: String, + pub hash: String, + pub role: String, + pub first_name: String, + pub last_name: String, +} + +impl InsertUser { + pub fn insert(user: Self) -> Result { + let mut conn = crate::db::connection()?; + let user = diesel::insert_into(users::table) + .values(user) + .get_result(&mut conn)?; + Ok(user) + } +} diff --git a/service/src/db/users/routes.rs b/service/src/auth/routes.rs similarity index 55% rename from service/src/db/users/routes.rs rename to service/src/auth/routes.rs index a3bcdb9..43ce2a1 100644 --- a/service/src/db/users/routes.rs +++ b/service/src/auth/routes.rs @@ -1,8 +1,10 @@ -use actix_identity::Identity; -use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError, HttpMessage}; +use actix_web::{get, post, web, HttpResponse, ResponseError}; +use actix_web_httpauth::middleware::HttpAuthentication; use siren::ServiceError; -use crate::db::users::{LoginAuth, RegisterUser, InsertUser, QueryUser, verify, LoggedUser}; +use crate::auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, create_token, LoginResponse}; + +use super::validator; #[post("/register")] async fn register(user: web::Json) -> HttpResponse { @@ -27,25 +29,19 @@ async fn register(user: web::Json) -> HttpResponse { } #[post("/login")] -async fn login(req: HttpRequest, auth: web::Json) -> HttpResponse { - let email = auth.email.clone(); +async fn login(request: web::Json) -> HttpResponse { + let email = request.email.clone(); let query_user = match QueryUser::get_by_email(&email) { Ok(query_user) => query_user, Err(err) => return ResponseError::error_response(&err) }; let hash = query_user.hash; - let password = auth.password.as_bytes(); - match verify(&hash, password) { + let password = request.password.as_bytes(); + match verify_password(&hash, password) { Ok(_) => { - let user = LoggedUser { - email: email.clone() - }; - let user_string = serde_json::to_string(&user).unwrap(); - match Identity::login(&req.extensions(), user_string) { - Ok(_) => HttpResponse::Ok().finish(), - Err(err) => return ResponseError::error_response(&err) - } + let token = create_token(&email); + HttpResponse::Ok().json(LoginResponse { token }) }, Err(err) => ResponseError::error_response(&ServiceError { status: 401, @@ -55,20 +51,23 @@ async fn login(req: HttpRequest, auth: web::Json) -> HttpResponse { } #[post("/logout")] -async fn logout(identity: Identity) -> HttpResponse { - identity.logout(); +async fn logout() -> HttpResponse { HttpResponse::Ok().finish() } #[get("/ping")] -async fn ping(user: LoggedUser) -> HttpResponse { - HttpResponse::Ok().json(user) +async fn ping() -> HttpResponse { + HttpResponse::Ok().finish() } pub fn init_routes(config: &mut web::ServiceConfig) { - config.service(web::scope("users") + let auth = HttpAuthentication::bearer(validator); + config.service(web::scope("auth") .service(register) .service(login) - .service(logout) - .service(ping)); + .service(web::scope("") + .wrap(auth) + .service(logout) + .service(ping) + )); } \ No newline at end of file diff --git a/service/src/db/mod.rs b/service/src/db/mod.rs index d444418..46c7a38 100644 --- a/service/src/db/mod.rs +++ b/service/src/db/mod.rs @@ -17,7 +17,6 @@ pub mod messages; pub mod options; pub mod races; pub mod spells; -pub mod users; pub mod schema; type Pool = r2d2::Pool>; diff --git a/service/src/db/users/mod.rs b/service/src/db/users/mod.rs deleted file mode 100644 index 6fbb137..0000000 --- a/service/src/db/users/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod model; -mod routes; - -pub use model::*; -pub use routes::init_routes; diff --git a/service/src/db/users/model.rs b/service/src/db/users/model.rs deleted file mode 100644 index 72cb786..0000000 --- a/service/src/db/users/model.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::{future::{ready, Ready, Future}, pin::Pin}; -use actix_identity::Identity; -use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, error::{ErrorUnauthorized, ErrorInternalServerError}}; -use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash}; -use diesel::prelude::*; -use serde::{Serialize, Deserialize}; -use siren::ServiceError; - -use crate::db::schema::users; - -#[derive(Debug, Serialize, Deserialize)] -pub struct RegisterUser { - pub email: String, - pub password: String, - pub first_name: String, - pub last_name: String, -} - -impl RegisterUser { - pub fn convert_to_insert(self) -> Result { - let hash = hash(self.password.as_bytes())?; - Ok(InsertUser { - email: self.email, - hash, - role: "user".to_string(), - first_name: self.first_name, - last_name: self.last_name, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginAuth { - pub email: String, - pub password: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct LoggedUser { - pub email: String -} - -impl FromRequest for LoggedUser { - type Error = ActixError; - // type Future = Ready>; - // type Future = std::pin::Pin>>>; - type Future = Pin>>>; - - fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future { - // if let Ok(identity) = Identity::from_request(req, pl).into_inner() { - // if let Ok(user_json) = identity.id() { - // if let Ok(user) = serde_json::from_str(&user_json) { - // return ready(Ok(user)); - // } - // } - // } - // std::future::ready(Err( - // ActixError::from(ServiceError { - // status: 401, - // message: "Unauthorized".to_string(), - // }) - // )) - let identity = Identity::extract(req).into_inner(); - Box::pin(async move { - process_req_auth_data(identity).await - }) - } -} - -async fn process_req_auth_data(identity: Result) -> Result { - let id = identity - .map_err(|_| ErrorUnauthorized("You are not logged in; 1"))? - .id() - .map_err(|_| ErrorUnauthorized("You are not logged in; 3"))?; - let logged_user = match serde_json::from_str::(&id) { - Ok(user) => user, - Err(err) => return Err(ErrorInternalServerError(err)) - }; - - let user = QueryUser::get_by_email(&logged_user.email) - .map_err(|_| ErrorUnauthorized("You are not logged in; 3"))?; - - Ok(LoggedUser { email: user.email }) -} - -#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)] -#[diesel(table_name = users)] -pub struct QueryUser { - pub email: String, - pub hash: String, - pub role: String, - pub first_name: String, - pub last_name: String, -} - -impl QueryUser { - pub fn get_by_email(email: &str) -> Result { - let mut conn = crate::db::connection()?; - let user = users::table - .filter(users::email.eq(email)) - .first(&mut conn)?; - Ok(user) - } -} - -#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)] -#[diesel(table_name = users)] -pub struct InsertUser { - pub email: String, - pub hash: String, - pub role: String, - pub first_name: String, - pub last_name: String, -} - -impl InsertUser { - pub fn insert(user: Self) -> Result { - let mut conn = crate::db::connection()?; - let user = diesel::insert_into(users::table) - .values(user) - .get_result(&mut conn)?; - Ok(user) - } -} - -// https://github.com/Sirneij/rust-auth/blob/main/backend/src/routes/users/login.rs -// https://dev.to/sirneij/authentication-system-using-rust-actix-web-and-sveltekit-user-registration-580h -// https://github.com/actix/actix-extras/blob/master/actix-session/examples/basic.rs -// maybe https://github.com/actix/actix-extras/blob/master/actix-identity/examples/identity.rs -// https://www.lpalmieri.com/posts/session-based-authentication-in-rust/#3-3-1-postgres - -// pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result { -// let token = credentials.token(); -// println!("{:?}", req); -// match validate_token(token) { -// Ok(res) => { -// if res { -// Ok(req) -// } else { -// Err((ActixError::from(actix_web::error::ErrorUnauthorized("Invalid token")), req)) -// } -// }, -// Err(err) => { -// Err((ActixError::from(actix_web::error::ErrorUnauthorized(err)), req)) -// } -// } -// } - -// fn validate_token(token: &str) -> Result { -// println!("Validating token: {}", token); -// Ok(true) -// } - -pub fn hash(password: &[u8]) -> Result { - let salt = SaltString::generate(&mut OsRng); - Ok(Argon2::default().hash_password(password, &salt)?.to_string()) -} - -pub fn verify(hash: &str, password: &[u8]) -> Result<(), HashError> { - let parsed_hash = PasswordHash::new(hash)?; - Ok(Argon2::default().verify_password(password, &parsed_hash)?) -} diff --git a/service/src/main.rs b/service/src/main.rs index 6bd9601..2aa1c45 100644 --- a/service/src/main.rs +++ b/service/src/main.rs @@ -5,8 +5,6 @@ extern crate diesel_migrations; use std::env; use std::collections::HashSet; use std::sync::Arc; -use actix_identity::IdentityMiddleware; -use actix_session::{SessionMiddleware, storage::{RedisActorSessionStore, CookieSessionStore}, config::BrowserSession}; use log::{error, warn, info}; use serenity::client::Cache; use serenity::framework::StandardFramework; @@ -20,6 +18,7 @@ use crate::bot::{commands::oai::GPTModel, handler::Handler}; use dotenv::dotenv; +mod auth; mod dnd; mod bot; mod db; @@ -113,21 +112,21 @@ async fn main() -> std::io::Result<()> { let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); let server = match HttpServer::new(move || { - let private_key = actix_web::cookie::Key::generate(); + // let private_key = actix_web::cookie::Key::generate(); // let redis_host = env::var("REDIS_HOST").unwrap_or("localhost".to_string()); // let redis_port = env::var("REDIS_PORT").unwrap_or("6379".to_string()); - let session = SessionMiddleware::builder( + // let session = SessionMiddleware::builder( // RedisActorSessionStore::new(format!("{}:{}", redis_host, redis_port)), - CookieSessionStore::default(), - private_key - ) - .session_lifecycle(BrowserSession::default()) - .cookie_name("auth".to_owned()) - .cookie_secure(false) - .cookie_http_only(false) - .cookie_domain(Some("localhost".to_owned())) - .cookie_path("/".to_owned()) - .build(); + // CookieSessionStore::default(), + // private_key + // ) + // .session_lifecycle(BrowserSession::default()) + // .cookie_name("auth".to_owned()) + // .cookie_secure(false) + // .cookie_http_only(false) + // .cookie_domain(Some("localhost".to_owned())) + // .cookie_path("/".to_owned()) + // .build(); let cors = Cors::default() .allow_any_origin() .allow_any_method() @@ -135,13 +134,13 @@ async fn main() -> std::io::Result<()> { .supports_credentials() .max_age(3600); App::new() - .wrap(IdentityMiddleware::default()) - .wrap(session) + // .wrap(IdentityMiddleware::default()) + // .wrap(session) .wrap(cors) .app_data(web::Data::new(Arc::clone(&app_data))) .configure(crate::db::messages::init_routes) .configure(crate::db::spells::init_routes) - .configure(crate::db::users::init_routes) + .configure(crate::auth::init_routes) .configure(crate::bot::api::init_routes) }) .bind(format!("{}:{}", host, port)) {