API tokens migration, queries, and handlers (list, create)

This commit is contained in:
Brandon Thomas committed 2022-02-16 03:59:38 -05:00
1 parent 23e6f552e7
commit 653c282f07
11 files changed
+457

No files matched your search

@@ -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;
@@ -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<String> {
// 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::<Vec<String>>();
// TODO: THis is a massive hack since SQLx doesn't support binding Vec<T>
// 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)
}
@@ -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<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
/// 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<Vec<AvailableApiToken>> {
let records : Vec<AvailableApiTokenInternal> = 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::<Vec<AvailableApiToken>>();
// 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<String>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
}
@@ -0,0 +1,2 @@
pub mod create_api_token;
pub mod list_available_api_tokens_for_user;
+1
View File
@@ -2,6 +2,7 @@
mod builders;
pub mod api_tokens;
pub mod column_types;
pub mod helpers;
pub mod tokens;
@@ -25,6 +25,10 @@ impl Tokens {
random_prefix_crockford_token("WR:", 32, false)
}
pub fn new_api_token() -> AnyhowResult<String> {
random_prefix_crockford_token("API:", 32, true)
}
// ========== Tokens not typically visible to users ==========
pub fn new_user() -> AnyhowResult<String> {
@@ -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<CreateApiTokenRequest>,
server_state: web::Data<Arc<ServerState>>) -> Result<HttpResponse, CreateApiTokenError>
{
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))
}
@@ -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<ApiToken>,
}
/// Public-facing and optimized (non-irrelevant fields)
#[derive(Serialize)]
pub struct ApiToken {
pub api_token: String,
pub maybe_short_description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// =============== 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<Arc<ServerState>>) -> Result<HttpResponse, ListApiTokensError>
{
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::<Vec<ApiToken>>();
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))
}
@@ -0,0 +1,2 @@
pub mod create_api_token;
pub mod list_api_tokens;
@@ -1,3 +1,4 @@
pub mod api_tokens;
pub mod categories;
pub mod events;
pub mod leaderboard;
@@ -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<T, B> (app: App<T, B>) -> App<T, B>
where
@@ -88,6 +90,7 @@ pub fn add_routes<T, B> (app: App<T, B>) -> App<T, B>
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<T, B> (app: App<T, B>) -> App<T, B>
)
)
}
// ==================== API TOKEN ROUTES ====================
fn add_api_token_routes<T, B> (app: App<T, B>) -> App<T, B>
where
B: MessageBody,
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse<B>,
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.