feat: migrate to stateless server functions for auth with jwt and shadcn ui
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
This commit is contained in:
@@ -45,4 +45,5 @@ governor = "0.10.4"
|
|||||||
|
|
||||||
# Leptos
|
# Leptos
|
||||||
leptos = { version = "0.8.15", features = ["nightly"] }
|
leptos = { version = "0.8.15", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.8.7" }
|
leptos_axum = { version = "0.8.7" }
|
||||||
|
jsonwebtoken = "9"
|
||||||
@@ -55,10 +55,9 @@ async fn auth_middleware(
|
|||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Skip auth for public paths
|
// Skip auth for public paths
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
if path.starts_with("/api/auth/login")
|
if path.starts_with("/api/server_fns/Login") // Login server fn
|
||||||
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|
|| path.starts_with("/api/server_fns/GetSetupStatus")
|
||||||
|| path.starts_with("/api/setup")
|
|| path.starts_with("/api/server_fns/Setup")
|
||||||
|| path.starts_with("/api/server_fns")
|
|
||||||
|| path.starts_with("/swagger-ui")
|
|| path.starts_with("/swagger-ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| !path.starts_with("/api/") // Allow static files (frontend)
|
|| !path.starts_with("/api/") // Allow static files (frontend)
|
||||||
@@ -68,9 +67,19 @@ async fn auth_middleware(
|
|||||||
|
|
||||||
// Check token
|
// Check token
|
||||||
if let Some(token) = jar.get("auth_token") {
|
if let Some(token) = jar.get("auth_token") {
|
||||||
match state.db.get_session_user(token.value()).await {
|
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||||
Ok(Some(_)) => return Ok(next.run(request).await),
|
use shared::server_fns::auth::Claims;
|
||||||
_ => {} // Invalid
|
|
||||||
|
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||||
|
let validation = Validation::default();
|
||||||
|
|
||||||
|
match decode::<Claims>(
|
||||||
|
token.value(),
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&validation,
|
||||||
|
) {
|
||||||
|
Ok(_) => return Ok(next.run(request).await),
|
||||||
|
Err(_) => {} // Invalid token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,14 +442,6 @@ async fn main() {
|
|||||||
let app = app
|
let app = app
|
||||||
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
|
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
|
||||||
.route("/api/setup", post(handlers::setup::setup_handler))
|
.route("/api/setup", post(handlers::setup::setup_handler))
|
||||||
.route(
|
|
||||||
"/api/auth/login",
|
|
||||||
post(handlers::auth::login_handler).layer(GovernorLayer::new(Arc::new(
|
|
||||||
rate_limit::get_login_rate_limit_config(),
|
|
||||||
))),
|
|
||||||
)
|
|
||||||
.route("/api/auth/logout", post(handlers::auth::logout_handler))
|
|
||||||
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
|
|
||||||
.route("/api/events", get(sse::sse_handler))
|
.route("/api/events", get(sse::sse_handler))
|
||||||
.route("/api/server_fns/{*fn_name}", post({
|
.route("/api/server_fns/{*fn_name}", post({
|
||||||
let scgi_path = scgi_path_for_ctx.clone();
|
let scgi_path = scgi_path_for_ctx.clone();
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use crate::api;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let username = signal(String::new());
|
let username = signal(String::new());
|
||||||
let password = signal(String::new());
|
let password = signal(String::new());
|
||||||
let remember_me = signal(false);
|
|
||||||
let error = signal(Option::<String>::None);
|
let error = signal(Option::<String>::None);
|
||||||
let loading = signal(false);
|
let loading = signal(false);
|
||||||
|
|
||||||
@@ -17,12 +15,11 @@ pub fn Login() -> impl IntoView {
|
|||||||
|
|
||||||
let user = username.0.get();
|
let user = username.0.get();
|
||||||
let pass = password.0.get();
|
let pass = password.0.get();
|
||||||
let rem = remember_me.0.get();
|
|
||||||
|
|
||||||
log::info!("Attempting login for user: {}", user);
|
log::info!("Attempting login for user: {}", user);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::auth::login(&user, &pass, rem).await {
|
match shared::server_fns::auth::login(user, pass).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Login successful, redirecting...");
|
log::info!("Login successful, redirecting...");
|
||||||
let window = web_sys::window().expect("window should exist");
|
let window = web_sys::window().expect("window should exist");
|
||||||
@@ -38,43 +35,43 @@ pub fn Login() -> impl IntoView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="flex items-center justify-center min-h-screen bg-base-200">
|
<div class="flex items-center justify-center min-h-screen bg-muted/40">
|
||||||
<div class="card w-full max-w-sm shadow-xl bg-base-100">
|
<div class="w-full max-w-sm rounded-xl border border-border bg-card text-card-foreground shadow-lg">
|
||||||
<div class="card-body">
|
<div class="flex flex-col space-y-1.5 p-6 pb-2 items-center">
|
||||||
<div class="flex flex-col items-center mb-6">
|
<div class="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-primary-foreground shadow-sm mb-4">
|
||||||
<div class="w-16 h-16 bg-primary rounded-2xl flex items-center justify-center text-primary-content shadow-lg mb-4">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.25 0 003 2.48z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
|
</svg>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="card-title text-2xl font-bold">"VibeTorrent"</h2>
|
|
||||||
<p class="text-base-content/60 text-sm">"Hesabınıza giriş yapın"</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="font-semibold tracking-tight text-2xl">"VibeTorrent"</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">"Hesabınıza giriş yapın"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 pt-4">
|
||||||
<form on:submit=handle_login class="space-y-4">
|
<form on:submit=handle_login class="space-y-4">
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
<span class="label-text">"Kullanıcı Adı"</span>
|
"Kullanıcı Adı"
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Kullanıcı adınız"
|
placeholder="Kullanıcı adınız"
|
||||||
class="input input-bordered w-full"
|
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
prop:value=move || username.0.get()
|
prop:value=move || username.0.get()
|
||||||
on:input=move |ev| username.1.set(event_target_value(&ev))
|
on:input=move |ev| username.1.set(event_target_value(&ev))
|
||||||
disabled=move || loading.0.get()
|
disabled=move || loading.0.get()
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<label class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
<span class="label-text">"Şifre"</span>
|
"Şifre"
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
class="input input-bordered w-full"
|
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
prop:value=move || password.0.get()
|
prop:value=move || password.0.get()
|
||||||
on:input=move |ev| password.1.set(event_target_value(&ev))
|
on:input=move |ev| password.1.set(event_target_value(&ev))
|
||||||
disabled=move || loading.0.get()
|
disabled=move || loading.0.get()
|
||||||
@@ -82,32 +79,21 @@ pub fn Login() -> impl IntoView {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-primary checkbox-sm"
|
|
||||||
prop:checked=move || remember_me.0.get()
|
|
||||||
on:change=move |ev| remember_me.1.set(event_target_checked(&ev))
|
|
||||||
/>
|
|
||||||
<span class="label-text">"Beni hatırla"</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
<Show when=move || error.0.get().is_some() fallback=|| ()>
|
||||||
<div class="alert alert-error text-xs py-2 shadow-sm">
|
<div class="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive dark:border-destructive dark:bg-destructive/50 dark:text-destructive-foreground">
|
||||||
<span>{move || error.0.get().unwrap_or_default()}</span>
|
<span>{move || error.0.get().unwrap_or_default()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="form-control mt-6">
|
<div class="pt-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary w-full"
|
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 w-full"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled=move || loading.0.get()
|
disabled=move || loading.0.get()
|
||||||
>
|
>
|
||||||
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
<Show when=move || loading.0.get() fallback=|| "Giriş Yap">
|
||||||
<span class="loading loading-spinner"></span>
|
<span class="animate-spin mr-2 h-4 w-4 border-2 border-current border-t-transparent rounded-full"></span>
|
||||||
|
"Giriş Yapılıyor..."
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,4 +37,26 @@ quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = tr
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true }
|
||||||
anyhow = { version = "1.0", optional = true }
|
anyhow = { version = "1.0", optional = true }
|
||||||
|
|
||||||
|
# Auth (SSR)
|
||||||
|
jsonwebtoken = { version = "9", optional = true }
|
||||||
|
cookie = { version = "0.18", features = ["percent-encode"], optional = true }
|
||||||
|
bcrypt = { version = "0.17", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = [
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:bytes",
|
||||||
|
"dep:thiserror",
|
||||||
|
"dep:quick-xml",
|
||||||
|
"dep:leptos_axum",
|
||||||
|
"dep:sqlx",
|
||||||
|
"dep:anyhow",
|
||||||
|
"dep:jsonwebtoken",
|
||||||
|
"dep:cookie",
|
||||||
|
"dep:bcrypt",
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
]
|
||||||
129
shared/src/server_fns/auth.rs
Normal file
129
shared/src/server_fns/auth.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // username
|
||||||
|
pub uid: i64, // user id
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(Login, "/api/auth/login")]
|
||||||
|
pub async fn login(username: String, password: String) -> Result<UserResponse, ServerFnError> {
|
||||||
|
use crate::DbContext;
|
||||||
|
use leptos_axum::ResponseOptions;
|
||||||
|
use jsonwebtoken::{encode, Header, EncodingKey};
|
||||||
|
use cookie::{Cookie, SameSite};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
let db_context = use_context::<DbContext>().ok_or(ServerFnError::ServerError("DB Context missing".to_string()))?;
|
||||||
|
|
||||||
|
let user_opt = db_context.db.get_user_by_username(&username).await
|
||||||
|
.map_err(|e| ServerFnError::ServerError(format!("DB error: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some((uid, password_hash)) = user_opt {
|
||||||
|
let valid = bcrypt::verify(&password, &password_hash).unwrap_or(false);
|
||||||
|
if !valid {
|
||||||
|
return Err(ServerFnError::ServerError("Invalid credentials".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as usize + 24 * 3600; // 24 hours
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: username.clone(),
|
||||||
|
uid,
|
||||||
|
exp: expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||||
|
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
|
||||||
|
.map_err(|e| ServerFnError::ServerError(format!("Token error: {}", e)))?;
|
||||||
|
|
||||||
|
let cookie = Cookie::build(("auth_token", token))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Strict)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(options) = use_context::<ResponseOptions>() {
|
||||||
|
options.insert_header(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UserResponse {
|
||||||
|
id: uid,
|
||||||
|
username,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(ServerFnError::ServerError("Invalid credentials".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(Logout, "/api/auth/logout")]
|
||||||
|
pub async fn logout() -> Result<(), ServerFnError> {
|
||||||
|
use leptos_axum::ResponseOptions;
|
||||||
|
use cookie::{Cookie, SameSite};
|
||||||
|
|
||||||
|
let cookie = Cookie::build(("auth_token", ""))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Strict)
|
||||||
|
.max_age(cookie::time::Duration::seconds(0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(options) = use_context::<ResponseOptions>() {
|
||||||
|
options.insert_header(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
axum::http::HeaderValue::from_str(&cookie.to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(GetUser, "/api/auth/user")]
|
||||||
|
pub async fn get_user() -> Result<Option<UserResponse>, ServerFnError> {
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use jsonwebtoken::{decode, Validation, DecodingKey};
|
||||||
|
|
||||||
|
let headers: HeaderMap = extract().await?;
|
||||||
|
let cookie_header = headers.get(axum::http::header::COOKIE)
|
||||||
|
.and_then(|h| h.to_str().ok());
|
||||||
|
|
||||||
|
if let Some(cookie_str) = cookie_header {
|
||||||
|
// Parse all cookies
|
||||||
|
for c_str in cookie_str.split(';') {
|
||||||
|
if let Ok(c) = cookie::Cookie::parse(c_str.trim()) {
|
||||||
|
if c.name() == "auth_token" {
|
||||||
|
let token = c.value();
|
||||||
|
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||||
|
let token_data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Ok(data) = token_data {
|
||||||
|
return Ok(Some(UserResponse {
|
||||||
|
id: data.claims.uid,
|
||||||
|
username: data.claims.sub,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod push;
|
pub mod push;
|
||||||
|
pub mod auth;
|
||||||
Reference in New Issue
Block a user