diff --git a/trifid-api/migrations/20230204185754_magic_links.sql b/trifid-api/migrations/20230204185754_magic_links.sql index 501f361..14d175d 100644 --- a/trifid-api/migrations/20230204185754_magic_links.sql +++ b/trifid-api/migrations/20230204185754_magic_links.sql @@ -1,5 +1,5 @@ CREATE TABLE magic_links ( - id VARCHAR(36) NOT NULL PRIMARY KEY UNIQUE, + id VARCHAR(39) NOT NULL PRIMARY KEY UNIQUE, user_id SERIAL NOT NULL REFERENCES users(id), expires_on INTEGER NOT NULL ); \ No newline at end of file diff --git a/trifid-api/migrations/20230206012409_create_session_tokens.sql b/trifid-api/migrations/20230206012409_create_session_tokens.sql index fbae208..c68dd4a 100644 --- a/trifid-api/migrations/20230206012409_create_session_tokens.sql +++ b/trifid-api/migrations/20230206012409_create_session_tokens.sql @@ -1,5 +1,5 @@ CREATE TABLE session_tokens ( - id VARCHAR(36) NOT NULL PRIMARY KEY, + id VARCHAR(39) NOT NULL PRIMARY KEY, user_id SERIAL NOT NULL REFERENCES users(id), expires_on INTEGER NOT NULL ); \ No newline at end of file diff --git a/trifid-api/migrations/20230206031048_create_auth_tokens.sql b/trifid-api/migrations/20230206031048_create_auth_tokens.sql new file mode 100644 index 0000000..0e023e9 --- /dev/null +++ b/trifid-api/migrations/20230206031048_create_auth_tokens.sql @@ -0,0 +1,5 @@ +CREATE TABLE auth_tokens ( + id VARCHAR(39) NOT NULL PRIMARY KEY, + user_id SERIAL NOT NULL REFERENCES users(id), + session_token VARCHAR(39) NOT NULL REFERENCES session_tokens(id) +); \ No newline at end of file diff --git a/trifid-api/src/auth.rs b/trifid-api/src/auth.rs new file mode 100644 index 0000000..0bae614 --- /dev/null +++ b/trifid-api/src/auth.rs @@ -0,0 +1,124 @@ +use rocket::http::Status; +use rocket::{Request, State}; +use rocket::request::{FromRequest, Outcome}; +use sqlx::PgPool; +use crate::tokens::{validate_auth_token, validate_session_token}; + +pub struct PartialUserInfo { + pub user_id: i32, + pub created_at: i64, + pub email: String, + pub hasTotp: bool +} + +#[derive(Debug)] +pub enum AuthenticationError { + MissingToken, + InvalidToken, + DatabaseError, + RequiresTOTP +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for PartialUserInfo { + type Error = AuthenticationError; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let headers = req.headers(); + + // make sure the bearer token exists + if let Some(authorization) = headers.get_one("Authorization") { + // parse bearer token + let components = authorization.split(' ').collect::>(); + + if components.len() != 2 || components.len() != 3 { + return Outcome::Failure((Status::Unauthorized, AuthenticationError::MissingToken)); + } + + if components[0] != "Bearer" { + return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)); + } + + if components.len() == 2 && components[1].starts_with("st-") { + return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)); + } + + let st: String; + let user_id: i64; + let at: Option; + + match &components[1][..3] { + "st-" => { + // validate session token + st = components[1].to_string(); + match validate_session_token(st.clone(), req.rocket().state().unwrap()).await { + Ok(uid) => user_id = uid, + Err(_) => return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)) + } + }, + _ => return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)) + } + + if components.len() == 3 { + match &components[2][..3] { + "at-" => { + // validate auth token + at = Some(components[2].to_string()); + match validate_auth_token(at.clone().unwrap().clone(), st.clone(), req.rocket().state().unwrap()).await { + Ok(_) => (), + Err(_) => return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)) + } + }, + _ => return Outcome::Failure((Status::Unauthorized, AuthenticationError::InvalidToken)) + } + } else { + at = None; + } + + // this user is 100% valid and authenticated, fetch their info + + let user = match sqlx::query!("SELECT * FROM users WHERE id = $1", user_id.clone() as i32).fetch_one(req.rocket().state().unwrap()).await { + Ok(u) => u, + Err(_) => return Outcome::Failure((Status::InternalServerError, AuthenticationError::DatabaseError)) + }; + + Outcome::Success(PartialUserInfo { + user_id: user_id as i32, + created_at: user.created_on as i64, + email: user.email, + hasTotp: at.is_some(), + }) + } else { + Outcome::Failure((Status::Unauthorized, AuthenticationError::MissingToken)) + } + } +} + +pub struct TOTPAuthenticatedUserInfo { + pub user_id: i32, + pub created_at: i64, + pub email: String, +} +#[rocket::async_trait] +impl<'r> FromRequest<'r> for TOTPAuthenticatedUserInfo { + type Error = AuthenticationError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let userinfo = PartialUserInfo::from_request(request).await; + match userinfo { + Outcome::Failure(e) => Outcome::Failure(e), + Outcome::Forward(f) => Outcome::Forward(f), + Outcome::Success(s) => { + if s.hasTotp { + Outcome::Success(Self { + user_id: s.user_id, + created_at: s.created_at, + email: s.email, + }) + } else { + Outcome::Failure((Status::Unauthorized, AuthenticationError::RequiresTOTP)) + } + } + } + } +} \ No newline at end of file diff --git a/trifid-api/src/main.rs b/trifid-api/src/main.rs index c65f9cf..f0f029b 100644 --- a/trifid-api/src/main.rs +++ b/trifid-api/src/main.rs @@ -14,6 +14,7 @@ pub mod db; pub mod config; pub mod tokens; pub mod routes; +pub mod auth; static MIGRATOR: Migrator = sqlx::migrate!(); diff --git a/trifid-api/src/tokens.rs b/trifid-api/src/tokens.rs index a27588a..b06dc98 100644 --- a/trifid-api/src/tokens.rs +++ b/trifid-api/src/tokens.rs @@ -8,7 +8,7 @@ use std::time::UNIX_EPOCH; // https://admin.defined.net/auth/magic-link?email=coredoescode%40gmail.com&token=ml-ckBsgw_5IdK5VYgseBYcoV_v_cQjtdq1re_RhDu_MKg pub async fn send_magic_link(id: i64, email: String, db: &PgPool, config: &TFConfig) -> Result<(), Box> { - let otp = Uuid::new_v4().to_string(); + let otp = format!("ml-{}", Uuid::new_v4().to_string()); let otp_url = config.base.join(&format!("/auth/magic-link?email={}&token={}", urlencoding::encode(&email.clone()), otp.clone())).unwrap(); sqlx::query!("INSERT INTO magic_links (id, user_id, expires_on) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;", otp, id as i32, SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i32 + config.magic_links_valid_for as i32).execute(db).await?; // TODO: send email @@ -16,8 +16,22 @@ pub async fn send_magic_link(id: i64, email: String, db: &PgPool, config: &TFCon Ok(()) } -pub async fn generate_session_token(id: i64, db: &PgPool, config: &TFConfig) -> Result> { - let token = Uuid::new_v4().to_string(); - sqlx::query!("INSERT INTO session_tokens (id, user_id, expires_on) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;", token, id as i32, SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i32 + config.session_tokens_valid_for as i32).execute(db).await?; +pub async fn generate_session_token(user_id: i64, db: &PgPool, config: &TFConfig) -> Result> { + let token = format!("st-{}", Uuid::new_v4()); + sqlx::query!("INSERT INTO session_tokens (id, user_id, expires_on) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;", token, user_id as i32, SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i32 + config.session_tokens_valid_for as i32).execute(db).await?; Ok(token) +} +pub async fn validate_session_token(token: String, db: &PgPool) -> Result> { + Ok(sqlx::query!("SELECT user_id FROM session_tokens WHERE id = $1 AND expires_on < $2", token, SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i32).fetch_one(db).await?.user_id as i64) +} + +pub async fn generate_auth_token(user_id: i64, session_id: String, db: &PgPool, config: &TFConfig) -> Result> { + let token = format!("at-{}", Uuid::new_v4()); + sqlx::query!("INSERT INTO auth_tokens (id, session_token, user_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING;", token, session_id, user_id as i32).execute(db).await?; + Ok(token) +} +pub async fn validate_auth_token(token: String, session_id: String, db: &PgPool) -> Result<(), Box> { + validate_session_token(token.clone(), db).await?; + sqlx::query!("SELECT * FROM auth_tokens WHERE id = $1 AND session_token = $2", token, session_id).fetch_one(db).await?; + Ok(()) } \ No newline at end of file