From e5a9ef1b589a8399ac70cc4d215e4e3927367573 Mon Sep 17 00:00:00 2001 From: Jackz Date: Mon, 21 Apr 2025 08:57:23 -0500 Subject: [PATCH] Work on SSO user mapping / creating --- Cargo.lock | 7 +++ Cargo.toml | 2 +- config.sample.toml | 1 + src/main.rs | 5 ++ src/managers.rs | 3 +- src/managers/sso.rs | 4 ++ src/managers/user.rs | 106 ++++++++++++++++++++++++++++++++++++++ src/models/library.rs | 5 +- src/models/user.rs | 21 ++------ src/routes/ui/auth/sso.rs | 43 ++++++++++++++-- 10 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 src/managers/user.rs diff --git a/Cargo.lock b/Cargo.lock index f4bdb66..ea6e826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2948,6 +2948,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.8" @@ -3807,6 +3813,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", "serde", + "sha1_smol", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 61551a7..85eab2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ dotenvy = "0.15.7" rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] } humanize-bytes = "1.0.6" rocket-session-store = "0.2.1" -uuid = { version = "1.16.0", features = ["v4"] } +uuid = { version = "1.16.0", features = ["v4", "v5"] } rand = { version = "0.9.0", features = ["thread_rng"] } bcrypt = "0.17.0" openidconnect = "4.0.0" diff --git a/config.sample.toml b/config.sample.toml index 478d66e..ccfe194 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -26,6 +26,7 @@ claims = ["email", "profile"] # Should an account be created if SSO user id doesn't exist already create_account = true # Should normal login (username/email+pass) be disabled, forcing users to use sso? +# If enabled and disable_registration is enabled, the login page will redirect to SSO page directly disable_normal_login = false [smtp] diff --git a/src/main.rs b/src/main.rs index aa4426b..7fc2d41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ use routes::api; use crate::config::{get_settings, AppConfig}; use crate::consts::{init_statics, SESSION_COOKIE_NAME, SESSION_LIFETIME_SECONDS}; use crate::managers::sso::{SSOState, SSO}; +use crate::managers::user::UsersState; use crate::models::user::UserModel; use crate::routes::ui; @@ -115,6 +116,10 @@ async fn rocket() -> _ { let sso: SSOState = { if settings.auth.oidc.is_some() { Some(Arc::new(Mutex::new(SSO::create(&settings).await)) ) } else { None } }; + let users: UsersState = { + // TODO: somehow need to get store + UsersState::new(pool.clone()) + }; let figment = rocket::Config::figment() .merge(("port", listen_addr.port())) diff --git a/src/managers.rs b/src/managers.rs index a43655c..ae2a320 100644 --- a/src/managers.rs +++ b/src/managers.rs @@ -1,3 +1,4 @@ pub mod repos; pub mod libraries; -pub mod sso; \ No newline at end of file +pub mod sso; +pub mod user; \ No newline at end of file diff --git a/src/managers/sso.rs b/src/managers/sso.rs index 25cf97c..2382a6e 100644 --- a/src/managers/sso.rs +++ b/src/managers/sso.rs @@ -125,6 +125,10 @@ impl SSO { self.scopes.iter().map(|c| Scope::new(c.to_string())).collect() } + pub fn provider_id(&self) -> String { + self.issuer_url.url().domain().expect("issuer has no domain").to_string() + } + pub async fn cache_set(&mut self, ip: IpAddr, data: SSOSessionData) { self.cache.insert(ip, data).await; } diff --git a/src/managers/user.rs b/src/managers/user.rs new file mode 100644 index 0000000..d91caa3 --- /dev/null +++ b/src/managers/user.rs @@ -0,0 +1,106 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; +use anyhow::anyhow; +use rocket::futures::TryStreamExt; +use rocket::serde::Serialize; +use rocket::State; +use rocket_session_store::{Session, SessionStore, Store}; +use rocket_session_store::memory::MemoryStore; +use sqlx::{query, query_as, Pool, QueryBuilder}; +use uuid::Uuid; +use crate::config::AppConfig; +use crate::consts::ENCRYPTION_ROUNDS; +use crate::{SessionData, DB}; +use crate::models::user::{UserAuthError, UserModel}; + +pub struct UserManager { + pool: DB, +} + +#[derive(Debug, Serialize)] +pub struct CreateUserOptions { + pub email: String, + pub username: String, + pub name: Option, +} + +pub type UsersState = UserManager; + + +pub enum FindUserOption { + Id(String), + Email(String), + Username(String), +} + +#[derive(Hash)] +pub struct SSOData { + pub provider_id: String, + pub(crate) sub: String +} + +impl UserManager { + pub fn new(pool: DB) -> Self { + Self { + pool, + } + } + pub fn generate_id(sso_data: Option) -> String { + if let Some(sso_data) = sso_data { + let mut s = DefaultHasher::new(); + sso_data.hash(&mut s); + format!("{:x}", s.finish()) + } else { + uuid::Uuid::new_v4().to_string() + } + } + + pub async fn fetch_user(&self, search_options: &[FindUserOption]) -> Result, anyhow::Error> { + if search_options.is_empty() { return Err(anyhow!("At least one search option must be included"))} + let mut query = QueryBuilder::new("select id, username, password, created_at, email, name from storage.users where "); + for option in search_options { + match option { + FindUserOption::Id(id) => { + query.push("id = $1"); + query.push_bind(id); + }, + FindUserOption::Email(email) => { + query.push("email = $1"); + query.push_bind(email); + } + FindUserOption::Username(username) => { + query.push("username = $1"); + query.push_bind(username); + } + }; + } + query.build_query_as::() + .fetch_optional(&self.pool) + .await + .map_err(|e| anyhow!(e)) + } + /// Returns user's id + pub async fn create_normal_user(&self, user: CreateUserOptions, plain_password: String) -> Result { + let password = bcrypt::hash(plain_password, ENCRYPTION_ROUNDS) + .map_err(|e| anyhow!(e))?; + let id = Self::generate_id(None); + self.create_user(id, user, Some(password)).await + } + /// Returns user's id + pub async fn create_sso_user(&self, user: CreateUserOptions, id: String) -> Result { + self.create_user(id, user, None).await + } + async fn create_user(&self, id: String, user: CreateUserOptions, encrypted_password: Option) -> Result { + query!( + "INSERT INTO storage.users (id, name, password, email, username) VALUES ($1, $2, $3, $4, $5)", + id, + user.name, + encrypted_password, + user.email, + user.username + ) + .execute(&self.pool) + .await?; + Ok(id) + } +} \ No newline at end of file diff --git a/src/models/library.rs b/src/models/library.rs index 4a68636..c251e32 100644 --- a/src/models/library.rs +++ b/src/models/library.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use anyhow::anyhow; use chrono::NaiveDateTime; use rocket::serde::{Serialize, Deserialize}; @@ -13,7 +14,7 @@ use crate::user::User; #[derive(Debug, Serialize, Deserialize)] pub struct LibraryModel { pub id: Uuid, - pub owner_id: Uuid, + pub owner_id: String, pub repo_id: String, pub created_at: NaiveDateTime, pub name: String, @@ -26,7 +27,7 @@ pub struct LibraryWithRepoModel { } pub async fn get_library(pool: &DB, library_id: &str) -> Result, anyhow::Error> { - let library_id = Uuid::parse_str(library_id)?; + let library_id = Uuid::from_str(library_id)?; let library = query_as!(LibraryModel, "select * from storage.libraries where id = $1", library_id) .fetch_optional(pool) .await.map_err(anyhow::Error::from)?; diff --git a/src/models/user.rs b/src/models/user.rs index de413c8..b2e2e63 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -17,20 +17,20 @@ use crate::util::JsonErrorResponse; #[derive(Serialize, Clone, Debug, FromRow)] pub struct UserModel { - pub id: Uuid, + pub id: String, pub username: String, pub email: String, pub created_at: NaiveDateTime, - pub name: String + pub name: Option } #[derive(Serialize, Clone, Debug, FromRow)] pub struct UserModelWithPassword { - pub id: Uuid, + pub id: String, pub username: String, pub email: String, pub password: Option, pub created_at: NaiveDateTime, - pub name: String + pub name: Option } #[derive(Debug)] @@ -91,7 +91,6 @@ impl UserAuthError { pub async fn get_user(pool: &DB, user_id: &str) -> Result, anyhow::Error> { - let user_id = Uuid::parse_str(user_id)?; query_as!(UserModel, "select id, username, created_at, email, name from storage.users where id = $1", user_id) .fetch_optional(pool) .await.map_err(anyhow::Error::from) @@ -135,16 +134,4 @@ pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> } } Err(UserAuthError::PasswordInvalid) -} - -pub struct CreateUserModel { - pub username: String, - pub email: String, - pub password: String, - pub name: String -} -pub async fn create_user(pool: &DB, user: CreateUserModel) -> Result { - let encrypted_pass = bcrypt::hash(user.password, ENCRYPTION_ROUNDS) - .map_err(|e| UserAuthError::EncryptionError(e))?; - todo!() } \ No newline at end of file diff --git a/src/routes/ui/auth/sso.rs b/src/routes/ui/auth/sso.rs index 2934bdb..0f88a18 100644 --- a/src/routes/ui/auth/sso.rs +++ b/src/routes/ui/auth/sso.rs @@ -17,7 +17,9 @@ use reqwest::header::HeaderMap; use rocket::http::{Header, Status}; use rocket_dyn_templates::{context, Template}; use tokio::sync::MutexGuard; +use crate::config::AppConfig; use crate::managers::sso::{SSOSessionData, SSOState, SSO}; +use crate::managers::user::{CreateUserOptions, FindUserOption, SSOData, UserManager, UsersState}; use crate::routes::ui::auth::HackyRedirectBecauseRocketBug; async fn page_handler(sso: &State, ip: IpAddr, return_to: Option) -> Result { @@ -51,7 +53,7 @@ pub async fn page(ip: IpAddr, sso: &State, return_to: Option) }))) } -async fn callback_handler(sso: &State, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, Option), anyhow::Error> { +async fn callback_handler(sso: &State, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, String, Option), anyhow::Error> { let mut sso = sso.as_ref().ok_or_else(||anyhow!("SSO is not configured"))?.lock().await; let sess_data = sso.cache_take(ip).await.ok_or_else(|| anyhow!("No valid sso started"))?; if &state != sess_data.csrf_token.secret() { @@ -95,17 +97,48 @@ async fn callback_handler(sso: &State, ip: IpAddr, code: String, state .request_async(sso.http_client()) .await .map_err(|_| anyhow!("could not acquire user data"))?; - Ok((userinfo, sess_data.return_to)) + Ok((userinfo, sso.provider_id(), sess_data.return_to)) } #[get("/auth/sso/cb?&")] -pub async fn callback(session: Session<'_, SessionData>, ip: IpAddr, sso: &State, code: String, state: String) -> Result { - let (userinfo, return_to) = callback_handler(sso, ip, code, state).await +pub async fn callback(config: &State, users: &State, ip: IpAddr, sso: &State, code: String, state: String) -> Result { + let (userinfo, provider_id, return_to) = callback_handler(sso, ip, code, state).await .map_err(|e| (Status::InternalServerError, Template::render("errors/500", context! { error: e.to_string() })))?; + // Setup search for existing users: + // TODO: own method? + let sso_data = SSOData { + provider_id, + sub: userinfo.subject().to_string() + }; + let uid = UserManager::generate_id(Some(sso_data)); + let email = userinfo.email().ok_or_else(||(Status::InternalServerError, Template::render("errors/500", context! { + error: "Provider did not provide an email" + })))?.to_string(); + let username = userinfo.preferred_username().ok_or_else(||(Status::InternalServerError, Template::render("errors/500", context! { + error: "Provider did not provide an username" + })))?.to_string(); + let search_options = vec![FindUserOption::Id(uid.clone()), FindUserOption::Email(email.clone()), FindUserOption::Username(username.clone())]; + let user = users.fetch_user(&search_options).await.map_err(|e|(Status::InternalServerError, Template::render("errors/500", context! { + error: format!("Failed to find user: {}", e) + })))?; + debug!("existing user = {:?}", user); + if user.is_none() { + if config.auth.oidc.as_ref().unwrap().create_account { + return Err((Status::InternalServerError, Template::render("errors/403", context! { + error: "No account found linked to oidc provider and account creation has been disabled" + }))); + } + let id = users.create_sso_user(CreateUserOptions { + email, + username, + name: userinfo.name().unwrap().get(None).map(|s| s.to_string()), + }, uid).await.expect("later i fix"); + debug!("new user = {}", id); + } debug!("user={:?}\nemail={:?}\nname={:?}", userinfo.subject(), userinfo.email(), userinfo.name()); - // TODO: rest of user login, map to existing user / create user, etc blah blah + // TODO: login user to session, prob through UserManager/users let return_to = return_to.unwrap_or("/".to_string()); Ok(HackyRedirectBecauseRocketBug { inner: "Login successful, redirecting...".to_string(),