diff --git a/Cargo.lock b/Cargo.lock index aad3851..b179519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index b39c65b..32d5a9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/admin.rs b/src/admin.rs index 6702790..fc39b73 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -194,6 +194,7 @@ struct LoginTemplate<'a> { lang: Lang, error: Option, 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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::(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::(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 { @@ -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"), diff --git a/src/i18n.rs b/src/i18n.rs index f17a151..f0be409 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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.", diff --git a/templates/admin/login.html b/templates/admin/login.html index 3e8467b..64a8d22 100644 --- a/templates/admin/login.html +++ b/templates/admin/login.html @@ -35,6 +35,9 @@ {% if let Some(err) = error.as_ref() %}
{{ err }}
{% endif %} + {% if oidc_enabled %} + {{ t.login_sso_button }} + {% else %}
@@ -49,6 +52,7 @@ {% endif %} + {% endif %}
diff --git a/templates/admin/settings.html b/templates/admin/settings.html index ff50f23..658e778 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -68,6 +68,25 @@ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+