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

This commit is contained in:
2026-05-18 23:50:34 +03:00
parent 71f444b9aa
commit 99e2cbc1f0
6 changed files with 284 additions and 2 deletions
Generated
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+4
View File
@@ -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>
+19
View File
@@ -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>