Added web player
This commit is contained in:
213
furumi-server/src/web/auth.rs
Normal file
213
furumi-server/src/web/auth.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
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 super::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())
|
||||
}
|
||||
|
||||
/// axum middleware: if token is configured, requires a valid session cookie.
|
||||
pub async fn require_auth(
|
||||
State(state): State<WebState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Auth disabled when token is empty
|
||||
if state.token.is_empty() {
|
||||
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 authed = cookies
|
||||
.split(';')
|
||||
.any(|c| c.trim() == format!("{}={}", SESSION_COOKIE, expected));
|
||||
|
||||
if authed {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /login — show login form.
|
||||
pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
|
||||
if state.token.is_empty() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
Html(LOGIN_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()
|
||||
}
|
||||
|
||||
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; }
|
||||
</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>
|
||||
</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>"#;
|
||||
Reference in New Issue
Block a user