This commit is contained in:
Generated
+8
@@ -3182,6 +3182,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3355,6 +3361,7 @@ dependencies = [
|
||||
name = "web-petting"
|
||||
version = "0.1.11"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cot",
|
||||
@@ -3368,6 +3375,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -16,5 +16,7 @@ multer = "3"
|
||||
futures = "0.3"
|
||||
tokio = { version = "1", features = ["fs"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
+235
-1
@@ -194,6 +194,7 @@ struct LoginTemplate<'a> {
|
||||
lang: Lang,
|
||||
error: Option<String>,
|
||||
turnstile_site_key: String,
|
||||
oidc_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
@@ -348,11 +349,28 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
||||
}
|
||||
|
||||
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||
|
||||
let oidc_enabled = {
|
||||
let k = "oidc_issuer_url".to_string();
|
||||
query!(Setting, $key == k)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| !s.value.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let error = if has_query_flag(&request, "error") {
|
||||
Some(lang.t().login_sso_error.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let body = LoginTemplate {
|
||||
t: lang.t(),
|
||||
lang,
|
||||
error: None,
|
||||
error,
|
||||
turnstile_site_key,
|
||||
oidc_enabled,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -442,6 +460,7 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
||||
lang,
|
||||
error: Some(lang.t().login_error.to_string()),
|
||||
turnstile_site_key,
|
||||
oidc_enabled: false,
|
||||
}
|
||||
.render()?;
|
||||
return html_response(body, lang);
|
||||
@@ -471,6 +490,7 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
||||
lang,
|
||||
error: Some(lang.t().login_error.to_string()),
|
||||
turnstile_site_key,
|
||||
oidc_enabled: false,
|
||||
}
|
||||
.render()?;
|
||||
html_response(body, lang)
|
||||
@@ -482,6 +502,212 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
|
||||
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OIDC Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_OIDC_STATE: &str = "oidc_state";
|
||||
|
||||
/// Read an OIDC-related setting from the DB, returning empty string if absent.
|
||||
async fn oidc_setting(db: &Database, name: &str) -> cot::Result<String> {
|
||||
let k = name.to_string();
|
||||
Ok(query!(Setting, $key == k)
|
||||
.get(db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Fetch the OpenID Connect discovery document and extract a field.
|
||||
async fn oidc_discover(issuer_url: &str, field: &str) -> Option<String> {
|
||||
let url = format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
issuer_url.trim_end_matches('/')
|
||||
);
|
||||
let resp = reqwest::Client::new().get(&url).send().await.ok()?;
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
json.get(field)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Decode the payload of a JWT (base64url, no signature verification).
|
||||
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
|
||||
use base64::Engine;
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let payload = parts[1];
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
async fn oidc_start(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
let lang = detect_lang(&request);
|
||||
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||
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()))
|
||||
.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()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let state = rand_token();
|
||||
session.insert(SESSION_OIDC_STATE, state.clone()).await?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/admin/oidc/callback",
|
||||
site_domain.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}",
|
||||
authorization_endpoint,
|
||||
urlencoding::encode(&client_id),
|
||||
urlencoding::encode(&redirect_uri),
|
||||
urlencoding::encode(&state),
|
||||
);
|
||||
|
||||
Redirect::new(redirect_url).into_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());
|
||||
|
||||
// Extract code and state from query string
|
||||
let query_str = request.uri().query().unwrap_or("");
|
||||
let mut code = String::new();
|
||||
let mut state = String::new();
|
||||
for pair in query_str.split('&') {
|
||||
if let Some(v) = pair.strip_prefix("code=") {
|
||||
code = v.to_string();
|
||||
} else if let Some(v) = pair.strip_prefix("state=") {
|
||||
state = v.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify state
|
||||
let saved_state = session
|
||||
.get::<String>(SESSION_OIDC_STATE)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
if code.is_empty() || state.is_empty() || state != saved_state {
|
||||
return Redirect::new(fail).into_response();
|
||||
}
|
||||
|
||||
// Clear used state
|
||||
let _ = session.remove::<String>(SESSION_OIDC_STATE).await;
|
||||
|
||||
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||
let client_secret = oidc_setting(&db, "oidc_client_secret").await?;
|
||||
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||
|
||||
// Get token endpoint from discovery
|
||||
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
|
||||
Some(ep) => ep,
|
||||
None => return Redirect::new(fail).into_response(),
|
||||
};
|
||||
|
||||
let redirect_uri = format!(
|
||||
"{}/admin/oidc/callback",
|
||||
site_domain.trim_end_matches('/')
|
||||
);
|
||||
|
||||
// Exchange code for tokens
|
||||
let token_resp = reqwest::Client::new()
|
||||
.post(&token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &code),
|
||||
("redirect_uri", &redirect_uri),
|
||||
("client_id", &client_id),
|
||||
("client_secret", &client_secret),
|
||||
])
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token_json: serde_json::Value = match token_resp {
|
||||
Ok(resp) => match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Redirect::new(fail).into_response(),
|
||||
},
|
||||
Err(_) => return Redirect::new(fail).into_response(),
|
||||
};
|
||||
|
||||
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => return Redirect::new(fail).into_response(),
|
||||
};
|
||||
|
||||
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
|
||||
let claims = match decode_jwt_payload(id_token) {
|
||||
Some(c) => c,
|
||||
None => return Redirect::new(fail).into_response(),
|
||||
};
|
||||
|
||||
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
|
||||
Some(u) => u.to_string(),
|
||||
None => return Redirect::new(fail).into_response(),
|
||||
};
|
||||
|
||||
let display_name = claims
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Find or create user
|
||||
let login = preferred_username.clone();
|
||||
let user = query!(User, $login == login).get(&db).await?;
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
let mut new_user = User {
|
||||
id: Auto::auto(),
|
||||
login: preferred_username.clone(),
|
||||
password_hash: String::new(),
|
||||
display_name: display_name.clone(),
|
||||
telegram_chat_id: None,
|
||||
telegram_notifications: Some(false),
|
||||
status: "active".to_string(),
|
||||
created_at: now_utc(),
|
||||
updated_at: now_utc(),
|
||||
};
|
||||
new_user.save(&db).await?;
|
||||
new_user
|
||||
}
|
||||
};
|
||||
|
||||
if user.status != "active" {
|
||||
return Redirect::new(fail).into_response();
|
||||
}
|
||||
|
||||
let display = user
|
||||
.display_name
|
||||
.as_deref()
|
||||
.unwrap_or(&user.login)
|
||||
.to_string();
|
||||
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
|
||||
session.insert(SESSION_USER_NAME, display).await?;
|
||||
|
||||
Redirect::new(format!("/admin/?lang={}", lang.code())).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET Handlers (protected)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -819,6 +1045,9 @@ struct SettingsForm {
|
||||
seo_keywords: String,
|
||||
turnstile_site_key: String,
|
||||
turnstile_secret_key: String,
|
||||
oidc_issuer_url: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
}
|
||||
|
||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
@@ -837,6 +1066,9 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
("seo_keywords", form.seo_keywords),
|
||||
("turnstile_site_key", form.turnstile_site_key),
|
||||
("turnstile_secret_key", form.turnstile_secret_key),
|
||||
("oidc_issuer_url", form.oidc_issuer_url),
|
||||
("oidc_client_id", form.oidc_client_id),
|
||||
("oidc_client_secret", form.oidc_client_secret),
|
||||
] {
|
||||
let k = key.to_string();
|
||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||
@@ -2055,6 +2287,8 @@ pub fn admin_router() -> Router {
|
||||
Route::with_handler_and_name("/logout", logout, "admin-logout"),
|
||||
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
|
||||
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
|
||||
Route::with_handler_and_name("/oidc/start", oidc_start, "admin-oidc-start"),
|
||||
Route::with_handler_and_name("/oidc/callback", oidc_callback, "admin-oidc-callback"),
|
||||
// Protected
|
||||
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
|
||||
Route::with_handler_and_name("/", admin_index, "admin-index"),
|
||||
|
||||
+15
@@ -137,6 +137,9 @@ pub struct Translations {
|
||||
pub settings_seo_keywords: &'static str,
|
||||
pub settings_turnstile_site_key: &'static str,
|
||||
pub settings_turnstile_secret_key: &'static str,
|
||||
pub settings_oidc_issuer_url: &'static str,
|
||||
pub settings_oidc_client_id: &'static str,
|
||||
pub settings_oidc_client_secret: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -151,6 +154,8 @@ pub struct Translations {
|
||||
pub login_title: &'static str,
|
||||
pub login_button: &'static str,
|
||||
pub login_error: &'static str,
|
||||
pub login_sso_button: &'static str,
|
||||
pub login_sso_error: &'static str,
|
||||
pub logout: &'static str,
|
||||
pub setup_title: &'static str,
|
||||
pub setup_description: &'static str,
|
||||
@@ -352,6 +357,9 @@ static RU: Translations = Translations {
|
||||
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
|
||||
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -386,6 +394,8 @@ static RU: Translations = Translations {
|
||||
login_title: "Вход в систему",
|
||||
login_button: "Войти",
|
||||
login_error: "Неверный логин или пароль.",
|
||||
login_sso_button: "Войти через SSO",
|
||||
login_sso_error: "Ошибка SSO-авторизации.",
|
||||
logout: "Выйти",
|
||||
setup_title: "Создание администратора",
|
||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||
@@ -557,6 +567,9 @@ static EN: Translations = Translations {
|
||||
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
|
||||
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
|
||||
settings_oidc_issuer_url: "OIDC — Issuer URL",
|
||||
settings_oidc_client_id: "OIDC — Client ID",
|
||||
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
@@ -591,6 +604,8 @@ static EN: Translations = Translations {
|
||||
login_title: "Sign In",
|
||||
login_button: "Sign In",
|
||||
login_error: "Invalid login or password.",
|
||||
login_sso_button: "Sign in with SSO",
|
||||
login_sso_error: "SSO authentication failed.",
|
||||
logout: "Sign Out",
|
||||
setup_title: "Create Administrator",
|
||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{% if let Some(err) = error.as_ref() %}
|
||||
<div class="notification is-danger is-light">{{ err }}</div>
|
||||
{% endif %}
|
||||
{% if oidc_enabled %}
|
||||
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||
{% else %}
|
||||
<form method="post" action="/admin/login/submit">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.users_login }}</label>
|
||||
@@ -49,6 +52,7 @@
|
||||
{% endif %}
|
||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user