From a65488c3045fa91894f1959ca9d8d2422f78a7d7 Mon Sep 17 00:00:00 2001 From: AB Date: Tue, 19 May 2026 00:57:05 +0300 Subject: [PATCH] Added OIDC auth --- src/admin.rs | 76 ++++++++++++++---- src/i18n.rs | 27 +++++++ templates/admin/settings.html | 144 +++++++++++++++++++--------------- 3 files changed, 169 insertions(+), 78 deletions(-) diff --git a/src/admin.rs b/src/admin.rs index da458bf..1f60be2 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -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 { + 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 { 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 { async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result { 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 = 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, #[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() { diff --git a/src/i18n.rs b/src/i18n.rs index 34172c7..e77dabf 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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.", diff --git a/templates/admin/settings.html b/templates/admin/settings.html index f1c9d8e..4bd8cdf 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -14,12 +14,8 @@
-
- -
- -
-
+ +

{{ t.settings_contact_info }}

@@ -32,18 +28,6 @@
-
- -
- -
-
-
- -
- -
-
@@ -52,55 +36,91 @@ placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}
-

Каждая фраза между запятыми — отдельное ключевое слово

-
- -
- -
-
-
- -
- -
-
+
+ + {{ t.settings_section_advanced }} + -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
+
+

{{ t.settings_section_general }}

+
+ +
+ +
+
+
+ +
+ +
+
-
- -
-
- -
+

{{ t.settings_section_notifications }}

+
+ +
+ +
+
- +

{{ t.settings_section_captcha }}

+
+ +
+ +
+
+
+ +
+ +
+
+ +

{{ t.settings_section_oidc }}

+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ +