This commit is contained in:
Generated
+1
-1
@@ -3359,7 +3359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+109
-17
@@ -194,7 +194,8 @@ struct LoginTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
turnstile_site_key: String,
|
turnstile_site_key: String,
|
||||||
oidc_enabled: bool,
|
auth_password_enabled: bool,
|
||||||
|
auth_sso_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -256,6 +257,8 @@ struct SettingsTemplate<'a> {
|
|||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
settings: Vec<Setting>,
|
settings: Vec<Setting>,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
auth_password_checked: bool,
|
||||||
|
auth_sso_checked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -350,13 +353,32 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
|
|
||||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
|
|
||||||
let oidc_enabled = {
|
let settings = Setting::objects().all(&db).await?;
|
||||||
let k = "oidc_issuer_url".to_string();
|
let get_val = |key: &str| -> String {
|
||||||
query!(Setting, $key == k)
|
settings
|
||||||
.get(&db)
|
.iter()
|
||||||
.await?
|
.find(|s| s.key == key)
|
||||||
.map(|s| !s.value.trim().is_empty())
|
.map(|s| s.value.clone())
|
||||||
.unwrap_or(false)
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_setting = get_val("auth_password_enabled");
|
||||||
|
let sso_setting = get_val("auth_sso_enabled");
|
||||||
|
let oidc_configured = !get_val("oidc_issuer_url").trim().is_empty();
|
||||||
|
|
||||||
|
// Default: password enabled if setting was never saved
|
||||||
|
let auth_password_enabled = if password_setting.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
password_setting == "true"
|
||||||
|
};
|
||||||
|
let auth_sso_enabled = sso_setting == "true" && oidc_configured;
|
||||||
|
|
||||||
|
// Fallback: if neither is enabled, show password form
|
||||||
|
let (auth_password_enabled, auth_sso_enabled) = if !auth_password_enabled && !auth_sso_enabled {
|
||||||
|
(true, false)
|
||||||
|
} else {
|
||||||
|
(auth_password_enabled, auth_sso_enabled)
|
||||||
};
|
};
|
||||||
|
|
||||||
let error = if has_query_flag(&request, "error") {
|
let error = if has_query_flag(&request, "error") {
|
||||||
@@ -370,7 +392,8 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
lang,
|
lang,
|
||||||
error,
|
error,
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
oidc_enabled,
|
auth_password_enabled,
|
||||||
|
auth_sso_enabled,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -460,7 +483,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
lang,
|
lang,
|
||||||
error: Some(lang.t().login_error.to_string()),
|
error: Some(lang.t().login_error.to_string()),
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
oidc_enabled: false,
|
auth_password_enabled: true,
|
||||||
|
auth_sso_enabled: false,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
return html_response(body, lang);
|
return html_response(body, lang);
|
||||||
@@ -490,7 +514,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
lang,
|
lang,
|
||||||
error: Some(lang.t().login_error.to_string()),
|
error: Some(lang.t().login_error.to_string()),
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
oidc_enabled: false,
|
auth_password_enabled: true,
|
||||||
|
auth_sso_enabled: false,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -606,6 +631,11 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if code.is_empty() || state.is_empty() || state != saved_state {
|
if code.is_empty() || state.is_empty() || state != saved_state {
|
||||||
|
tracing::warn!(
|
||||||
|
"OIDC state mismatch: state={state:?}, saved={saved_state:?}, code_empty={}, state_empty={}",
|
||||||
|
code.is_empty(),
|
||||||
|
state.is_empty(),
|
||||||
|
);
|
||||||
return Redirect::new(fail).into_response();
|
return Redirect::new(fail).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +650,10 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
|||||||
// Get token endpoint from discovery
|
// Get token endpoint from discovery
|
||||||
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
|
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
|
||||||
Some(ep) => ep,
|
Some(ep) => ep,
|
||||||
None => return Redirect::new(fail).into_response(),
|
None => {
|
||||||
|
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let redirect_uri = format!(
|
let redirect_uri = format!(
|
||||||
@@ -644,25 +677,40 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot:
|
|||||||
let token_json: serde_json::Value = match token_resp {
|
let token_json: serde_json::Value = match token_resp {
|
||||||
Ok(resp) => match resp.json().await {
|
Ok(resp) => match resp.json().await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return Redirect::new(fail).into_response(),
|
Err(e) => {
|
||||||
|
tracing::warn!("OIDC token response parse error: {e}");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(_) => return Redirect::new(fail).into_response(),
|
Err(e) => {
|
||||||
|
tracing::warn!("OIDC token request failed: {e}");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
|
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => return Redirect::new(fail).into_response(),
|
None => {
|
||||||
|
tracing::warn!("OIDC no id_token in response: {token_json}");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
|
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
|
||||||
let claims = match decode_jwt_payload(id_token) {
|
let claims = match decode_jwt_payload(id_token) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Redirect::new(fail).into_response(),
|
None => {
|
||||||
|
tracing::warn!("OIDC JWT decode failed");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
|
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
|
||||||
Some(u) => u.to_string(),
|
Some(u) => u.to_string(),
|
||||||
None => return Redirect::new(fail).into_response(),
|
None => {
|
||||||
|
tracing::warn!("OIDC no preferred_username in claims: {claims}");
|
||||||
|
return Redirect::new(fail).into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let display_name = claims
|
let display_name = claims
|
||||||
@@ -964,12 +1012,24 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let settings = Setting::objects().all(&db).await?;
|
let settings = Setting::objects().all(&db).await?;
|
||||||
|
let auth_password_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_password_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(true);
|
||||||
|
let auth_sso_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_sso_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
let body = SettingsTemplate {
|
let body = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: false,
|
saved: false,
|
||||||
|
auth_password_checked,
|
||||||
|
auth_sso_checked,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -1048,6 +1108,10 @@ 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,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_password_enabled: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_sso_enabled: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -1069,6 +1133,22 @@ 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),
|
||||||
|
(
|
||||||
|
"auth_password_enabled",
|
||||||
|
if form.auth_password_enabled.is_some() {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auth_sso_enabled",
|
||||||
|
if form.auth_sso_enabled.is_some() {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let k = key.to_string();
|
let k = key.to_string();
|
||||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||||
@@ -1091,12 +1171,24 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = Setting::objects().all(&db).await?;
|
let settings = Setting::objects().all(&db).await?;
|
||||||
|
let auth_password_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_password_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(true);
|
||||||
|
let auth_sso_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_sso_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
let rendered = SettingsTemplate {
|
let rendered = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: true,
|
saved: true,
|
||||||
|
auth_password_checked,
|
||||||
|
auth_sso_checked,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(rendered, lang)
|
html_response(rendered, lang)
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ 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_auth_password_enabled: &'static str,
|
||||||
|
pub settings_auth_sso_enabled: &'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,
|
||||||
|
|
||||||
@@ -360,6 +362,8 @@ 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_auth_password_enabled: "Вход по логину и паролю",
|
||||||
|
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
|
||||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
@@ -570,6 +574,8 @@ 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_auth_password_enabled: "Password login",
|
||||||
|
settings_auth_sso_enabled: "SSO login (OIDC)",
|
||||||
landing_contact_label: "Or contact us directly",
|
landing_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,11 @@
|
|||||||
{% if let Some(err) = error.as_ref() %}
|
{% if let Some(err) = error.as_ref() %}
|
||||||
<div class="notification is-danger is-light">{{ err }}</div>
|
<div class="notification is-danger is-light">{{ err }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if oidc_enabled %}
|
{% if auth_sso_enabled %}
|
||||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||||
{% else %}
|
{% endif %}
|
||||||
|
{% if auth_password_enabled %}
|
||||||
|
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
||||||
<form method="post" action="/admin/login/submit">
|
<form method="post" action="/admin/login/submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.users_login }}</label>
|
<label class="label">{{ t.users_login }}</label>
|
||||||
|
|||||||
@@ -87,6 +87,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user