471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Request, State},
|
|
http::{HeaderMap, StatusCode, header},
|
|
middleware::Next,
|
|
response::{Html, IntoResponse, Redirect, Response},
|
|
Form,
|
|
};
|
|
use sha2::{Digest, Sha256};
|
|
use serde::Deserialize;
|
|
|
|
use base64::Engine;
|
|
use hmac::{Hmac, Mac};
|
|
use rand::RngCore;
|
|
use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType};
|
|
use openidconnect::reqwest::async_http_client;
|
|
use openidconnect::{
|
|
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
|
|
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
|
|
};
|
|
|
|
use super::{OidcState, WebState};
|
|
|
|
/// Cookie name used to store the session token.
|
|
const SESSION_COOKIE: &str = "furumi_session";
|
|
|
|
/// Compute SHA-256 of the token as hex string (stored in cookie, not raw token).
|
|
pub fn token_hash(token: &str) -> String {
|
|
let mut h = Sha256::new();
|
|
h.update(token.as_bytes());
|
|
format!("{:x}", h.finalize())
|
|
}
|
|
|
|
pub async fn require_auth(
|
|
State(state): State<WebState>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
// Auth disabled when token is empty
|
|
if state.token.is_empty() {
|
|
req.extensions_mut().insert(super::AuthUserInfo("Unauthenticated".to_string()));
|
|
return next.run(req).await;
|
|
}
|
|
|
|
let cookies = req
|
|
.headers()
|
|
.get(header::COOKIE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
|
|
let expected = token_hash(&state.token);
|
|
let mut authed_user = None;
|
|
for c in cookies.split(';') {
|
|
let c = c.trim();
|
|
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
|
if val == expected {
|
|
authed_user = Some("Master Token".to_string());
|
|
break;
|
|
} else if let Some(oidc) = &state.oidc {
|
|
if let Some(user) = verify_sso_cookie(&oidc.session_secret, val) {
|
|
authed_user = Some(user);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(user) = authed_user {
|
|
req.extensions_mut().insert(super::AuthUserInfo(user));
|
|
next.run(req).await
|
|
} else {
|
|
let uri = req.uri().path();
|
|
if uri.starts_with("/api/") {
|
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
|
} else {
|
|
Redirect::to("/login").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
type HmacSha256 = Hmac<sha2::Sha256>;
|
|
|
|
pub 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)
|
|
}
|
|
|
|
pub 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
|
|
}
|
|
}
|
|
|
|
/// GET /login — show login form.
|
|
pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
|
|
let token_enabled = !state.token.is_empty();
|
|
let oidc_enabled = state.oidc.is_some();
|
|
|
|
if !token_enabled && !oidc_enabled {
|
|
return Redirect::to("/").into_response();
|
|
}
|
|
|
|
let html = LOGIN_HTML.replace(
|
|
"<!-- OIDC_PLACEHOLDER -->",
|
|
if oidc_enabled {
|
|
r#"<div class="divider"><span>OR</span></div>
|
|
<a href="/auth/login" class="btn-oidc">Log in with Authentik (SSO)</a>"#
|
|
} else {
|
|
""
|
|
}
|
|
);
|
|
Html(html).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginForm {
|
|
password: String,
|
|
}
|
|
|
|
/// POST /login — validate password, set session cookie.
|
|
pub async fn login_submit(
|
|
State(state): State<WebState>,
|
|
Form(form): Form<LoginForm>,
|
|
) -> impl IntoResponse {
|
|
if state.token.is_empty() {
|
|
return Redirect::to("/").into_response();
|
|
}
|
|
|
|
if form.password == *state.token {
|
|
let hash = token_hash(&state.token);
|
|
let cookie = format!(
|
|
"{}={}; HttpOnly; SameSite=Strict; Path=/",
|
|
SESSION_COOKIE, hash
|
|
);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
|
headers.insert(header::LOCATION, "/".parse().unwrap());
|
|
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
|
} else {
|
|
Html(LOGIN_ERROR_HTML).into_response()
|
|
}
|
|
}
|
|
|
|
/// GET /logout — clear session cookie and redirect to login.
|
|
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()
|
|
}
|
|
|
|
pub async fn oidc_init(
|
|
issuer: String,
|
|
client_id: String,
|
|
client_secret: String,
|
|
redirect: 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 mut session_secret = vec![0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut session_secret);
|
|
|
|
Ok(OidcState {
|
|
client,
|
|
session_secret,
|
|
})
|
|
}
|
|
|
|
pub async fn oidc_login(
|
|
State(state): State<WebState>,
|
|
req: Request,
|
|
) -> impl IntoResponse {
|
|
let oidc = match &state.oidc {
|
|
Some(o) => o,
|
|
None => return Redirect::to("/login").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 cookie_val = format!("{}:{}:{}", csrf_token.secret(), nonce.secret(), pkce_verifier.secret());
|
|
|
|
// Determine if we are running behind an HTTPS proxy
|
|
let is_https = req.headers().get("x-forwarded-proto")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s == "https")
|
|
.unwrap_or(false);
|
|
|
|
// If HTTPS, use SameSite=None + Secure to fully support cross-domain POST redirects.
|
|
// Otherwise fallback to Lax for local testing.
|
|
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,
|
|
}
|
|
|
|
pub async fn oidc_callback(
|
|
State(state): State<WebState>,
|
|
axum::extract::Query(query): axum::extract::Query<AuthCallbackQuery>,
|
|
req: Request,
|
|
) -> impl IntoResponse {
|
|
let oidc = match &state.oidc {
|
|
Some(o) => o,
|
|
None => return Redirect::to("/login").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 failed: Invalid state or missing valid cookie. Received cookies: {}", cookies);
|
|
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 exchange code error: {:?}", e);
|
|
if let openidconnect::RequestTokenError::ServerResponse(err) = &e {
|
|
tracing::error!("OIDC Server returned error: {:?}", err);
|
|
}
|
|
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 subject = claims.subject().as_str();
|
|
let session_val = generate_sso_cookie(&oidc.session_secret, subject);
|
|
|
|
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=/", SESSION_COOKIE, session_val, session_attrs);
|
|
let clear_state_cookie = "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_cookie.parse().unwrap());
|
|
headers.insert(header::LOCATION, "/".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);
|
|
}
|
|
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
|
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
|
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
|
input[type=password] {
|
|
width: 100%; padding: 0.6rem 0.8rem;
|
|
background: #0d0f14; border: 1px solid #2a3347; border-radius: 8px;
|
|
color: #e2e8f0; font-size: 0.95rem; outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
input[type=password]:focus { border-color: #7c6af7; }
|
|
button {
|
|
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
|
|
background: #7c6af7; border: none; border-radius: 8px;
|
|
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
button:hover { background: #6b58e8; }
|
|
.btn-oidc {
|
|
display: block; width: 100%; padding: 0.65rem; text-align: center;
|
|
background: #2a3347; border: 1px solid #3d4a66; border-radius: 8px;
|
|
color: #e2e8f0; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
|
transition: background 0.2s;
|
|
}
|
|
.btn-oidc:hover { background: #3d4a66; }
|
|
.divider {
|
|
display: flex; align-items: center; text-align: center; margin: 1.5rem 0;
|
|
color: #64748b; font-size: 0.75rem;
|
|
}
|
|
.divider::before, .divider::after {
|
|
content: ''; flex: 1; border-bottom: 1px solid #2a3347;
|
|
}
|
|
.divider span { padding: 0 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="logo">🎵 Furumi</div>
|
|
<div class="subtitle">Enter access token to continue</div>
|
|
<form method="POST" action="/login">
|
|
<label for="password">Access Token</label>
|
|
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
|
<button type="submit">Sign In</button>
|
|
</form>
|
|
<!-- OIDC_PLACEHOLDER -->
|
|
</div>
|
|
</body>
|
|
</html>"#;
|
|
|
|
const LOGIN_ERROR_HTML: &str = r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<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);
|
|
}
|
|
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
|
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
|
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 1rem; }
|
|
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
|
input[type=password] {
|
|
width: 100%; padding: 0.6rem 0.8rem;
|
|
background: #0d0f14; border: 1px solid #f87171; border-radius: 8px;
|
|
color: #e2e8f0; font-size: 0.95rem; outline: none;
|
|
}
|
|
button {
|
|
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
|
|
background: #7c6af7; border: none; border-radius: 8px;
|
|
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
|
}
|
|
button:hover { background: #6b58e8; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="logo">🎵 Furumi</div>
|
|
<div class="subtitle">Enter access token to continue</div>
|
|
<p class="error">❌ Invalid token. Please try again.</p>
|
|
<form method="POST" action="/login">
|
|
<label for="password">Access Token</label>
|
|
<input type="password" id="password" name="password" autofocus>
|
|
<button type="submit">Sign In</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>"#;
|