diff --git a/app/src/pages/ApiTokens.tsx b/app/src/pages/ApiTokens.tsx index ffd0734..488ec43 100644 --- a/app/src/pages/ApiTokens.tsx +++ b/app/src/pages/ApiTokens.tsx @@ -3,6 +3,8 @@ import { useApiTokens } from '../features/tokens/hooks/useApiTokens'; import { useIsMobile } from '../features/toolbar/hooks/useIsMobile'; import { Button, TextField } from '@mui/material'; import TokenCard from '../features/tokens/components/TokenCard'; +import { useGithubAuth } from '../features/toolbar/hooks/useGithubAuth'; +import { useNavigate } from 'react-router-dom'; function ApiTokens() { const [tokenName, setTokenName] = React.useState(''); diff --git a/src/api/api_token.rs b/src/api/api_token.rs index b26215c..3ed5e0f 100644 --- a/src/api/api_token.rs +++ b/src/api/api_token.rs @@ -10,8 +10,8 @@ pub struct Token { pub token: Option, } -impl From for Token { - fn from(token: models::Token) -> Self { +impl From for Token { + fn from(token: models::ApiToken) -> Self { Token { id: token.id.to_string(), name: token.friendly_name, diff --git a/src/api/mod.rs b/src/api/mod.rs index ba79a06..cae4c6a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod api_token; pub mod auth; +pub mod publish; use rocket::{ http::Status, diff --git a/src/api/publish.rs b/src/api/publish.rs new file mode 100644 index 0000000..336b585 --- /dev/null +++ b/src/api/publish.rs @@ -0,0 +1,8 @@ +use rocket::serde::Deserialize; + +/// The publish request. +#[derive(Deserialize, Debug)] +pub struct PublishRequest { + pub name: String, + pub version: String, +} diff --git a/src/db/api_token.rs b/src/db/api_token.rs index f1696b1..30b04e0 100644 --- a/src/db/api_token.rs +++ b/src/db/api_token.rs @@ -15,6 +15,12 @@ const TOKEN_LENGTH: usize = 32; #[derive(Debug)] pub struct PlainToken(String); +impl Default for PlainToken { + fn default() -> Self { + Self::new() + } +} + impl PlainToken { pub fn hash(&self) -> Vec { Sha256::digest(self.0.as_bytes()).as_slice().to_vec() @@ -33,9 +39,15 @@ impl PlainToken { } } -impl Into for PlainToken { - fn into(self) -> String { - self.0 +impl From for PlainToken { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From for String { + fn from(val: PlainToken) -> Self { + val.0 } } @@ -45,11 +57,11 @@ impl DbConn { &mut self, user_id: Uuid, friendly_name: String, - ) -> Result<(models::Token, PlainToken), DatabaseError> { + ) -> Result<(models::ApiToken, PlainToken), DatabaseError> { let plain_token = PlainToken::new(); let token = plain_token.hash(); - let new_token = models::NewToken { + let new_token = models::NewApiToken { user_id, friendly_name, token, @@ -59,7 +71,7 @@ impl DbConn { // Insert new session let saved_token = diesel::insert_into(schema::api_tokens::table) .values(&new_token) - .returning(models::Token::as_returning()) + .returning(models::ApiToken::as_returning()) .get_result(self.inner()) .map_err(|_| DatabaseError::InsertTokenFailed(user_id.to_string()))?; @@ -85,11 +97,24 @@ impl DbConn { pub fn get_tokens_for_user( &mut self, user_id: Uuid, - ) -> Result, DatabaseError> { + ) -> Result, DatabaseError> { schema::api_tokens::table .filter(schema::api_tokens::user_id.eq(user_id)) - .select(models::Token::as_returning()) + .select(models::ApiToken::as_returning()) .load(self.inner()) .map_err(|_| DatabaseError::NotFound(user_id.to_string())) } + + /// Fetch an API token given the plaintext token. + pub fn get_token( + &mut self, + plain_token: PlainToken, + ) -> Result { + let hashed = plain_token.hash(); + schema::api_tokens::table + .filter(schema::api_tokens::token.eq(hashed)) + .select(models::ApiToken::as_returning()) + .first::(self.inner()) + .map_err(|_| DatabaseError::NotFound("API Token".to_string())) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index fddabf2..6824487 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,4 @@ -mod api_token; +pub mod api_token; pub mod error; mod user_session; diff --git a/src/main.rs b/src/main.rs index 4478824..8603d02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,18 +4,17 @@ extern crate rocket; use forc_pub::api::api_token::{CreateTokenRequest, CreateTokenResponse, Token, TokensResponse}; +use forc_pub::api::publish::PublishRequest; use forc_pub::api::{ auth::{LoginRequest, LoginResponse, UserResponse}, ApiResult, EmptyResponse, }; -use forc_pub::middleware::cors::Cors; - use forc_pub::db::Database; use forc_pub::github::handle_login; +use forc_pub::middleware::cors::Cors; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; - +use forc_pub::middleware::token_auth::TokenAuth; use rocket::http::{Cookie, CookieJar}; - use rocket::{serde::json::Json, State}; #[derive(Default)] @@ -86,6 +85,16 @@ fn tokens(db: &State, auth: SessionAuth) -> ApiResult })) } +#[post("/publish", data = "")] +fn publish(request: Json, auth: TokenAuth) -> ApiResult { + println!( + "Publishing: {:?} for token: {:?}", + request, auth.token.friendly_name + ); + + Ok(Json(EmptyResponse)) +} + /// Catches all OPTION requests in order to get the CORS related Fairing triggered. #[options("/<_..>")] fn all_options() { @@ -118,6 +127,7 @@ fn rocket() -> _ { user, new_token, delete_token, + publish, tokens, all_options, health diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index c67a8ad..ff75b40 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -1,2 +1,3 @@ pub mod cors; pub mod session_auth; +pub mod token_auth; diff --git a/src/middleware/token_auth.rs b/src/middleware/token_auth.rs new file mode 100644 index 0000000..ac65e91 --- /dev/null +++ b/src/middleware/token_auth.rs @@ -0,0 +1,52 @@ +use crate::db::api_token::PlainToken; +use crate::db::Database; +use crate::models; +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; + + + +pub const SESSION_COOKIE_NAME: &str = "session"; + +pub struct TokenAuth { + pub token: models::ApiToken, +} + +#[derive(Debug)] +pub enum TokenAuthError { + Missing, + Invalid, + DatabaseConnection, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for TokenAuth { + type Error = TokenAuthError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + // TODO: use fairing for db connection? + // let db = try_outcome!(request.guard::().await); + + let mut db = match request.rocket().state::() { + Some(db) => db.conn(), + None => { + return Outcome::Failure(( + Status::InternalServerError, + TokenAuthError::DatabaseConnection, + )) + } + }; + + if let Some(auth_header) = request.headers().get_one("Authorization") { + if auth_header.starts_with("Bearer ") { + let token = auth_header.trim_start_matches("Bearer "); + if let Ok(token) = db.get_token(PlainToken::from(token.to_string())) { + return Outcome::Success(TokenAuth { token }); + } + } + return Outcome::Failure((Status::Unauthorized, TokenAuthError::Invalid)); + } + return Outcome::Failure((Status::Unauthorized, TokenAuthError::Missing)); + } +} diff --git a/src/models.rs b/src/models.rs index f821e51..d6cbdd6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -44,10 +44,10 @@ pub struct NewSession { pub expires_at: SystemTime, } -#[derive(Queryable, Selectable, Debug)] +#[derive(Queryable, Selectable, Debug, PartialEq, Eq)] #[diesel(table_name = crate::schema::api_tokens)] #[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Token { +pub struct ApiToken { pub id: Uuid, pub user_id: Uuid, pub friendly_name: String, @@ -57,7 +57,7 @@ pub struct Token { #[derive(Insertable)] #[diesel(table_name = crate::schema::api_tokens)] -pub struct NewToken { +pub struct NewApiToken { pub user_id: Uuid, pub friendly_name: String, pub token: Vec, diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 7e47b6f..0481ffa 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -83,31 +83,43 @@ fn test_user_sessions() { fn test_api_tokens() { let db = &mut Database::default().conn(); - let session = db.insert_user_session(&mock_user_1(), 1000).expect("result is ok"); + let session = db + .insert_user_session(&mock_user_1(), 1000) + .expect("result is ok"); let user = db.get_user_for_session(session.id).expect("result is ok"); // Insert tokens - let (token1, plain_token1) = db.new_token(user.id, TEST_TOKEN_NAME_1.into()).expect("result is ok"); - let (token2, plain_token2) = db.new_token(user.id, TEST_TOKEN_NAME_2.into()).expect("result is ok"); + let (token1, plain_token1) = db + .new_token(user.id, TEST_TOKEN_NAME_1.into()) + .expect("result is ok"); + let (token2, plain_token2) = db + .new_token(user.id, TEST_TOKEN_NAME_2.into()) + .expect("result is ok"); assert_eq!(token1.friendly_name, TEST_TOKEN_NAME_1); assert_eq!(token1.expires_at, None); assert_eq!(token2.friendly_name, TEST_TOKEN_NAME_2); assert_eq!(token2.expires_at, None); + // Test token hashing + assert_eq!(token1, db.get_token(plain_token1).expect("test token 1")); + assert_eq!(token2, db.get_token(plain_token2).expect("test token 2")); + // Get tokens let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 2); // Delete tokens - let _ = db.delete_token(user.id, token1.id.into()).expect("result is ok"); + db + .delete_token(user.id, token1.id.into()) + .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 1); - let _ = db.delete_token(user.id, token2.id.into()).expect("result is ok"); + db + .delete_token(user.id, token2.id.into()) + .expect("result is ok"); let tokens = db.get_tokens_for_user(user.id).expect("result is ok"); assert_eq!(tokens.len(), 0); - // TODO: test validating a plain token - clear_tables(db); -} \ No newline at end of file +}