mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add SSO/OIDC support to user model and settings
This commit is contained in:
@@ -18,9 +18,9 @@ use bichon_core::{
|
|||||||
token::TokenType,
|
token::TokenType,
|
||||||
users::{acl::AccessControl, role::RoleType},
|
users::{acl::AccessControl, role::RoleType},
|
||||||
};
|
};
|
||||||
|
use bichon_memdb::{Durability, MemDb};
|
||||||
use console::style;
|
use console::style;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use bichon_memdb::{Durability, MemDb};
|
|
||||||
use native_db::*;
|
use native_db::*;
|
||||||
use native_model::{native_model, Model};
|
use native_model::{native_model, Model};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -574,6 +574,8 @@ impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
|
|||||||
acl: value.acl,
|
acl: value.acl,
|
||||||
theme: value.theme,
|
theme: value.theme,
|
||||||
language: value.language,
|
language: value.language,
|
||||||
|
sso_id: None,
|
||||||
|
sso_provider: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,18 @@ pub const MAX_EXTRACT_BYTES: usize = 10 * 1024 * 1024;
|
|||||||
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
|
pub fn should_try_extract(content_type: &str, ext: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
ext,
|
ext,
|
||||||
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
|
"pdf"
|
||||||
| "txt" | "rtf" | "odt" | "ods" | "odp"
|
| "doc"
|
||||||
|
| "docx"
|
||||||
|
| "xls"
|
||||||
|
| "xlsx"
|
||||||
|
| "ppt"
|
||||||
|
| "pptx"
|
||||||
|
| "txt"
|
||||||
|
| "rtf"
|
||||||
|
| "odt"
|
||||||
|
| "ods"
|
||||||
|
| "odp"
|
||||||
) || content_type.starts_with("text/")
|
) || content_type.starts_with("text/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,26 @@ pub struct Settings {
|
|||||||
help = "Enable SMTP authentication requirement"
|
help = "Enable SMTP authentication requirement"
|
||||||
)]
|
)]
|
||||||
pub bichon_smtp_auth_required: bool,
|
pub bichon_smtp_auth_required: bool,
|
||||||
|
|
||||||
|
/// Enable OIDC-based Single Sign-On (Pro/Enterprise feature).
|
||||||
|
#[clap(long, default_value = "false", env, help = "Enable OpenID Connect SSO")]
|
||||||
|
pub bichon_oidc_enabled: bool,
|
||||||
|
|
||||||
|
/// OIDC issuer URL (e.g. https://keycloak.example.com/realms/myorg).
|
||||||
|
#[clap(long, env, help = "OpenID Connect issuer URL")]
|
||||||
|
pub bichon_oidc_issuer_url: Option<String>,
|
||||||
|
|
||||||
|
/// OIDC client ID registered with the IdP.
|
||||||
|
#[clap(long, env, help = "OpenID Connect client ID")]
|
||||||
|
pub bichon_oidc_client_id: Option<String>,
|
||||||
|
|
||||||
|
/// OIDC client secret registered with the IdP.
|
||||||
|
#[clap(long, env, help = "OpenID Connect client secret")]
|
||||||
|
pub bichon_oidc_client_secret: Option<String>,
|
||||||
|
|
||||||
|
/// OIDC redirect URI (must match what's registered with the IdP).
|
||||||
|
#[clap(long, env, help = "OpenID Connect redirect URI")]
|
||||||
|
pub bichon_oidc_redirect_uri: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
@@ -317,9 +337,8 @@ impl Settings {
|
|||||||
// rejects it, fall back to parsing with only the binary name so that
|
// rejects it, fall back to parsing with only the binary name so that
|
||||||
// the settings come entirely from environment variables.
|
// the settings come entirely from environment variables.
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let s = Self::try_parse_from(&args).unwrap_or_else(|_| {
|
let s = Self::try_parse_from(&args)
|
||||||
Self::parse_from(std::iter::once(args[0].clone()))
|
.unwrap_or_else(|_| Self::parse_from(std::iter::once(args[0].clone())));
|
||||||
});
|
|
||||||
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
|
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
|
||||||
panic!(
|
panic!(
|
||||||
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
|
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub struct SystemConfigurations {
|
|||||||
pub bichon_smtp_auth_required: bool,
|
pub bichon_smtp_auth_required: bool,
|
||||||
pub bichon_smtp_tls_key_path: Option<String>,
|
pub bichon_smtp_tls_key_path: Option<String>,
|
||||||
pub bichon_smtp_tls_cert_path: Option<String>,
|
pub bichon_smtp_tls_cert_path: Option<String>,
|
||||||
|
|
||||||
|
pub bichon_oidc_enabled: bool,
|
||||||
|
pub bichon_oidc_issuer_url: Option<String>,
|
||||||
|
pub bichon_oidc_client_id: Option<String>,
|
||||||
|
pub bichon_oidc_redirect_uri: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Settings> for SystemConfigurations {
|
impl From<&Settings> for SystemConfigurations {
|
||||||
@@ -94,6 +99,10 @@ impl From<&Settings> for SystemConfigurations {
|
|||||||
bichon_smtp_auth_required: s.bichon_smtp_auth_required,
|
bichon_smtp_auth_required: s.bichon_smtp_auth_required,
|
||||||
bichon_smtp_tls_key_path: s.bichon_smtp_tls_key_path.clone(),
|
bichon_smtp_tls_key_path: s.bichon_smtp_tls_key_path.clone(),
|
||||||
bichon_smtp_tls_cert_path: s.bichon_smtp_tls_cert_path.clone(),
|
bichon_smtp_tls_cert_path: s.bichon_smtp_tls_cert_path.clone(),
|
||||||
|
bichon_oidc_enabled: s.bichon_oidc_enabled,
|
||||||
|
bichon_oidc_issuer_url: s.bichon_oidc_issuer_url.clone(),
|
||||||
|
bichon_oidc_client_id: s.bichon_oidc_client_id.clone(),
|
||||||
|
bichon_oidc_redirect_uri: s.bichon_oidc_redirect_uri.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ pub struct BichonUserV2 {
|
|||||||
|
|
||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
|
/// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim).
|
||||||
|
pub sso_id: Option<String>,
|
||||||
|
/// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`.
|
||||||
|
pub sso_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemDbModel for BichonUserV2 {
|
impl MemDbModel for BichonUserV2 {
|
||||||
@@ -192,6 +197,8 @@ impl BichonUserV2 {
|
|||||||
global_permissions,
|
global_permissions,
|
||||||
theme: self.theme,
|
theme: self.theme,
|
||||||
language: self.language,
|
language: self.language,
|
||||||
|
sso_id: self.sso_id,
|
||||||
|
sso_provider: self.sso_provider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +233,8 @@ impl BichonUserV2 {
|
|||||||
acl: None,
|
acl: None,
|
||||||
theme: None,
|
theme: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
sso_id: None,
|
||||||
|
sso_provider: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Generate and insert an initial access token for the first-time setup
|
// 3. Generate and insert an initial access token for the first-time setup
|
||||||
@@ -382,6 +391,8 @@ impl BichonUserV2 {
|
|||||||
account_access_map: request.account_access_map,
|
account_access_map: request.account_access_map,
|
||||||
theme: request.theme,
|
theme: request.theme,
|
||||||
language: request.language,
|
language: request.language,
|
||||||
|
sso_id: None,
|
||||||
|
sso_provider: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_clone = user.clone();
|
let user_clone = user.clone();
|
||||||
|
|||||||
@@ -52,4 +52,9 @@ pub struct UserView {
|
|||||||
pub acl: Option<AccessControl>,
|
pub acl: Option<AccessControl>,
|
||||||
pub theme: Option<String>,
|
pub theme: Option<String>,
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
|
/// SSO identity: unique subject ID from the external IdP (e.g. OIDC `sub` claim).
|
||||||
|
pub sso_id: Option<String>,
|
||||||
|
/// SSO provider identifier: `"oidc"` or future `"saml"` / `"ldap"`.
|
||||||
|
pub sso_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ macro_rules! generate_token {
|
|||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generate_token_impl(bit_strength: usize) -> String {
|
pub fn generate_token_impl(bit_strength: usize) -> String {
|
||||||
let byte_length = (bit_strength + 23) / 24 * 3;
|
let byte_length = (bit_strength + 23) / 24 * 3;
|
||||||
let random_bytes: Vec<u8> = (0..byte_length).map(|_| rand::random::<u8>()).collect();
|
let random_bytes: Vec<u8> = (0..byte_length).map(|_| rand::random::<u8>()).collect();
|
||||||
let mut encoded = general_purpose::URL_SAFE.encode(&random_bytes);
|
let mut encoded = general_purpose::URL_SAFE.encode(&random_bytes);
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ import { useLocation, useNavigate } from '@tanstack/react-router'
|
|||||||
import { Button } from '@/components/button'
|
import { Button } from '@/components/button'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { Loader2, LogIn } from 'lucide-react'
|
import { Loader2, LogIn, Shield } from 'lucide-react'
|
||||||
import { login } from '@/api/users/api'
|
import { login } from '@/api/users/api'
|
||||||
import { useTheme } from '@/context/theme-context'
|
import { useTheme } from '@/context/theme-context'
|
||||||
|
import { useEdition } from '@/hooks/use-edition'
|
||||||
|
|
||||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
|||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { isPro } = useEdition()
|
||||||
|
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const redirect = toSearchParams(search).get('redirect') || '/';
|
const redirect = toSearchParams(search).get('redirect') || '/';
|
||||||
@@ -156,6 +158,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
|||||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn size={16} className='mr-2' />}
|
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn size={16} className='mr-2' />}
|
||||||
{t('auth.login')}
|
{t('auth.login')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isPro && (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='mt-2'
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = '/api/auth/oidc/login'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield size={16} className='mr-2' />
|
||||||
|
{t('auth.ssoLogin')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import axiosInstance from '@/api/axiosInstance'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export interface EditionInfo {
|
||||||
|
features: string[]
|
||||||
|
edition: 'community' | 'pro' | 'enterprise'
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEdition(): Promise<EditionInfo> {
|
||||||
|
const { data } = await axiosInstance.get<EditionInfo>('api/v1/features')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEdition() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['edition'],
|
||||||
|
queryFn: fetchEdition,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPro: data?.edition === 'pro' || data?.edition === 'enterprise',
|
||||||
|
edition: data?.edition ?? 'community',
|
||||||
|
features: data?.features ?? [],
|
||||||
|
} as const
|
||||||
|
}
|
||||||
@@ -423,6 +423,7 @@
|
|||||||
"sessionExpired": "Session expired!",
|
"sessionExpired": "Session expired!",
|
||||||
"sessionExpiredDesc": "Your session has ended due to inactivity. Please log in again to continue.",
|
"sessionExpiredDesc": "Your session has ended due to inactivity. Please log in again to continue.",
|
||||||
"somethingWentWrong": "Something went wrong",
|
"somethingWentWrong": "Something went wrong",
|
||||||
|
"ssoLogin": "Sign in with SSO",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"welcome": "Welcome to Bichon",
|
"welcome": "Welcome to Bichon",
|
||||||
"youWillNeedToLogInAgain": "You will need to log in again to access your account."
|
"youWillNeedToLogInAgain": "You will need to log in again to access your account."
|
||||||
|
|||||||
Reference in New Issue
Block a user