Improve web UI
This commit is contained in:
@@ -1,29 +1,36 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
extract::{Form, Request, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
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::{
|
||||
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 sha2::{Digest, Sha256};
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
|
||||
use super::{OidcState, WebState};
|
||||
|
||||
/// Cookie name used to store the session token.
|
||||
const SESSION_COOKIE: &str = "furumi_session";
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -69,11 +76,12 @@ pub async fn require_auth(
|
||||
req.extensions_mut().insert(super::AuthUserInfo(user));
|
||||
next.run(req).await
|
||||
} else {
|
||||
let uri = req.uri().path();
|
||||
let uri = req.uri().to_string();
|
||||
if uri.starts_with("/api/") {
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
let redirect_url = format!("/login?next={}", urlencoding::encode(&uri));
|
||||
Redirect::to(&redirect_url).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,8 +114,16 @@ pub fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery {
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /login — show login form.
|
||||
pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
|
||||
pub async fn login_page(
|
||||
State(state): State<WebState>,
|
||||
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let token_enabled = !state.token.is_empty();
|
||||
let oidc_enabled = state.oidc.is_some();
|
||||
|
||||
@@ -115,21 +131,31 @@ pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
|
||||
let html = LOGIN_HTML.replace(
|
||||
"<!-- OIDC_PLACEHOLDER -->",
|
||||
if oidc_enabled {
|
||||
let next_val = query.next.unwrap_or_else(|| "/".to_string());
|
||||
let next_encoded = urlencoding::encode(&next_val);
|
||||
|
||||
let oidc_html = if oidc_enabled {
|
||||
format!(
|
||||
r#"<div class="divider"><span>OR</span></div>
|
||||
<a href="/auth/login" class="btn-oidc">Log in with Authentik (SSO)</a>"#
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
<a href="/auth/login?next={}" class="btn-oidc">Log in with Authentik (SSO)</a>"#,
|
||||
next_encoded
|
||||
)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let next_input = format!(r#"<input type="hidden" name="next" value="{}">"#, esc(&next_val));
|
||||
|
||||
let html = LOGIN_HTML
|
||||
.replace("<!-- OIDC_PLACEHOLDER -->", &oidc_html)
|
||||
.replace("<!-- NEXT_INPUT_PLACEHOLDER -->", &next_input);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
password: String,
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /login — validate password, set session cookie.
|
||||
@@ -144,12 +170,13 @@ pub async fn login_submit(
|
||||
if form.password == *state.token {
|
||||
let hash = token_hash(&state.token);
|
||||
let cookie = format!(
|
||||
"{}={}; HttpOnly; SameSite=Strict; Path=/",
|
||||
"{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
|
||||
SESSION_COOKIE, hash
|
||||
);
|
||||
let redirect_to = form.next.as_deref().unwrap_or("/");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
||||
headers.insert(header::LOCATION, "/".parse().unwrap());
|
||||
headers.insert(header::LOCATION, redirect_to.parse().unwrap());
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
} else {
|
||||
Html(LOGIN_ERROR_HTML).into_response()
|
||||
@@ -173,6 +200,7 @@ pub async fn oidc_init(
|
||||
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)?,
|
||||
@@ -188,8 +216,15 @@ pub async fn oidc_init(
|
||||
.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);
|
||||
let session_secret = if let Some(s) = session_secret_override {
|
||||
let mut b = s.into_bytes();
|
||||
b.resize(32, 0); // Ensure at least 32 bytes for HMAC-SHA256
|
||||
b
|
||||
} else {
|
||||
let mut b = vec![0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut b);
|
||||
b
|
||||
};
|
||||
|
||||
Ok(OidcState {
|
||||
client,
|
||||
@@ -199,6 +234,7 @@ pub async fn oidc_init(
|
||||
|
||||
pub async fn oidc_login(
|
||||
State(state): State<WebState>,
|
||||
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
|
||||
req: Request,
|
||||
) -> impl IntoResponse {
|
||||
let oidc = match &state.oidc {
|
||||
@@ -220,7 +256,8 @@ pub async fn oidc_login(
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
let cookie_val = format!("{}:{}:{}", csrf_token.secret(), nonce.secret(), pkce_verifier.secret());
|
||||
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));
|
||||
|
||||
// Determine if we are running behind an HTTPS proxy
|
||||
let is_https = req.headers().get("x-forwarded-proto")
|
||||
@@ -320,8 +357,20 @@ pub async fn oidc_callback(
|
||||
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 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 parts: Vec<&str> = cookie_val.split(':').collect();
|
||||
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())
|
||||
@@ -334,13 +383,13 @@ pub async fn oidc_callback(
|
||||
"SameSite=Strict"
|
||||
};
|
||||
|
||||
let session_cookie = format!("{}={}; HttpOnly; {}; Path=/", SESSION_COOKIE, session_val, session_attrs);
|
||||
let session_cookie = format!("{}={}; HttpOnly; {}; Path=/; Max-Age=604800", 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());
|
||||
headers.insert(header::LOCATION, redirect_to.parse().unwrap());
|
||||
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
@@ -407,6 +456,7 @@ const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
||||
<div class="logo">🎵 Furumi</div>
|
||||
<div class="subtitle">Enter access token to continue</div>
|
||||
<form method="POST" action="/login">
|
||||
<!-- NEXT_INPUT_PLACEHOLDER -->
|
||||
<label for="password">Access Token</label>
|
||||
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit">Sign In</button>
|
||||
@@ -461,6 +511,7 @@ const LOGIN_ERROR_HTML: &str = r#"<!DOCTYPE html>
|
||||
<div class="subtitle">Enter access token to continue</div>
|
||||
<p class="error">❌ Invalid token. Please try again.</p>
|
||||
<form method="POST" action="/login">
|
||||
<!-- NEXT_INPUT_PLACEHOLDER -->
|
||||
<label for="password">Access Token</label>
|
||||
<input type="password" id="password" name="password" autofocus>
|
||||
<button type="submit">Sign In</button>
|
||||
|
||||
Reference in New Issue
Block a user