feat: add SSO/OIDC support to user model and settings

This commit is contained in:
rustmailer
2026-06-24 17:32:14 +08:00
parent b0f229618c
commit 9e55026f12
10 changed files with 109 additions and 8 deletions
+3 -1
View File
@@ -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,
}
}
}
+12 -2
View File
@@ -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/")
}
+22 -3
View File
@@ -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"
+9
View File
@@ -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(),
}
}
}
+11
View File
@@ -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();
+5
View File
@@ -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>,
}
+1 -1
View File
@@ -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);
+17 -1
View File
@@ -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>
+28
View File
@@ -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
}
+1
View File
@@ -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."