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,
|
||||
users::{acl::AccessControl, role::RoleType},
|
||||
};
|
||||
use bichon_memdb::{Durability, MemDb};
|
||||
use console::style;
|
||||
use itertools::Itertools;
|
||||
use bichon_memdb::{Durability, MemDb};
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -574,6 +574,8 @@ impl From<BichonUserV2> for bichon_core::users::BichonUserV2 {
|
||||
acl: value.acl,
|
||||
theme: value.theme,
|
||||
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 {
|
||||
matches!(
|
||||
ext,
|
||||
"pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
|
||||
| "txt" | "rtf" | "odt" | "ods" | "odp"
|
||||
"pdf"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "txt"
|
||||
| "rtf"
|
||||
| "odt"
|
||||
| "ods"
|
||||
| "odp"
|
||||
) || content_type.starts_with("text/")
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,26 @@ pub struct Settings {
|
||||
help = "Enable SMTP authentication requirement"
|
||||
)]
|
||||
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 {
|
||||
@@ -317,9 +337,8 @@ impl Settings {
|
||||
// rejects it, fall back to parsing with only the binary name so that
|
||||
// the settings come entirely from environment variables.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let s = Self::try_parse_from(&args).unwrap_or_else(|_| {
|
||||
Self::parse_from(std::iter::once(args[0].clone()))
|
||||
});
|
||||
let s = Self::try_parse_from(&args)
|
||||
.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() {
|
||||
panic!(
|
||||
"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_tls_key_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 {
|
||||
@@ -94,6 +99,10 @@ impl From<&Settings> for SystemConfigurations {
|
||||
bichon_smtp_auth_required: s.bichon_smtp_auth_required,
|
||||
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_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 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 {
|
||||
@@ -192,6 +197,8 @@ impl BichonUserV2 {
|
||||
global_permissions,
|
||||
theme: self.theme,
|
||||
language: self.language,
|
||||
sso_id: self.sso_id,
|
||||
sso_provider: self.sso_provider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +233,8 @@ impl BichonUserV2 {
|
||||
acl: None,
|
||||
theme: None,
|
||||
language: None,
|
||||
sso_id: None,
|
||||
sso_provider: None,
|
||||
};
|
||||
|
||||
// 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,
|
||||
theme: request.theme,
|
||||
language: request.language,
|
||||
sso_id: None,
|
||||
sso_provider: None,
|
||||
};
|
||||
|
||||
let user_clone = user.clone();
|
||||
|
||||
@@ -52,4 +52,9 @@ pub struct UserView {
|
||||
pub acl: Option<AccessControl>,
|
||||
pub theme: 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 random_bytes: Vec<u8> = (0..byte_length).map(|_| rand::random::<u8>()).collect();
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { Loader2, LogIn, Shield } from 'lucide-react'
|
||||
import { login } from '@/api/users/api'
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
@@ -52,6 +53,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { isPro } = useEdition()
|
||||
|
||||
const { search } = useLocation();
|
||||
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' />}
|
||||
{t('auth.login')}
|
||||
</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>
|
||||
</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!",
|
||||
"sessionExpiredDesc": "Your session has ended due to inactivity. Please log in again to continue.",
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"ssoLogin": "Sign in with SSO",
|
||||
"username": "Username",
|
||||
"welcome": "Welcome to Bichon",
|
||||
"youWillNeedToLogInAgain": "You will need to log in again to access your account."
|
||||
|
||||
Reference in New Issue
Block a user