This commit is contained in:
+60
-16
@@ -88,6 +88,15 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_query_param(request: &Request, key: &str) -> Option<String> {
|
||||
let prefix = format!("{}=", key);
|
||||
request.uri().query().and_then(|q| {
|
||||
q.split('&').find_map(|p| {
|
||||
p.strip_prefix(&prefix).map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Soft pastel palette for client calendar colors.
|
||||
const CLIENT_COLORS: &[&str] = &[
|
||||
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
|
||||
@@ -381,11 +390,17 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
||||
(auth_password_enabled, auth_sso_enabled)
|
||||
};
|
||||
|
||||
let error = if has_query_flag(&request, "error") {
|
||||
Some(lang.t().login_sso_error.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let error = get_query_param(&request, "error").map(|code| {
|
||||
let t = lang.t();
|
||||
match code.as_str() {
|
||||
"sso_group" => t.login_sso_error_group,
|
||||
"sso_provider" => t.login_sso_error_provider,
|
||||
"sso_disabled" => t.login_sso_error_user_disabled,
|
||||
"sso" => t.login_sso_error,
|
||||
_ => t.login_sso_error,
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let body = LoginTemplate {
|
||||
t: lang.t(),
|
||||
@@ -573,14 +588,14 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
|
||||
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||
|
||||
if issuer_url.trim().is_empty() || client_id.trim().is_empty() {
|
||||
return Redirect::new(format!("/admin/login?lang={}&error=sso", lang.code()))
|
||||
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
return Redirect::new(format!("/admin/login?lang={}&error=sso", lang.code()))
|
||||
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -613,7 +628,7 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
|
||||
|
||||
async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let fail = format!("/admin/login?lang={}&error=sso", lang.code());
|
||||
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
|
||||
|
||||
// Read saved state from cookie
|
||||
let saved_state = request
|
||||
@@ -646,7 +661,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
code.is_empty(),
|
||||
state.is_empty(),
|
||||
);
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso")).into_response();
|
||||
}
|
||||
|
||||
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||
@@ -659,7 +674,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
Some(ep) => ep,
|
||||
None => {
|
||||
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -686,12 +701,12 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("OIDC token response parse error: {e}");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("OIDC token request failed: {e}");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -699,7 +714,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::warn!("OIDC no id_token in response: {token_json}");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -708,7 +723,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!("OIDC JWT decode failed");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_provider")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -716,7 +731,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
Some(u) => u.to_string(),
|
||||
None => {
|
||||
tracing::warn!("OIDC no preferred_username in claims: {claims}");
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -725,6 +740,33 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Check group membership
|
||||
let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?;
|
||||
if !allowed_groups.trim().is_empty() {
|
||||
let required: Vec<&str> = allowed_groups.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
let user_groups: Vec<String> = claims
|
||||
.get("groups")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|g| g.as_str())
|
||||
.map(|g| g.trim_start_matches('/').to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let has_group = required.iter().any(|r| {
|
||||
user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r))
|
||||
});
|
||||
|
||||
if !has_group {
|
||||
tracing::warn!(
|
||||
"OIDC group check failed: user={preferred_username}, user_groups={user_groups:?}, required={required:?}"
|
||||
);
|
||||
return Redirect::new(fail("sso_group")).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let login = preferred_username.clone();
|
||||
let user = query!(User, $login == login).get(&db).await?;
|
||||
@@ -749,7 +791,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
||||
};
|
||||
|
||||
if user.status != "active" {
|
||||
return Redirect::new(fail).into_response();
|
||||
return Redirect::new(fail("sso_disabled")).into_response();
|
||||
}
|
||||
|
||||
let display = user
|
||||
@@ -1120,6 +1162,7 @@ struct SettingsForm {
|
||||
oidc_issuer_url: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
oidc_allowed_groups: String,
|
||||
#[serde(default)]
|
||||
auth_password_enabled: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -1145,6 +1188,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
("oidc_issuer_url", form.oidc_issuer_url),
|
||||
("oidc_client_id", form.oidc_client_id),
|
||||
("oidc_client_secret", form.oidc_client_secret),
|
||||
("oidc_allowed_groups", form.oidc_allowed_groups),
|
||||
(
|
||||
"auth_password_enabled",
|
||||
if form.auth_password_enabled.is_some() {
|
||||
|
||||
+27
@@ -140,8 +140,14 @@ pub struct Translations {
|
||||
pub settings_oidc_issuer_url: &'static str,
|
||||
pub settings_oidc_client_id: &'static str,
|
||||
pub settings_oidc_client_secret: &'static str,
|
||||
pub settings_oidc_allowed_groups: &'static str,
|
||||
pub settings_auth_password_enabled: &'static str,
|
||||
pub settings_auth_sso_enabled: &'static str,
|
||||
pub settings_section_advanced: &'static str,
|
||||
pub settings_section_notifications: &'static str,
|
||||
pub settings_section_captcha: &'static str,
|
||||
pub settings_section_oidc: &'static str,
|
||||
pub settings_section_general: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -158,6 +164,9 @@ pub struct Translations {
|
||||
pub login_error: &'static str,
|
||||
pub login_sso_button: &'static str,
|
||||
pub login_sso_error: &'static str,
|
||||
pub login_sso_error_group: &'static str,
|
||||
pub login_sso_error_provider: &'static str,
|
||||
pub login_sso_error_user_disabled: &'static str,
|
||||
pub logout: &'static str,
|
||||
pub setup_title: &'static str,
|
||||
pub setup_description: &'static str,
|
||||
@@ -362,8 +371,14 @@ static RU: Translations = Translations {
|
||||
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
|
||||
settings_auth_password_enabled: "Вход по логину и паролю",
|
||||
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
|
||||
settings_section_advanced: "Расширенные настройки",
|
||||
settings_section_notifications: "Уведомления",
|
||||
settings_section_captcha: "Защита от ботов",
|
||||
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||
settings_section_general: "Сайт",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -400,6 +415,9 @@ static RU: Translations = Translations {
|
||||
login_error: "Неверный логин или пароль.",
|
||||
login_sso_button: "Войти через SSO",
|
||||
login_sso_error: "Ошибка SSO-авторизации.",
|
||||
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
|
||||
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
|
||||
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
|
||||
logout: "Выйти",
|
||||
setup_title: "Создание администратора",
|
||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
@@ -574,8 +592,14 @@ static EN: Translations = Translations {
|
||||
settings_oidc_issuer_url: "OIDC — Issuer URL",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
|
||||
settings_auth_password_enabled: "Password login",
|
||||
settings_auth_sso_enabled: "SSO login (OIDC)",
|
||||
settings_section_advanced: "Advanced settings",
|
||||
settings_section_notifications: "Notifications",
|
||||
settings_section_captcha: "Bot protection",
|
||||
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||
settings_section_general: "Site",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
@@ -612,6 +636,9 @@ static EN: Translations = Translations {
|
||||
login_error: "Invalid login or password.",
|
||||
login_sso_button: "Sign in with SSO",
|
||||
login_sso_error: "SSO authentication failed.",
|
||||
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
|
||||
login_sso_error_provider: "Could not reach the authentication provider.",
|
||||
login_sso_error_user_disabled: "Your account is disabled.",
|
||||
logout: "Sign Out",
|
||||
setup_title: "Create Administrator",
|
||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||
|
||||
Reference in New Issue
Block a user