diff --git a/service/.env.TEMPLATE b/service/.env.TEMPLATE index 6d1af0b..e28243e 100644 --- a/service/.env.TEMPLATE +++ b/service/.env.TEMPLATE @@ -6,7 +6,15 @@ DATABASE_NAME=siren DATABASE_HOST=localhost DATABASE_PORT=5432 -JWT_SECRET= +ACCESS_TOKEN_PRIVATE_KEY= +ACCESS_TOKEN_PUBLIC_KEY= +ACCESS_TOKEN_EXPIRED_IN=15m +ACCESS_TOKEN_MAXAGE=15 + +REFRESH_TOKEN_PRIVATE_KEY= +REFRESH_TOKEN_PUBLIC_KEY= +REFRESH_TOKEN_EXPIRED_IN=60m +REFRESH_TOKEN_MAXAGE=60 REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/service/Cargo.toml b/service/Cargo.toml index dd001db..474b911 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -27,6 +27,8 @@ lazy_static = "1.4.0" uuid = { version = "1.4.1", features = ["serde", "v4"] } argon2 = "0.5.2" jsonwebtoken = "9.0.0" +redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] } +base64 = "0.21.4" [dependencies.tokio] version = "1.32.0" diff --git a/service/src/auth/mod.rs b/service/src/auth/mod.rs index fa0d527..18bff39 100644 --- a/service/src/auth/mod.rs +++ b/service/src/auth/mod.rs @@ -1,71 +1,68 @@ -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 base64::{engine::general_purpose, Engine as _}; 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; +use siren::ServiceError; #[derive(Debug, Serialize, Deserialize)] -struct Claims { - sub: String, - exp: usize, +struct TokenClaims { + sub: String, // Subject + token_uuid: String, // Issuer + exp: i64, // Expiration time + iat: i64, // Issued At + nbf: i64 // Not Before } -// 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)) - } - } +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenDetails { + pub token: Option, + pub token_uuid: uuid::Uuid, + pub email: String, + pub expires_in: Option } -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() - }) - } - } +// https://codevoweb.com/rust-actix-web-jwt-access-and-refresh-tokens/ +// https://github.com/wpcodevo/rust-jwt-rs256/blob/master/src/main.rs + +pub fn verify_token(token: &str, public_key: &str) -> Result { + let bytes_public_key = general_purpose::STANDARD.decode(public_key).unwrap(); + let decoded_public_key = String::from_utf8(bytes_public_key).unwrap(); + let key = DecodingKey::from_rsa_pem(decoded_public_key.as_bytes())?; + let validation = Validation::new(Algorithm::RS256); + let decoded = decode::(token, &key, &validation)?; + let email = decoded.claims.sub; + let token_uuid = uuid::Uuid::parse_str(decoded.claims.token_uuid.as_str()).unwrap(); + Ok(TokenDetails { token: None, token_uuid, email, expires_in: None }) } -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, +pub fn generate_token(email: &str, ttl: i64, private_key: &str) -> Result { + let now = chrono::Utc::now(); + let mut token_details = TokenDetails { + token: None, + token_uuid: uuid::Uuid::new_v4(), + email: email.to_string(), + expires_in: Some((now + chrono::Duration::minutes(ttl)).timestamp()) }; - let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(jwt_secret.as_ref())).unwrap(); - token + let claims = TokenClaims { + sub: token_details.email.clone(), + token_uuid: token_details.token_uuid.to_string(), + exp: token_details.expires_in.unwrap(), + iat: now.timestamp(), + nbf: now.timestamp() + }; + let header = Header::new(Algorithm::RS256); + let bytes_private_key = general_purpose::STANDARD.decode(private_key).unwrap(); + let decoded_private_key = String::from_utf8(bytes_private_key).unwrap(); + let key = EncodingKey::from_rsa_pem(decoded_private_key.as_bytes())?; + let token = encode(&header, &claims, &key)?; + token_details.token = Some(token); + Ok(token_details) } pub fn hash_password(password: &[u8]) -> Result { diff --git a/service/src/auth/model.rs b/service/src/auth/model.rs index b67c28a..54dafae 100644 --- a/service/src/auth/model.rs +++ b/service/src/auth/model.rs @@ -1,10 +1,14 @@ +use std::{future::{ready, Ready}, env}; +use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload, http}; use diesel::prelude::*; +use log::error; +use redis::Commands; use serde::{Serialize, Deserialize}; use siren::ServiceError; use crate::db::schema::users; -use super::hash_password; +use super::{hash_password, verify_token}; #[derive(Debug, Serialize, Deserialize)] pub struct RegisterUser { @@ -33,11 +37,6 @@ pub struct LoginRequest { 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 { @@ -79,3 +78,75 @@ impl InsertUser { Ok(user) } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtAuth { + pub access_token_uuid: uuid::Uuid +} + +impl FromRequest for JwtAuth { + type Error = ActixError; + type Future = Ready>; + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let access_token = match req + .cookie("access_token") + .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(token) => token, + None => return ready(Err(ActixError::from(ServiceError { + status: 401, + message: "Unauthorized".to_string() + }))) + }; + + let public_key = env::var("ACCESS_TOKEN_PUBLIC_KEY") + .expect("ACCESS_TOKEN_PUBLIC_KEY must be set"); + + let access_token_details = match verify_token(&access_token, &public_key) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to verify access token: {}", err); + return ready(Err(ActixError::from(ServiceError { + status: 401, + message: format!("Failed to verify access token: {}", err) + }))) + } + }; + + let access_token_uuid = uuid::Uuid::parse_str(&access_token_details.token_uuid.to_string()).unwrap(); + + let mut conn = match crate::db::redis_connection() { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ready(Err(ActixError::from(ServiceError { + status: 500, + message: format!("Failed to get redis connection: {}", err) + }))) + } + }; + let user_email = match conn.get::<_, String>(access_token_uuid.clone().to_string()) { + Ok(result) => result, + Err(err) => { + error!("Failed to get access token from redis: {}", err); + return ready(Err(ActixError::from(ServiceError { + status: 500, + message: format!("Failed to get access token from redis: {}", err) + }))) + } + }; + + match QueryUser::get_by_email(&user_email) { + Ok(_) => { + ready(Ok(JwtAuth { access_token_uuid })) + } + Err(err) => return ready(Err(ActixError::from(ServiceError { + status: 500, + message: format!("Failed to get user from db: {}", err) + }))) + } + } +} diff --git a/service/src/auth/routes.rs b/service/src/auth/routes.rs index 43ce2a1..284ef4e 100644 --- a/service/src/auth/routes.rs +++ b/service/src/auth/routes.rs @@ -1,10 +1,11 @@ -use actix_web::{get, post, web, HttpResponse, ResponseError}; -use actix_web_httpauth::middleware::HttpAuthentication; +use std::env; + +use actix_web::{get, post, web, HttpResponse, ResponseError, cookie::{Cookie, time::Duration}, HttpRequest}; +use log::error; +use redis::AsyncCommands; use siren::ServiceError; -use crate::auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, create_token, LoginResponse}; - -use super::validator; +use crate::{auth::{LoginRequest, RegisterUser, InsertUser, QueryUser, verify_password, generate_token, JwtAuth}, db}; #[post("/register")] async fn register(user: web::Json) -> HttpResponse { @@ -40,8 +41,80 @@ async fn login(request: web::Json) -> HttpResponse { let password = request.password.as_bytes(); match verify_password(&hash, password) { Ok(_) => { - let token = create_token(&email); - HttpResponse::Ok().json(LoginResponse { token }) + let access_token_max_age = env::var("ACCESS_TOKEN_MAXAGE") + .expect("ACCESS_TOKEN_MAXAGE must be set") + .parse::() + .expect("ACCESS_TOKEN_MAXAGE must be an integer"); + let access_private_key = env::var("ACCESS_TOKEN_PRIVATE_KEY") + .expect("ACCESS_TOKEN_PRIVATE_KEY must be set"); + let access_token_details = match generate_token(&email, access_token_max_age, &access_private_key) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate access token: {}", err); + return ResponseError::error_response(&err) + } + }; + let refresh_token_max_age = env::var("REFRESH_TOKEN_MAXAGE") + .expect("REFRESH_TOKEN_MAXAGE must be set") + .parse::() + .expect("REFRESH_TOKEN_MAXAGE must be an integer"); + let refresh_private_key = env::var("REFRESH_TOKEN_PRIVATE_KEY") + .expect("REFRESH_TOKEN_PRIVATE_KEY must be set"); + let refresh_token_details = match generate_token(&email, refresh_token_max_age, &refresh_private_key) { + Ok(token_details) => token_details, + Err(err) => { + error!("Failed to generate refresh token: {}", err); + return ResponseError::error_response(&err) + } + }; + + let mut conn = match db::redis_async_connection().await { + Ok(conn) => conn, + Err(err) => { + error!("Failed to get redis connection: {}", err); + return ResponseError::error_response(&err) + } + }; + + let access_result: redis::RedisResult<()> = conn.set_ex(access_token_details.token_uuid.to_string(), &email, (access_token_max_age * 60) as usize).await; + if let Err(err) = access_result { + error!("Failed to set access token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set access token in redis: {}", err) + }) + }; + + let refresh_result: redis::RedisResult<()> = conn.set_ex(refresh_token_details.token_uuid.to_string(), &email, (refresh_token_max_age * 60) as usize).await; + if let Err(err) = refresh_result { + error!("Failed to set refresh token in redis: {}", err); + return ResponseError::error_response(&ServiceError { + status: 500, + message: format!("Failed to set refresh token in redis: {}", err) + }) + }; + + let access_cookie = Cookie::build("access_token", access_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(access_token_max_age, 0)) + .http_only(true) + .finish(); + let refresh_cookie = Cookie::build("refresh_token", refresh_token_details.token.clone().unwrap()) + .path("/") + .max_age(Duration::new(refresh_token_max_age, 0)) + .http_only(true) + .finish(); + let logged_in_cookie = Cookie::build("logged_in", "true") + .path("/") + .max_age(Duration::new(access_token_max_age, 0)) + .http_only(false) + .finish(); + + HttpResponse::Ok() + .cookie(access_cookie) + .cookie(refresh_cookie) + .cookie(logged_in_cookie) + .json(access_token_details.token.unwrap()) }, Err(err) => ResponseError::error_response(&ServiceError { status: 401, @@ -50,24 +123,27 @@ async fn login(request: web::Json) -> HttpResponse { } } -#[post("/logout")] -async fn logout() -> HttpResponse { +#[get("/refresh")] +async fn refresh(req: HttpRequest) -> HttpResponse { HttpResponse::Ok().finish() } -#[get("/ping")] -async fn ping() -> HttpResponse { +#[post("/logout")] +async fn logout(req: HttpRequest, auth: JwtAuth) -> HttpResponse { HttpResponse::Ok().finish() } +#[get("/me")] +async fn me(auth: JwtAuth) -> HttpResponse { + HttpResponse::Ok().json(auth) +} + pub fn init_routes(config: &mut web::ServiceConfig) { - let auth = HttpAuthentication::bearer(validator); config.service(web::scope("auth") .service(register) .service(login) - .service(web::scope("") - .wrap(auth) - .service(logout) - .service(ping) - )); + .service(refresh) + .service(logout) + .service(me) + ); } \ No newline at end of file diff --git a/service/src/db/mod.rs b/service/src/db/mod.rs index 46c7a38..96b1ac4 100644 --- a/service/src/db/mod.rs +++ b/service/src/db/mod.rs @@ -1,4 +1,6 @@ -use diesel::{r2d2::ConnectionManager, PgConnection}; +use diesel::{r2d2::ConnectionManager as DieselConnectionManager, PgConnection}; +// use redis::{aio::{Connection as RedisConnection, ConnectionManager as RedisConnectionManager}, AsyncCommands}; +use redis::aio::Connection as RedisConnection; use siren::ServiceError; use crate::diesel_migrations::MigrationHarness; use lazy_static::lazy_static; @@ -19,22 +21,31 @@ pub mod races; pub mod spells; pub mod schema; -type Pool = r2d2::Pool>; -pub type DbConnection = r2d2::PooledConnection>; +type DbPool = r2d2::Pool>; +pub type DbConnection = r2d2::PooledConnection>; +// type RedisPool = r2d2::Pool; pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = embed_migrations!(); lazy_static! { - static ref POOL: Pool = { + static ref POOL: DbPool = { let username = env::var("DATABASE_USER").expect("DATABASE_USERNAME is not set"); let password = env::var("DATABASE_PASSWORD").expect("DATABASE_PASSWORD is not set"); let host = env::var("DATABASE_HOST").unwrap_or("localhost".to_string()); let name = env::var("DATABASE_NAME").expect("DATABASE_NAME is not set"); let port = env::var("DATABASE_PORT").unwrap_or("5432".to_string()); let url = format!("postgres://{}:{}@{}:{}/{}", username, password, host, port, name); - let manager = ConnectionManager::::new(url); - Pool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool") + let manager = DieselConnectionManager::::new(url); + DbPool::builder().test_on_check_out(true).build(manager).expect("Failed to create db pool") }; + // static ref REDIS_POOL: RedisPool = { + // let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string()); + // let port = env::var("REDIS_PORT").unwrap_or("6379".to_string()); + // let url = format!("redis://{}:{}", host, port); + // let client = redis::Client::open(url).expect("Failed to create redis client"); + // let manager = RedisConnectionManager::new(client); + // "".to_string() + // }; } pub fn init() { @@ -51,6 +62,26 @@ pub fn connection() -> Result { .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) } +pub fn redis_client() -> Result { + let host = env::var("REDIS_HOST").unwrap_or("localhost".to_string()); + let port = env::var("REDIS_PORT").unwrap_or("6379".to_string()); + let url = format!("redis://{}:{}", host, port); + let client = redis::Client::open(url)?; + Ok(client) +} + +pub fn redis_connection() -> Result { + let client = redis_client()?; + let conn = client.get_connection()?; + Ok(conn) +} + +pub async fn redis_async_connection() -> Result { + let client = redis_client()?; + let conn = client.get_async_connection().await?; + Ok(conn) +} + pub fn load_data(data_dir_path: &str) { spells::load_data(data_dir_path); } diff --git a/service/src/lib.rs b/service/src/lib.rs index 9dadc22..67645ec 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -100,6 +100,18 @@ impl From for ServiceError { } } +impl From for ServiceError { + fn from(error: jsonwebtoken::errors::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown jsonwebtoken error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: redis::RedisError) -> ServiceError { + ServiceError::new(500, format!("Unknown redis error: {}", error)) + } +} + impl ResponseError for ServiceError { fn error_response(&self) -> HttpResponse { let status_code = match StatusCode::from_u16(self.status) {