diff --git a/_migrations/2022-02-16-072149_api_tokens/up.sql b/_migrations/2022-02-16-072149_api_tokens/up.sql index df5aaeb247..985d54072f 100644 --- a/_migrations/2022-02-16-072149_api_tokens/up.sql +++ b/_migrations/2022-02-16-072149_api_tokens/up.sql @@ -7,6 +7,10 @@ CREATE TABLE api_tokens ( -- Not used for anything except replication. id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + -- Idempotency token from client + -- This is so the frontend client doesn't submit duplicate jobs. + uuid_idempotency_token VARCHAR(36) NOT NULL, + -- This is the secret API token value api_token VARCHAR(32) NOT NULL, @@ -24,7 +28,9 @@ CREATE TABLE api_tokens ( -- INDICES -- PRIMARY KEY (id), UNIQUE KEY (api_token), + UNIQUE KEY (uuid_idempotency_token), KEY fk_user_token (user_token), + KEY index_created_at (created_at), KEY index_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/lib/database_queries/src/api_tokens/create_api_token.rs b/crates/lib/database_queries/src/api_tokens/create_api_token.rs new file mode 100644 index 0000000000..98b92f5c8c --- /dev/null +++ b/crates/lib/database_queries/src/api_tokens/create_api_token.rs @@ -0,0 +1,85 @@ +use container_common::anyhow_result::AnyhowResult; +use sqlx::MySqlPool; +use crate::tokens::Tokens; +use sqlx; +use anyhow::anyhow; +use crate::api_tokens::list_available_api_tokens_for_user::list_available_api_tokens_for_user; + +/// Create a new API token for the user. +/// If the user has more than five tokens, delete the least recent. +pub async fn create_api_token_for_user( + user_token: &str, + uuid_idempotency_token: &str, + mysql_pool: &MySqlPool +) -> AnyhowResult { + + // TODO: Get last 5 API tokens for the user. + // TODO: Database transaction. + + let api_token = Tokens::new_api_token()?; + + let query = sqlx::query!( + r#" +INSERT INTO api_tokens +SET + api_token = ?, + user_token = ?, + uuid_idempotency_token = ? + "#, + api_token, + user_token, + uuid_idempotency_token + ); + + let query_result = query.execute(mysql_pool).await; + + let _record_id = match query_result { + Ok(res) => res.last_insert_id(), + Err(err) => return Err(anyhow!("Error creating API token: {:?}", err)), + }; + + // Delete older tokens. + let valid_api_tokens= list_available_api_tokens_for_user(user_token, mysql_pool).await?; + + let valid_api_tokens = valid_api_tokens.into_iter() + .map(|r| r.api_token) + .collect::>(); + + // TODO: THis is a massive hack since SQLx doesn't support binding Vec + // This array's elements will be replaced with any valid tokens. + let mut api_tokens = [ + api_token.as_str(), + api_token.as_str(), + api_token.as_str(), + api_token.as_str(), + api_token.as_str(), + api_token.as_str(), // One extra copy of the latest token. + ]; + + for i in 0..5 { + if let Some(token) = valid_api_tokens.get(i).map(|s| s.as_str()) { + api_tokens[i] = token; + } + } + + let query = sqlx::query!( + r#" +UPDATE api_tokens +SET + deleted_at = CURRENT_TIMESTAMP +WHERE + user_token = ? +AND api_token NOT IN (?, ?, ?, ?, ?) + "#, + user_token, + &api_tokens[0], + &api_tokens[1], + &api_tokens[2], + &api_tokens[3], + &api_tokens[4], + ); + + let _query_result = query.execute(mysql_pool).await?; + + Ok(api_token) +} \ No newline at end of file diff --git a/crates/lib/database_queries/src/api_tokens/list_available_api_tokens_for_user.rs b/crates/lib/database_queries/src/api_tokens/list_available_api_tokens_for_user.rs new file mode 100644 index 0000000000..83b7f38a74 --- /dev/null +++ b/crates/lib/database_queries/src/api_tokens/list_available_api_tokens_for_user.rs @@ -0,0 +1,70 @@ +use anyhow::anyhow; +use chrono::{Utc, DateTime}; +use container_common::anyhow_result::AnyhowResult; +use crate::column_types::job_status::JobStatus; +use crate::column_types::record_visibility::RecordVisibility; +use crate::helpers::boolean_converters::i8_to_bool; +use crate::tts::tts_inference_jobs::_keys::TtsInferenceJobId; +use log::{warn, info}; +use sqlx::MySqlPool; + +/// table: api_tokens +#[derive(Debug)] +pub struct AvailableApiToken { + pub api_token: String, + pub maybe_short_description: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// Query available (non-deleted) API tokens for a user. +/// A user can only have five active tokens at a time, so we only return the five most recent. +pub async fn list_available_api_tokens_for_user( + user_token: &str, + pool: &MySqlPool, +) -> AnyhowResult> { + + let records : Vec = sqlx::query_as!( + AvailableApiTokenInternal, + r#" +SELECT + api_token, + maybe_short_description, + created_at, + updated_at +FROM api_tokens +WHERE + user_token = ? + AND deleted_at IS NULL +ORDER BY created_at DESC +LIMIT 5 + "#, + user_token, + ) + .fetch_all(pool) + .await?; + + let mut records = records.into_iter() + .map(|record : AvailableApiTokenInternal | { + AvailableApiToken { + api_token: record.api_token, + maybe_short_description: record.maybe_short_description, + created_at: record.created_at, + updated_at: record.updated_at, + } + }) + .collect::>(); + + // Queried in DESC order, but sort returned results ordered ASC. + records.sort_by_key(|r| r.created_at); + + Ok(records) +} + +#[derive(Debug)] +struct AvailableApiTokenInternal { + pub api_token: String, + pub maybe_short_description: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} diff --git a/crates/lib/database_queries/src/api_tokens/mod.rs b/crates/lib/database_queries/src/api_tokens/mod.rs new file mode 100644 index 0000000000..30fa2f5281 --- /dev/null +++ b/crates/lib/database_queries/src/api_tokens/mod.rs @@ -0,0 +1,2 @@ +pub mod create_api_token; +pub mod list_available_api_tokens_for_user; \ No newline at end of file diff --git a/crates/lib/database_queries/src/lib.rs b/crates/lib/database_queries/src/lib.rs index a85062e2bc..57b7a39eda 100644 --- a/crates/lib/database_queries/src/lib.rs +++ b/crates/lib/database_queries/src/lib.rs @@ -2,6 +2,7 @@ mod builders; +pub mod api_tokens; pub mod column_types; pub mod helpers; pub mod tokens; diff --git a/crates/lib/database_queries/src/tokens.rs b/crates/lib/database_queries/src/tokens.rs index 1144344c89..8d1b9e9336 100644 --- a/crates/lib/database_queries/src/tokens.rs +++ b/crates/lib/database_queries/src/tokens.rs @@ -25,6 +25,10 @@ impl Tokens { random_prefix_crockford_token("WR:", 32, false) } + pub fn new_api_token() -> AnyhowResult { + random_prefix_crockford_token("API:", 32, true) + } + // ========== Tokens not typically visible to users ========== pub fn new_user() -> AnyhowResult { diff --git a/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/create_api_token.rs b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/create_api_token.rs new file mode 100644 index 0000000000..260c00034d --- /dev/null +++ b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/create_api_token.rs @@ -0,0 +1,124 @@ +use actix_http::Error; +use actix_http::http::header; +use actix_web::HttpResponseBuilder; +use actix_web::cookie::Cookie; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::{Path, Json}; +use actix_web::{Responder, web, HttpResponse, error, HttpRequest}; +use crate::database::enums::record_visibility::RecordVisibility; +use crate::database::queries::query_w2l_template::select_w2l_template_by_token; +use crate::http_server::web_utils::ip_address::get_request_ip; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; +use crate::server_state::ServerState; +use crate::util::email_to_gravatar::email_to_gravatar; +use crate::util::markdown_to_html::markdown_to_html; +use database_queries::tokens::Tokens; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use log::{info, warn, log}; +use regex::Regex; +use sqlx::MySqlPool; +use sqlx::error::DatabaseError; +use sqlx::error::Error::Database; +use sqlx::mysql::MySqlDatabaseError; +use std::fmt; +use std::sync::Arc; +use database_queries::api_tokens::create_api_token::create_api_token_for_user; + +// =============== Request =============== + +#[derive(Deserialize)] +pub struct CreateApiTokenRequest { + pub idempotency_token: String, +} + +// =============== Success Response =============== + +#[derive(Serialize)] +pub struct CreateApiTokenResponse { + pub success: bool, + pub api_token: String, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize)] +pub enum CreateApiTokenError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for CreateApiTokenError { + fn status_code(&self) -> StatusCode { + match *self { + CreateApiTokenError::BadInput(_) => StatusCode::BAD_REQUEST, + CreateApiTokenError::NotAuthorized => StatusCode::UNAUTHORIZED, + CreateApiTokenError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using DeriveMore since Clion doesn't understand it. +impl fmt::Display for CreateApiTokenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + +pub async fn create_api_token_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data>) -> Result +{ + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + CreateApiTokenError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(CreateApiTokenError::NotAuthorized); + } + }; + + if user_session.is_banned { + warn!("banned users cannot create API tokens"); + return Err(CreateApiTokenError::NotAuthorized); + } + + let api_token = create_api_token_for_user( + &user_session.user_token, + &request.idempotency_token, + &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("API token creation errror: {:?}", e); + CreateApiTokenError::ServerError + })?; + + let response = CreateApiTokenResponse { + success: true, + api_token + }; + + let body = serde_json::to_string(&response) + .map_err(|e| CreateApiTokenError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/list_api_tokens.rs b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/list_api_tokens.rs new file mode 100644 index 0000000000..80d99fc231 --- /dev/null +++ b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/list_api_tokens.rs @@ -0,0 +1,133 @@ +use actix_http::Error; +use actix_http::http::header; +use actix_web::HttpResponseBuilder; +use actix_web::cookie::Cookie; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::{Path, Json}; +use actix_web::{Responder, web, HttpResponse, error, HttpRequest}; +use chrono::{DateTime, Utc}; +use crate::database::enums::record_visibility::RecordVisibility; +use crate::database::queries::query_w2l_template::select_w2l_template_by_token; +use crate::database::query_builders::list_categories_query_builder::ListCategoriesQueryBuilder; +use crate::http_server::web_utils::ip_address::get_request_ip; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::response_success_helpers::simple_json_success; +use crate::server_state::ServerState; +use crate::util::email_to_gravatar::email_to_gravatar; +use crate::util::markdown_to_html::markdown_to_html; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use lexical_sort::natural_lexical_cmp; +use log::{info, warn, log}; +use regex::Regex; +use sqlx::MySqlPool; +use sqlx::error::DatabaseError; +use sqlx::error::Error::Database; +use sqlx::mysql::MySqlDatabaseError; +use std::fmt; +use std::sync::Arc; +use database_queries::api_tokens::list_available_api_tokens_for_user::list_available_api_tokens_for_user; + +// =============== Success Response =============== + +#[derive(Serialize)] +pub struct ListApiTokensResponse { + pub success: bool, + pub api_tokens: Vec, +} + +/// Public-facing and optimized (non-irrelevant fields) +#[derive(Serialize)] +pub struct ApiToken { + pub api_token: String, + pub maybe_short_description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// =============== Error Response =============== + +#[derive(Debug, Serialize)] +pub enum ListApiTokensError { + NotAuthorized, + ServerError, +} + +impl ResponseError for ListApiTokensError { + fn status_code(&self) -> StatusCode { + match *self { + ListApiTokensError::NotAuthorized => StatusCode::UNAUTHORIZED, + ListApiTokensError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for ListApiTokensError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +// =============== Handler =============== + +pub async fn list_api_tokens_handler( + http_request: HttpRequest, + server_state: web::Data>) -> Result +{ + let maybe_user_session = server_state + .session_checker + .maybe_get_user_session(&http_request, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("Session checker error: {:?}", e); + ListApiTokensError::ServerError + })?; + + let user_session = match maybe_user_session { + Some(session) => session, + None => { + warn!("not logged in"); + return Err(ListApiTokensError::NotAuthorized); + } + }; + + if user_session.is_banned { + warn!("banned users cannot use API tokens"); + return Err(ListApiTokensError::NotAuthorized); + } + + let api_tokens = list_available_api_tokens_for_user(&user_session.user_token, &server_state.mysql_pool) + .await + .map_err(|e| { + warn!("API token query error: {:?}", e); + ListApiTokensError::ServerError + })?; + + let mut api_tokens = api_tokens.into_iter() + .map(|r| { + ApiToken { + api_token: r.api_token, + maybe_short_description: r.maybe_short_description, + created_at: r.created_at, + updated_at: r.updated_at, + } + }) + .collect::>(); + + let response = ListApiTokensResponse { + success: true, + api_tokens, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ListApiTokensError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/mod.rs b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/mod.rs new file mode 100644 index 0000000000..866920efd9 --- /dev/null +++ b/crates/service/storyteller_web/src/http_server/endpoints/api_tokens/mod.rs @@ -0,0 +1,2 @@ +pub mod create_api_token; +pub mod list_api_tokens; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/storyteller_web/src/http_server/endpoints/mod.rs index 4be42e802b..e779339a76 100644 --- a/crates/service/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/storyteller_web/src/http_server/endpoints/mod.rs @@ -1,3 +1,4 @@ +pub mod api_tokens; pub mod categories; pub mod events; pub mod leaderboard; diff --git a/crates/service/storyteller_web/src/routes.rs b/crates/service/storyteller_web/src/routes.rs index cee0183f37..49b461c4b0 100644 --- a/crates/service/storyteller_web/src/routes.rs +++ b/crates/service/storyteller_web/src/routes.rs @@ -71,6 +71,8 @@ use crate::http_server::endpoints::w2l::get_w2l_upload_template_job_status::get_ use crate::http_server::endpoints::w2l::list_w2l_templates::list_w2l_templates_handler; use crate::http_server::endpoints::w2l::set_w2l_template_mod_approval::set_w2l_template_mod_approval_handler; use crate::http_server::endpoints::misc::detect_locale_handler::detect_locale_handler; +use crate::http_server::endpoints::api_tokens::create_api_token::create_api_token_handler; +use crate::http_server::endpoints::api_tokens::list_api_tokens::list_api_tokens_handler; pub fn add_routes (app: App) -> App where @@ -88,6 +90,7 @@ pub fn add_routes (app: App) -> App app = add_w2l_routes(app); /* /w2l */ app = add_category_routes(app); /* /category */ app = add_user_profile_routes(app); /* /user */ + app = add_api_token_routes(app); /* /api_tokens */ app = add_twitch_oauth_routes(app); /* /twitch */ // TODO: MAYBE TEMPORARY // ==================== ACCOUNT CREATION / SESSION MANAGEMENT ==================== @@ -528,6 +531,32 @@ fn add_user_profile_routes (app: App) -> App ) ) } + +// ==================== API TOKEN ROUTES ==================== + +fn add_api_token_routes (app: App) -> App + where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service(web::scope("/api_tokens") + .service(web::resource("/create") + .route(web::post().to(create_api_token_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + .service(web::resource("/list") + .route(web::get().to(list_api_tokens_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) +} + // ==================== TWITCH ROUTES ==================== // TODO: Maybe move these into an "oauth-gateway" type http service.