mirror of
https://github.com/Jackzmc/storage.git
synced 2025-05-08 22:23:21 +00:00
Work on SSO user mapping / creating
This commit is contained in:
parent
a839501168
commit
e5a9ef1b58
10 changed files with 171 additions and 26 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -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]]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod repos;
|
||||
pub mod libraries;
|
||||
pub mod sso;
|
||||
pub mod sso;
|
||||
pub mod user;
|
|
@ -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;
|
||||
}
|
||||
|
|
106
src/managers/user.rs
Normal file
106
src/managers/user.rs
Normal file
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<SSOData>) -> 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<Option<UserModel>, 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::<UserModel>()
|
||||
.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<String, anyhow::Error> {
|
||||
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<String, anyhow::Error> {
|
||||
self.create_user(id, user, None).await
|
||||
}
|
||||
async fn create_user(&self, id: String, user: CreateUserOptions, encrypted_password: Option<String>) -> Result<String, anyhow::Error> {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<Option<LibraryModel>, 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)?;
|
||||
|
|
|
@ -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<String>
|
||||
}
|
||||
#[derive(Serialize, Clone, Debug, FromRow)]
|
||||
pub struct UserModelWithPassword {
|
||||
pub id: Uuid,
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub name: String
|
||||
pub name: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -91,7 +91,6 @@ impl UserAuthError {
|
|||
|
||||
|
||||
pub async fn get_user(pool: &DB, user_id: &str) -> Result<Option<UserModel>, 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<UserModel, UserAuthError> {
|
||||
let encrypted_pass = bcrypt::hash(user.password, ENCRYPTION_ROUNDS)
|
||||
.map_err(|e| UserAuthError::EncryptionError(e))?;
|
||||
todo!()
|
||||
}
|
|
@ -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<SSOState>, ip: IpAddr, return_to: Option<String>) -> Result<Redirect, anyhow::Error> {
|
||||
|
@ -51,7 +53,7 @@ pub async fn page(ip: IpAddr, sso: &State<SSOState>, return_to: Option<String>)
|
|||
})))
|
||||
}
|
||||
|
||||
async fn callback_handler(sso: &State<SSOState>, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, Option<String>), anyhow::Error> {
|
||||
async fn callback_handler(sso: &State<SSOState>, ip: IpAddr, code: String, state: String) -> Result<(CoreUserInfoClaims, String, Option<String>), 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<SSOState>, 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?<code>&<state>")]
|
||||
pub async fn callback(session: Session<'_, SessionData>, ip: IpAddr, sso: &State<SSOState>, code: String, state: String) -> Result<HackyRedirectBecauseRocketBug, (Status, Template)> {
|
||||
let (userinfo, return_to) = callback_handler(sso, ip, code, state).await
|
||||
pub async fn callback(config: &State<AppConfig>, users: &State<UsersState>, ip: IpAddr, sso: &State<SSOState>, code: String, state: String) -> Result<HackyRedirectBecauseRocketBug, (Status, Template)> {
|
||||
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(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue