New player
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
This commit is contained in:
384
furumi-web-player/src/web/auth.rs
Normal file
384
furumi-web-player/src/web/auth.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use openidconnect::{
|
||||
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||
reqwest::async_http_client,
|
||||
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
|
||||
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use serde::Deserialize;
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
|
||||
use super::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SESSION_COOKIE: &str = "furumi_session";
|
||||
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
pub struct OidcState {
|
||||
pub client: CoreClient,
|
||||
pub session_secret: Vec<u8>,
|
||||
}
|
||||
|
||||
pub async fn oidc_init(
|
||||
issuer: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect: String,
|
||||
session_secret_override: Option<String>,
|
||||
) -> anyhow::Result<OidcState> {
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(issuer)?,
|
||||
async_http_client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(client_id),
|
||||
Some(ClientSecret::new(client_secret)),
|
||||
)
|
||||
.set_auth_type(openidconnect::AuthType::RequestBody)
|
||||
.set_redirect_uri(RedirectUrl::new(redirect)?);
|
||||
|
||||
let session_secret = if let Some(s) = session_secret_override {
|
||||
let mut b = s.into_bytes();
|
||||
b.resize(32, 0);
|
||||
b
|
||||
} else {
|
||||
let mut b = vec![0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut b);
|
||||
b
|
||||
};
|
||||
|
||||
Ok(OidcState {
|
||||
client,
|
||||
session_secret,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(user_id.as_bytes());
|
||||
let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
format!("sso:{}:{}", user_id, sig)
|
||||
}
|
||||
|
||||
fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = cookie_val.split(':').collect();
|
||||
if parts.len() != 3 || parts[0] != "sso" {
|
||||
return None;
|
||||
}
|
||||
let user_id = parts[1];
|
||||
let sig = parts[2];
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(user_id.as_bytes());
|
||||
|
||||
let expected_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
if sig == expected_sig {
|
||||
Some(user_id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth middleware: requires valid SSO session cookie.
|
||||
pub async fn require_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let oidc = match &state.oidc {
|
||||
Some(o) => o,
|
||||
None => return next.run(req).await, // No OIDC configured = no auth
|
||||
};
|
||||
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
for c in cookies.split(';') {
|
||||
let c = c.trim();
|
||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uri = req.uri().to_string();
|
||||
if uri.starts_with("/api/") {
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /login — show SSO login page.
|
||||
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
if state.oidc.is_none() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
|
||||
Html(LOGIN_HTML).into_response()
|
||||
}
|
||||
|
||||
/// GET /logout — clear session cookie.
|
||||
pub async fn logout() -> impl IntoResponse {
|
||||
let cookie = format!(
|
||||
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
SESSION_COOKIE
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
||||
headers.insert(header::LOCATION, "/login".parse().unwrap());
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery {
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /auth/login — initiate OIDC flow.
|
||||
pub async fn oidc_login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
|
||||
req: Request,
|
||||
) -> impl IntoResponse {
|
||||
let oidc = match &state.oidc {
|
||||
Some(o) => o,
|
||||
None => return Redirect::to("/").into_response(),
|
||||
};
|
||||
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_token, nonce) = oidc
|
||||
.client
|
||||
.authorize_url(
|
||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
let next_url = query.next.unwrap_or_else(|| "/".to_string());
|
||||
let cookie_val = format!(
|
||||
"{}:{}:{}:{}",
|
||||
csrf_token.secret(),
|
||||
nonce.secret(),
|
||||
pkce_verifier.secret(),
|
||||
urlencoding::encode(&next_url)
|
||||
);
|
||||
|
||||
let is_https = req
|
||||
.headers()
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s == "https")
|
||||
.unwrap_or(false);
|
||||
|
||||
let cookie_attrs = if is_https {
|
||||
"SameSite=None; Secure"
|
||||
} else {
|
||||
"SameSite=Lax"
|
||||
};
|
||||
|
||||
let cookie = format!(
|
||||
"furumi_oidc_state={}; HttpOnly; {}; Path=/; Max-Age=3600",
|
||||
cookie_val, cookie_attrs
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
||||
headers.insert(header::LOCATION, auth_url.as_str().parse().unwrap());
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthCallbackQuery {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
/// GET /auth/callback — handle OIDC callback.
|
||||
pub async fn oidc_callback(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Query(query): axum::extract::Query<AuthCallbackQuery>,
|
||||
req: Request,
|
||||
) -> impl IntoResponse {
|
||||
let oidc = match &state.oidc {
|
||||
Some(o) => o,
|
||||
None => return Redirect::to("/").into_response(),
|
||||
};
|
||||
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let mut matching_val = None;
|
||||
for c in cookies.split(';') {
|
||||
let c = c.trim();
|
||||
if let Some(val) = c.strip_prefix("furumi_oidc_state=") {
|
||||
let parts: Vec<&str> = val.split(':').collect();
|
||||
if parts.len() >= 3 && parts[0] == query.state {
|
||||
matching_val = Some(val.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cookie_val = match matching_val {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!("OIDC callback: invalid state or missing cookie");
|
||||
return (StatusCode::BAD_REQUEST, "Invalid state").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = cookie_val.split(':').collect();
|
||||
let nonce = Nonce::new(parts[1].to_string());
|
||||
let pkce_verifier = PkceCodeVerifier::new(parts[2].to_string());
|
||||
|
||||
let token_response = oidc
|
||||
.client
|
||||
.exchange_code(AuthorizationCode::new(query.code))
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(async_http_client)
|
||||
.await;
|
||||
|
||||
let token_response = match token_response {
|
||||
Ok(tr) => tr,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token exchange error: {:?}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("OIDC error: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let id_token = match token_response.id_token() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "No ID token").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let claims = match id_token.claims(&oidc.client.id_token_verifier(), &nonce) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = claims
|
||||
.preferred_username()
|
||||
.map(|u| u.to_string())
|
||||
.or_else(|| claims.email().map(|e| e.to_string()))
|
||||
.unwrap_or_else(|| claims.subject().to_string());
|
||||
|
||||
let session_val = generate_sso_cookie(&oidc.session_secret, &user_id);
|
||||
|
||||
let redirect_to = parts
|
||||
.get(3)
|
||||
.and_then(|&s| urlencoding::decode(s).ok())
|
||||
.map(|v| v.into_owned())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
let redirect_to = if redirect_to.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
redirect_to
|
||||
};
|
||||
|
||||
let is_https = req
|
||||
.headers()
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s == "https")
|
||||
.unwrap_or(false);
|
||||
|
||||
let session_attrs = if is_https {
|
||||
"SameSite=Strict; Secure"
|
||||
} else {
|
||||
"SameSite=Strict"
|
||||
};
|
||||
|
||||
let session_cookie = format!(
|
||||
"{}={}; HttpOnly; {}; Path=/; Max-Age=604800",
|
||||
SESSION_COOKIE, session_val, session_attrs
|
||||
);
|
||||
let clear_state =
|
||||
"furumi_oidc_state=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap());
|
||||
headers.append(header::SET_COOKIE, clear_state.parse().unwrap());
|
||||
headers.insert(header::LOCATION, redirect_to.parse().unwrap());
|
||||
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
|
||||
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Furumi Player — Login</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0d0f14;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.card {
|
||||
background: #161b27;
|
||||
border: 1px solid #2a3347;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 3rem;
|
||||
width: 360px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
||||
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
||||
.btn-sso {
|
||||
display: block; width: 100%; padding: 0.75rem; text-align: center;
|
||||
background: #7c6af7; border: none; border-radius: 8px;
|
||||
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn-sso:hover { background: #6b58e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Furumi</div>
|
||||
<div class="subtitle">Sign in to continue</div>
|
||||
<a href="/auth/login" class="btn-sso">SSO Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
Reference in New Issue
Block a user