Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s

This commit is contained in:
2026-05-19 00:57:05 +03:00
parent 4d9d0a894c
commit a65488c304
3 changed files with 169 additions and 78 deletions
+60 -16
View File
@@ -88,6 +88,15 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
.unwrap_or(false) .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. /// Soft pastel palette for client calendar colors.
const CLIENT_COLORS: &[&str] = &[ const CLIENT_COLORS: &[&str] = &[
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f", "#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) (auth_password_enabled, auth_sso_enabled)
}; };
let error = if has_query_flag(&request, "error") { let error = get_query_param(&request, "error").map(|code| {
Some(lang.t().login_sso_error.to_string()) let t = lang.t();
} else { match code.as_str() {
None "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 { let body = LoginTemplate {
t: lang.t(), 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?; let site_domain = oidc_setting(&db, "site_domain").await?;
if issuer_url.trim().is_empty() || client_id.trim().is_empty() { 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(); .into_response();
} }
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await { let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
Some(ep) => ep, Some(ep) => ep,
None => { 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(); .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> { async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request); 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 // Read saved state from cookie
let saved_state = request let saved_state = request
@@ -646,7 +661,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
code.is_empty(), code.is_empty(),
state.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?; 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, Some(ep) => ep,
None => { None => {
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}"); 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, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!("OIDC token response parse error: {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) => { Err(e) => {
tracing::warn!("OIDC token request failed: {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, Some(t) => t,
None => { None => {
tracing::warn!("OIDC no id_token in response: {token_json}"); 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, Some(c) => c,
None => { None => {
tracing::warn!("OIDC JWT decode failed"); 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(), Some(u) => u.to_string(),
None => { None => {
tracing::warn!("OIDC no preferred_username in claims: {claims}"); 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()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .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 // Find or create user
let login = preferred_username.clone(); let login = preferred_username.clone();
let user = query!(User, $login == login).get(&db).await?; 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" { if user.status != "active" {
return Redirect::new(fail).into_response(); return Redirect::new(fail("sso_disabled")).into_response();
} }
let display = user let display = user
@@ -1120,6 +1162,7 @@ struct SettingsForm {
oidc_issuer_url: String, oidc_issuer_url: String,
oidc_client_id: String, oidc_client_id: String,
oidc_client_secret: String, oidc_client_secret: String,
oidc_allowed_groups: String,
#[serde(default)] #[serde(default)]
auth_password_enabled: Option<String>, auth_password_enabled: Option<String>,
#[serde(default)] #[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_issuer_url", form.oidc_issuer_url),
("oidc_client_id", form.oidc_client_id), ("oidc_client_id", form.oidc_client_id),
("oidc_client_secret", form.oidc_client_secret), ("oidc_client_secret", form.oidc_client_secret),
("oidc_allowed_groups", form.oidc_allowed_groups),
( (
"auth_password_enabled", "auth_password_enabled",
if form.auth_password_enabled.is_some() { if form.auth_password_enabled.is_some() {
+27
View File
@@ -140,8 +140,14 @@ pub struct Translations {
pub settings_oidc_issuer_url: &'static str, pub settings_oidc_issuer_url: &'static str,
pub settings_oidc_client_id: &'static str, pub settings_oidc_client_id: &'static str,
pub settings_oidc_client_secret: &'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_password_enabled: &'static str,
pub settings_auth_sso_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_contact_label: &'static str,
pub landing_pricing_title: &'static str, pub landing_pricing_title: &'static str,
@@ -158,6 +164,9 @@ pub struct Translations {
pub login_error: &'static str, pub login_error: &'static str,
pub login_sso_button: &'static str, pub login_sso_button: &'static str,
pub login_sso_error: &'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 logout: &'static str,
pub setup_title: &'static str, pub setup_title: &'static str,
pub setup_description: &'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_issuer_url: "OIDC — URL провайдера (Issuer URL)",
settings_oidc_client_id: "OIDC — Client ID", settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret", settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
settings_auth_password_enabled: "Вход по логину и паролю", settings_auth_password_enabled: "Вход по логину и паролю",
settings_auth_sso_enabled: "Вход через SSO (OIDC)", 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_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость", landing_pricing_title: "Стоимость",
@@ -400,6 +415,9 @@ static RU: Translations = Translations {
login_error: "Неверный логин или пароль.", login_error: "Неверный логин или пароль.",
login_sso_button: "Войти через SSO", login_sso_button: "Войти через SSO",
login_sso_error: "Ошибка SSO-авторизации.", login_sso_error: "Ошибка SSO-авторизации.",
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
logout: "Выйти", logout: "Выйти",
setup_title: "Создание администратора", setup_title: "Создание администратора",
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.", setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
@@ -574,8 +592,14 @@ static EN: Translations = Translations {
settings_oidc_issuer_url: "OIDC — Issuer URL", settings_oidc_issuer_url: "OIDC — Issuer URL",
settings_oidc_client_id: "OIDC — Client ID", settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret", 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_password_enabled: "Password login",
settings_auth_sso_enabled: "SSO login (OIDC)", 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_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing", landing_pricing_title: "Pricing",
@@ -612,6 +636,9 @@ static EN: Translations = Translations {
login_error: "Invalid login or password.", login_error: "Invalid login or password.",
login_sso_button: "Sign in with SSO", login_sso_button: "Sign in with SSO",
login_sso_error: "SSO authentication failed.", 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", logout: "Sign Out",
setup_title: "Create Administrator", setup_title: "Create Administrator",
setup_description: "There are no administrators yet. Create the first one to get started.", setup_description: "There are no administrators yet. Create the first one to get started.",
+82 -62
View File
@@ -14,12 +14,8 @@
<div class="form-card"> <div class="form-card">
<form method="post" action="/admin/settings/save"> <form method="post" action="/admin/settings/save">
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label> <h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field"> <div class="field">
<label class="label">{{ t.settings_contact_info }}</label> <label class="label">{{ t.settings_contact_info }}</label>
<div class="control"> <div class="control">
@@ -32,18 +28,6 @@
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea> <textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field"> <div class="field">
<label class="label">{{ t.settings_seo_keywords }}</label> <label class="label">{{ t.settings_seo_keywords }}</label>
<div class="control"> <div class="control">
@@ -52,55 +36,91 @@
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea> placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div> </div>
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div> <div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
</div> </div>
<div class="field"> <details style="margin-top:1.5rem;">
<label class="label">{{ t.settings_turnstile_site_key }}</label> <summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
<div class="control"> {{ t.settings_section_advanced }}
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}"> </summary>
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field"> <div style="margin-top:1rem;">
<label class="label">{{ t.settings_oidc_issuer_url }}</label> <h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
<div class="control"> <div class="field">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}"> <label class="label">{{ t.settings_site_domain }}</label>
</div> <div class="control">
</div> <input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
<div class="field"> </div>
<label class="label">{{ t.settings_oidc_client_id }}</label> </div>
<div class="control"> <div class="field">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}"> <label class="label">{{ t.settings_timezone }}</label>
</div> <div class="control">
</div> <input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
<div class="field"> </div>
<label class="label">{{ t.settings_oidc_client_secret }}</label> </div>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field"> <h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
<label class="checkbox"> <div class="field">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}> <label class="label">{{ t.settings_telegram_bot_token }}</label>
{{ t.settings_auth_password_enabled }} <div class="control">
</label> <input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
<div class="field"> </div>
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button> <h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
<div class="field">
<label class="label">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
<div class="field">
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_secret }}</label>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
{{ t.settings_auth_password_enabled }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div>
</div>
</details>
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
</form> </form>
</div> </div>