mirror of
https://github.com/Jackzmc/storage.git
synced 2025-05-05 21:53:21 +00:00
Compare commits
5 commits
e13f080d91
...
3f222dfd3c
Author | SHA1 | Date | |
---|---|---|---|
3f222dfd3c | |||
fe17ca5633 | |||
e5a9ef1b58 | |||
a839501168 | |||
8f69de989b |
15 changed files with 280 additions and 64 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -2948,6 +2948,12 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.8"
|
version = "0.10.8"
|
||||||
|
@ -3807,6 +3813,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.2",
|
"getrandom 0.3.2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sha1_smol",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -20,7 +20,7 @@ dotenvy = "0.15.7"
|
||||||
rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] }
|
rocket_dyn_templates = { version = "0.2.0", features = ["handlebars"] }
|
||||||
humanize-bytes = "1.0.6"
|
humanize-bytes = "1.0.6"
|
||||||
rocket-session-store = "0.2.1"
|
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"] }
|
rand = { version = "0.9.0", features = ["thread_rng"] }
|
||||||
bcrypt = "0.17.0"
|
bcrypt = "0.17.0"
|
||||||
openidconnect = "4.0.0"
|
openidconnect = "4.0.0"
|
||||||
|
|
15
README.md
15
README.md
|
@ -33,7 +33,12 @@ _The current files list when logged in_
|
||||||
git clone https://github.com/jackzmc/storage.git
|
git clone https://github.com/jackzmc/storage.git
|
||||||
cd storage
|
cd storage
|
||||||
|
|
||||||
# Configure your database (create .env file with your PostgreSQL connection)
|
# Copy the sample config
|
||||||
|
cp config.sample.toml config.toml
|
||||||
|
# Edit the config.toml or provide the equivalant settings with env
|
||||||
|
# ex: [auth.oidc] ---> STORAGE_auth.oidc.issuer__url
|
||||||
|
# issuer-url
|
||||||
|
# Configure your database (requires to be set by env for now)
|
||||||
echo "DATABASE_URL=postgres://username:password@localhost" > .env
|
echo "DATABASE_URL=postgres://username:password@localhost" > .env
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
|
@ -52,12 +57,18 @@ Rough roadmap in a rough order of priority
|
||||||
|
|
||||||
* [ ] WebDAV Support
|
* [ ] WebDAV Support
|
||||||
* [ ] Email support (for password resets, user invites)
|
* [ ] Email support (for password resets, user invites)
|
||||||
|
* [ ] Email sender utility
|
||||||
|
* [ ] Individual email actions
|
||||||
* [ ] SSO Support (openid)
|
* [ ] SSO Support (openid)
|
||||||
|
* [x] Basic implementation
|
||||||
|
* [ ] User mapping
|
||||||
|
* [ ] User creation
|
||||||
|
* [ ] User logout
|
||||||
|
* [ ] S3 backend support
|
||||||
* [ ] Administration panel
|
* [ ] Administration panel
|
||||||
* [ ] Add storage backends
|
* [ ] Add storage backends
|
||||||
* [ ] Manage users
|
* [ ] Manage users
|
||||||
* [ ] Change app settings
|
* [ ] Change app settings
|
||||||
* [ ] S3 backend support
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ claims = ["email", "profile"]
|
||||||
# Should an account be created if SSO user id doesn't exist already
|
# Should an account be created if SSO user id doesn't exist already
|
||||||
create_account = true
|
create_account = true
|
||||||
# Should normal login (username/email+pass) be disabled, forcing users to use sso?
|
# 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
|
disable_normal_login = false
|
||||||
|
|
||||||
[smtp]
|
[smtp]
|
||||||
|
|
|
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||||
use std::env::var;
|
use std::env::var;
|
||||||
use figment::Figment;
|
use figment::Figment;
|
||||||
use figment::providers::{Env, Format, Toml};
|
use figment::providers::{Env, Format, Toml};
|
||||||
use log::error;
|
use log::{debug, error};
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||||
use openidconnect::IssuerUrl;
|
use openidconnect::IssuerUrl;
|
||||||
use openidconnect::url::Url;
|
use openidconnect::url::Url;
|
||||||
|
@ -55,6 +55,12 @@ pub struct AuthConfig {
|
||||||
pub oidc: Option<OidcConfig>,
|
pub oidc: Option<OidcConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AuthConfig {
|
||||||
|
pub fn oidc_enabled(&self) -> bool {
|
||||||
|
self.oidc.as_ref().map(|o| o.enabled).unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct OidcConfig {
|
pub struct OidcConfig {
|
||||||
|
|
|
@ -27,6 +27,7 @@ use routes::api;
|
||||||
use crate::config::{get_settings, AppConfig};
|
use crate::config::{get_settings, AppConfig};
|
||||||
use crate::consts::{init_statics, SESSION_COOKIE_NAME, SESSION_LIFETIME_SECONDS};
|
use crate::consts::{init_statics, SESSION_COOKIE_NAME, SESSION_LIFETIME_SECONDS};
|
||||||
use crate::managers::sso::{SSOState, SSO};
|
use crate::managers::sso::{SSOState, SSO};
|
||||||
|
use crate::managers::user::UsersState;
|
||||||
use crate::models::user::UserModel;
|
use crate::models::user::UserModel;
|
||||||
use crate::routes::ui;
|
use crate::routes::ui;
|
||||||
|
|
||||||
|
@ -115,6 +116,10 @@ async fn rocket() -> _ {
|
||||||
let sso: SSOState = {
|
let sso: SSOState = {
|
||||||
if settings.auth.oidc.is_some() { Some(Arc::new(Mutex::new(SSO::create(&settings).await)) ) } else { None }
|
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()
|
let figment = rocket::Config::figment()
|
||||||
.merge(("port", listen_addr.port()))
|
.merge(("port", listen_addr.port()))
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
pub mod libraries;
|
pub mod libraries;
|
||||||
pub mod sso;
|
pub mod sso;
|
||||||
|
pub mod user;
|
|
@ -3,7 +3,7 @@ use std::net::IpAddr;
|
||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use log::warn;
|
use log::{info, warn};
|
||||||
use moka::future::Cache;
|
use moka::future::Cache;
|
||||||
use openidconnect::core::{CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenResponse};
|
use openidconnect::core::{CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenResponse};
|
||||||
use openidconnect::http::{HeaderMap, HeaderValue};
|
use openidconnect::http::{HeaderMap, HeaderValue};
|
||||||
|
@ -40,7 +40,8 @@ impl SSO {
|
||||||
pub async fn create(config: &AppConfig) -> Self {
|
pub async fn create(config: &AppConfig) -> Self {
|
||||||
let oidc_config = config.auth.oidc.as_ref().expect("OIDC config not provided");
|
let oidc_config = config.auth.oidc.as_ref().expect("OIDC config not provided");
|
||||||
let referer = config.general.get_public_url().domain().map(|s| s.to_string());
|
let referer = config.general.get_public_url().domain().map(|s| s.to_string());
|
||||||
let http_client = SSO::setup_http_client(referer, None);
|
let proxy_settings = SSO::setup_proxy();
|
||||||
|
let http_client = SSO::setup_http_client(referer, proxy_settings);
|
||||||
let issuer_url = IssuerUrl::new(oidc_config.issuer_url.to_string()).expect("bad issuer url");
|
let issuer_url = IssuerUrl::new(oidc_config.issuer_url.to_string()).expect("bad issuer url");
|
||||||
let client_id = ClientId::new(oidc_config.client_id.to_string());
|
let client_id = ClientId::new(oidc_config.client_id.to_string());
|
||||||
let client_secret = Some(ClientSecret::new(oidc_config.client_secret.to_string()));
|
let client_secret = Some(ClientSecret::new(oidc_config.client_secret.to_string()));
|
||||||
|
@ -63,6 +64,16 @@ impl SSO {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_proxy() -> Option<HttpProxySettings> {
|
||||||
|
if let Ok(proxy_url) = var("DEV_PROXY_URL") {
|
||||||
|
return Some(HttpProxySettings {
|
||||||
|
url: proxy_url,
|
||||||
|
disable_cert_check: var("DEV_PROXY_DANGER_DISABLE_CERT_CHECK").is_ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_http_client(referer: Option<String>, proxy_settings: Option<HttpProxySettings>) -> reqwest::Client {
|
fn setup_http_client(referer: Option<String>, proxy_settings: Option<HttpProxySettings>) -> reqwest::Client {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
// TODO: pull from config.
|
// TODO: pull from config.
|
||||||
|
@ -75,7 +86,10 @@ impl SSO {
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.default_headers(headers);
|
.default_headers(headers);
|
||||||
if let Some(proxy) = proxy_settings {
|
if let Some(proxy) = proxy_settings {
|
||||||
warn!("DANGER_DEV_PROXY set, requests are being proxied & ignoring certificates");
|
info!("Using proxy url: {}", proxy.url);
|
||||||
|
if proxy.disable_cert_check {
|
||||||
|
warn!("!! DEV_PROXY_DANGER_DISABLE_CERT_CHECK is set: requests are proxied, ignoring certificates");
|
||||||
|
}
|
||||||
builder = builder
|
builder = builder
|
||||||
.proxy(reqwest::Proxy::https(proxy.url).unwrap())
|
.proxy(reqwest::Proxy::https(proxy.url).unwrap())
|
||||||
.danger_accept_invalid_certs(proxy.disable_cert_check);
|
.danger_accept_invalid_certs(proxy.disable_cert_check);
|
||||||
|
@ -111,6 +125,10 @@ impl SSO {
|
||||||
self.scopes.iter().map(|c| Scope::new(c.to_string())).collect()
|
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) {
|
pub async fn cache_set(&mut self, ip: IpAddr, data: SSOSessionData) {
|
||||||
self.cache.insert(ip, data).await;
|
self.cache.insert(ip, data).await;
|
||||||
}
|
}
|
||||||
|
|
149
src/managers/user.rs
Normal file
149
src/managers/user.rs
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
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::{DISABLE_LOGIN_CHECK, ENCRYPTION_ROUNDS};
|
||||||
|
use crate::{LoginSessionData, SessionData, DB};
|
||||||
|
use crate::models::user::{UserAuthError, UserModel, UserModelWithPassword};
|
||||||
|
|
||||||
|
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(crate) 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<UserModel, 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<UserModel, anyhow::Error> {
|
||||||
|
self.create_user(id, user, None).await
|
||||||
|
}
|
||||||
|
async fn create_user(&self, id: String, user: CreateUserOptions, encrypted_password: Option<String>) -> Result<UserModel, 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(UserModel {
|
||||||
|
id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
created_at: Default::default(),
|
||||||
|
name: user.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_user_session(&self, user: UserModel, ip_address: IpAddr, sessions: &Session<'_, SessionData>) {
|
||||||
|
sessions.set(SessionData {
|
||||||
|
csrf_token: None,
|
||||||
|
login: Some(LoginSessionData {
|
||||||
|
user,
|
||||||
|
ip_address
|
||||||
|
}),
|
||||||
|
}).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_normal_user(&self, email_or_usrname: &str, password: &str, ip: IpAddr, session: &Session<'_, SessionData>) -> Result<UserModel, UserAuthError> {
|
||||||
|
let user = query_as!(UserModelWithPassword,
|
||||||
|
"select id, username, password, created_at, email, name from storage.users where email = $1 OR username = $1", email_or_usrname
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserAuthError::DatabaseError(e))?;
|
||||||
|
let Some(user) = user else {
|
||||||
|
return Err(UserAuthError::UserNotFound);
|
||||||
|
};
|
||||||
|
if let Some(db_password) = user.password {
|
||||||
|
if !*DISABLE_LOGIN_CHECK || bcrypt::verify(password, &db_password).map_err(|e| UserAuthError::EncryptionError(e))? {
|
||||||
|
let model = UserModel {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
created_at: user.created_at,
|
||||||
|
name: user.name
|
||||||
|
};
|
||||||
|
self.login_user_session(model.clone(), ip, session).await;
|
||||||
|
return Ok(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(UserAuthError::PasswordInvalid)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::str::FromStr;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use rocket::serde::{Serialize, Deserialize};
|
use rocket::serde::{Serialize, Deserialize};
|
||||||
|
@ -13,7 +14,7 @@ use crate::user::User;
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LibraryModel {
|
pub struct LibraryModel {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: String,
|
||||||
pub repo_id: String,
|
pub repo_id: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub name: String,
|
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> {
|
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)
|
let library = query_as!(LibraryModel, "select * from storage.libraries where id = $1", library_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await.map_err(anyhow::Error::from)?;
|
.await.map_err(anyhow::Error::from)?;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::net::IpAddr;
|
||||||
use bcrypt::BcryptError;
|
use bcrypt::BcryptError;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use rocket::form::Context;
|
use rocket::form::Context;
|
||||||
|
@ -9,28 +10,30 @@ use rocket::form::error::Entity;
|
||||||
use rocket::response::Responder;
|
use rocket::response::Responder;
|
||||||
use rocket::serde::Serialize;
|
use rocket::serde::Serialize;
|
||||||
use rocket::serde::uuid::Uuid;
|
use rocket::serde::uuid::Uuid;
|
||||||
|
use rocket_session_store::Session;
|
||||||
use sqlx::{query_as, FromRow};
|
use sqlx::{query_as, FromRow};
|
||||||
use crate::consts::{DISABLE_LOGIN_CHECK, ENCRYPTION_ROUNDS};
|
use crate::consts::{DISABLE_LOGIN_CHECK, ENCRYPTION_ROUNDS};
|
||||||
use crate::{LoginSessionData, SessionData, DB};
|
use crate::{LoginSessionData, SessionData, DB};
|
||||||
|
use crate::managers::user::UsersState;
|
||||||
use crate::models::repo::RepoModel;
|
use crate::models::repo::RepoModel;
|
||||||
use crate::util::JsonErrorResponse;
|
use crate::util::JsonErrorResponse;
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug, FromRow)]
|
#[derive(Serialize, Clone, Debug, FromRow)]
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
pub id: Uuid,
|
pub id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub name: String
|
pub name: Option<String>
|
||||||
}
|
}
|
||||||
#[derive(Serialize, Clone, Debug, FromRow)]
|
#[derive(Serialize, Clone, Debug, FromRow)]
|
||||||
pub struct UserModelWithPassword {
|
pub struct UserModelWithPassword {
|
||||||
pub id: Uuid,
|
pub id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub name: String
|
pub name: Option<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -91,27 +94,15 @@ impl UserAuthError {
|
||||||
|
|
||||||
|
|
||||||
pub async fn get_user(pool: &DB, user_id: &str) -> Result<Option<UserModel>, anyhow::Error> {
|
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)
|
query_as!(UserModel, "select id, username, created_at, email, name from storage.users where id = $1", user_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await.map_err(anyhow::Error::from)
|
.await.map_err(anyhow::Error::from)
|
||||||
}
|
}
|
||||||
/// Validates user login form, returning Some on success or None (with ctx containing errors) on failure
|
/// Validates user login form
|
||||||
pub async fn validate_user_form(ctx: &mut Context<'_>, pool: &DB) -> Option<UserModel> {
|
pub async fn try_login_user_form(ctx: &mut Context<'_>, users: &UsersState, ip: IpAddr, session: &Session<'_, SessionData>) -> Result<UserModel, UserAuthError> {
|
||||||
let username = ctx.field_value("username").unwrap();
|
let username = ctx.field_value("username").unwrap();
|
||||||
let password = ctx.field_value("password").unwrap(); // TODO: no unwrap
|
let password = ctx.field_value("password").unwrap(); // TODO: no unwrap
|
||||||
match validate_user(pool, username, password).await {
|
users.login_normal_user(username, password, ip, session).await
|
||||||
Ok(u) => Some(u),
|
|
||||||
Err(UserAuthError::PasswordInvalid | UserAuthError::UserNotFound) => {
|
|
||||||
ctx.push_error(form::Error::validation("Username or password is incorrect").with_entity(Entity::Form));
|
|
||||||
None
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
ctx.push_error(form::Error::custom(e));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result<UserModel, UserAuthError> {
|
pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result<UserModel, UserAuthError> {
|
||||||
let user = query_as!(UserModelWithPassword,
|
let user = query_as!(UserModelWithPassword,
|
||||||
|
@ -135,16 +126,4 @@ pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(UserAuthError::PasswordInvalid)
|
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!()
|
|
||||||
}
|
}
|
|
@ -9,7 +9,7 @@ use rocket::http::uri::{Origin, Reference, Uri};
|
||||||
use rocket::response::Redirect;
|
use rocket::response::Redirect;
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
use rocket_session_store::Session;
|
use rocket_session_store::Session;
|
||||||
use crate::models::user::{validate_user, validate_user_form, UserAuthError, UserModel};
|
use crate::models::user::{validate_user, try_login_user_form, UserAuthError, UserModel};
|
||||||
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
|
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
|
||||||
use crate::guards::AuthUser;
|
use crate::guards::AuthUser;
|
||||||
use crate::routes::ui;
|
use crate::routes::ui;
|
||||||
|
|
|
@ -6,8 +6,10 @@ use rocket::http::{Header, Status};
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
use rocket_session_store::Session;
|
use rocket_session_store::Session;
|
||||||
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
|
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
|
||||||
|
use crate::config::AppConfig;
|
||||||
use crate::consts::{APP_METADATA, DISABLE_LOGIN_CHECK};
|
use crate::consts::{APP_METADATA, DISABLE_LOGIN_CHECK};
|
||||||
use crate::models::user::validate_user_form;
|
use crate::managers::user::UsersState;
|
||||||
|
use crate::models::user::try_login_user_form;
|
||||||
use crate::routes::ui::auth::HackyRedirectBecauseRocketBug;
|
use crate::routes::ui::auth::HackyRedirectBecauseRocketBug;
|
||||||
use crate::util::{set_csrf, validate_csrf_form};
|
use crate::util::{set_csrf, validate_csrf_form};
|
||||||
|
|
||||||
|
@ -16,7 +18,9 @@ pub async fn page(
|
||||||
route: &Route,
|
route: &Route,
|
||||||
session: Session<'_, SessionData>,
|
session: Session<'_, SessionData>,
|
||||||
return_to: Option<String>,
|
return_to: Option<String>,
|
||||||
logged_out: Option<bool>
|
logged_out: Option<bool>,
|
||||||
|
settings: &State<AppConfig>,
|
||||||
|
|
||||||
) -> Template {
|
) -> Template {
|
||||||
// TODO: redirect if already logged in
|
// TODO: redirect if already logged in
|
||||||
let csrf_token = set_csrf(&session).await;
|
let csrf_token = set_csrf(&session).await;
|
||||||
|
@ -26,7 +30,8 @@ pub async fn page(
|
||||||
form: &Context::default(),
|
form: &Context::default(),
|
||||||
return_to,
|
return_to,
|
||||||
logged_out,
|
logged_out,
|
||||||
meta: APP_METADATA.clone()
|
meta: APP_METADATA.clone(),
|
||||||
|
sso_enabled: settings.auth.oidc_enabled()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,28 +50,21 @@ struct LoginForm<'r> {
|
||||||
|
|
||||||
#[post("/auth/login?<return_to>", data = "<form>")]
|
#[post("/auth/login?<return_to>", data = "<form>")]
|
||||||
pub async fn handler(
|
pub async fn handler(
|
||||||
pool: &State<DB>,
|
|
||||||
route: &Route,
|
route: &Route,
|
||||||
ip_addr: IpAddr,
|
ip_addr: IpAddr,
|
||||||
session: Session<'_, SessionData>,
|
session: Session<'_, SessionData>,
|
||||||
mut form: Form<Contextual<'_, LoginForm<'_>>>,
|
mut form: Form<Contextual<'_, LoginForm<'_>>>,
|
||||||
|
users: &State<UsersState>,
|
||||||
|
settings: &State<AppConfig>,
|
||||||
return_to: Option<String>,
|
return_to: Option<String>,
|
||||||
) -> Result<HackyRedirectBecauseRocketBug, Template> {
|
) -> Result<HackyRedirectBecauseRocketBug, Template> {
|
||||||
trace!("handler");
|
trace!("handler");
|
||||||
if !*DISABLE_LOGIN_CHECK {
|
validate_csrf_form(&mut form.context, &session).await;
|
||||||
validate_csrf_form(&mut form.context, &session).await;
|
let user = try_login_user_form(&mut form.context, users.inner(), ip_addr, &session).await.ok();
|
||||||
}
|
// TODO: use new users fetch user
|
||||||
let user = validate_user_form(&mut form.context, &pool).await;
|
|
||||||
trace!("check form");
|
trace!("check form");
|
||||||
if form.context.status() == Status::Ok {
|
if form.context.status() == Status::Ok {
|
||||||
if let Some(submission) = &form.value {
|
if let Some(_) = &form.value {
|
||||||
session.set(SessionData {
|
|
||||||
csrf_token: None,
|
|
||||||
login: Some(LoginSessionData {
|
|
||||||
user: user.expect("failed to acquire user but no errors"), // if validate_user_form returned None, form had errors, this shouldnt run,
|
|
||||||
ip_address: ip_addr,
|
|
||||||
}),
|
|
||||||
}).await.unwrap();
|
|
||||||
let mut return_to_path = return_to.unwrap_or("/".to_string());
|
let mut return_to_path = return_to.unwrap_or("/".to_string());
|
||||||
if return_to_path == "" { return_to_path.push_str("/"); }
|
if return_to_path == "" { return_to_path.push_str("/"); }
|
||||||
debug!("returning user to {:?}", return_to_path);
|
debug!("returning user to {:?}", return_to_path);
|
||||||
|
@ -87,7 +85,8 @@ pub async fn handler(
|
||||||
csrf_token: csrf_token,
|
csrf_token: csrf_token,
|
||||||
form: &form.context,
|
form: &form.context,
|
||||||
return_to,
|
return_to,
|
||||||
meta: APP_METADATA.clone()
|
meta: APP_METADATA.clone(),
|
||||||
|
sso_enabled: settings.auth.oidc_enabled()
|
||||||
};
|
};
|
||||||
Err(Template::render("auth/login", &ctx))
|
Err(Template::render("auth/login", &ctx))
|
||||||
}
|
}
|
|
@ -17,7 +17,9 @@ use reqwest::header::HeaderMap;
|
||||||
use rocket::http::{Header, Status};
|
use rocket::http::{Header, Status};
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
use tokio::sync::MutexGuard;
|
use tokio::sync::MutexGuard;
|
||||||
|
use crate::config::AppConfig;
|
||||||
use crate::managers::sso::{SSOSessionData, SSOState, SSO};
|
use crate::managers::sso::{SSOSessionData, SSOState, SSO};
|
||||||
|
use crate::managers::user::{CreateUserOptions, FindUserOption, SSOData, UserManager, UsersState};
|
||||||
use crate::routes::ui::auth::HackyRedirectBecauseRocketBug;
|
use crate::routes::ui::auth::HackyRedirectBecauseRocketBug;
|
||||||
|
|
||||||
async fn page_handler(sso: &State<SSOState>, ip: IpAddr, return_to: Option<String>) -> Result<Redirect, anyhow::Error> {
|
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 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"))?;
|
let sess_data = sso.cache_take(ip).await.ok_or_else(|| anyhow!("No valid sso started"))?;
|
||||||
if &state != sess_data.csrf_token.secret() {
|
if &state != sess_data.csrf_token.secret() {
|
||||||
|
@ -95,16 +97,53 @@ async fn callback_handler(sso: &State<SSOState>, ip: IpAddr, code: String, state
|
||||||
.request_async(sso.http_client())
|
.request_async(sso.http_client())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("could not acquire user data"))?;
|
.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>")]
|
#[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)> {
|
pub async fn callback(session: Session<'_, SessionData>, config: &State<AppConfig>, users: &State<UsersState>, ip: IpAddr, sso: &State<SSOState>, code: String, state: String) -> Result<HackyRedirectBecauseRocketBug, (Status, Template)> {
|
||||||
let (userinfo, return_to) = callback_handler(sso, ip, code, state).await
|
let (userinfo, provider_id, return_to) = callback_handler(sso, ip, code, state).await
|
||||||
.map_err(|e| (Status::InternalServerError, Template::render("errors/500", context! {
|
.map_err(|e| (Status::InternalServerError, Template::render("errors/500", context! {
|
||||||
error: e.to_string()
|
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 mut 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"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
user = {
|
||||||
|
let u = 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 = {}", u.id);
|
||||||
|
Some(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let user = user.unwrap();
|
||||||
|
users.login_user_session(user, ip, &session).await;
|
||||||
debug!("user={:?}\nemail={:?}\nname={:?}", userinfo.subject(), userinfo.email(), userinfo.name());
|
debug!("user={:?}\nemail={:?}\nname={:?}", userinfo.subject(), userinfo.email(), userinfo.name());
|
||||||
|
// TODO: login user to session, prob through UserManager/users
|
||||||
let return_to = return_to.unwrap_or("/".to_string());
|
let return_to = return_to.unwrap_or("/".to_string());
|
||||||
Ok(HackyRedirectBecauseRocketBug {
|
Ok(HackyRedirectBecauseRocketBug {
|
||||||
inner: "Login successful, redirecting...".to_string(),
|
inner: "Login successful, redirecting...".to_string(),
|
||||||
|
|
|
@ -58,7 +58,7 @@
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button class="button is-link is-fullwidth" type="submit" >Login</button>
|
<button class="button is-link is-fullwidth" type="submit" >Login</button>
|
||||||
{{#if sso_enabled}}
|
{{#if sso_enabled}}
|
||||||
<a href="/login/sso" class="button is-fullwidth">Login with SSO</a>
|
<a href="/auth/sso" class="button is-fullwidth">Login with SSO</a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue