mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 09:54:41 -05:00
move several complex examples into projects
This commit is contained in:
12
projects/login_with_token_csr_only/Cargo.toml
Normal file
12
projects/login_with_token_csr_only/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["client", "api-boundary", "server"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[patch.crates-io]
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_router = { path = "../../router" }
|
||||
api-boundary = { path = "api-boundary" }
|
||||
1
projects/login_with_token_csr_only/Makefile.toml
Normal file
1
projects/login_with_token_csr_only/Makefile.toml
Normal file
@@ -0,0 +1 @@
|
||||
extend = { path = "../cargo-make/main.toml" }
|
||||
13
projects/login_with_token_csr_only/README.md
Normal file
13
projects/login_with_token_csr_only/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Leptos Login Example
|
||||
|
||||
This example demonstrates a scenario of a client-side rendered application
|
||||
that uses an existing API that you cannot or do not want to change.
|
||||
The authentications of this example are done using an API token.
|
||||
|
||||
The `api-boundary` crate contains data structures that are used by the server and the client.
|
||||
|
||||
## Getting Started
|
||||
|
||||
See the [Examples README](../README.md) for setup and run instructions.
|
||||
|
||||
You will also need to run `cargo make stop` to end the server process.
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "api-boundary"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -0,0 +1,4 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.check-format]
|
||||
env = { LEPTOS_PROJECT_DIRECTORY = "../../../" }
|
||||
22
projects/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
22
projects/login_with_token_csr_only/api-boundary/src/lib.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ApiToken {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
}
|
||||
20
projects/login_with_token_csr_only/client/Cargo.toml
Normal file
20
projects/login_with_token_csr_only/client/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
api-boundary = "*"
|
||||
|
||||
leptos = { path = "../../../leptos", features = ["csr"] }
|
||||
leptos_meta = { path = "../../../meta", features = ["csr"] }
|
||||
leptos_router = { path = "../../../router", features = ["csr"] }
|
||||
|
||||
log = "0.4"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
gloo-net = "0.6"
|
||||
gloo-storage = "0.3"
|
||||
serde = "1.0"
|
||||
thiserror = "1.0"
|
||||
16
projects/login_with_token_csr_only/client/Makefile.toml
Normal file
16
projects/login_with_token_csr_only/client/Makefile.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
extend = [
|
||||
{ path = "../../cargo-make/main.toml" },
|
||||
{ path = "../../cargo-make/trunk_server.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
SERVER_PROCESS_NAME = "server"
|
||||
|
||||
[tasks.check-format]
|
||||
env = { LEPTOS_PROJECT_DIRECTORY = "../../../" }
|
||||
|
||||
[tasks.start-server]
|
||||
cwd = "../server"
|
||||
script = '''
|
||||
cargo run &
|
||||
'''
|
||||
3
projects/login_with_token_csr_only/client/Trunk.toml
Normal file
3
projects/login_with_token_csr_only/client/Trunk.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[[proxy]]
|
||||
rewrite = "/api/"
|
||||
backend = "http://127.0.0.1:3000/"
|
||||
7
projects/login_with_token_csr_only/client/index.html
Normal file
7
projects/login_with_token_csr_only/client/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
93
projects/login_with_token_csr_only/client/src/api.rs
Normal file
93
projects/login_with_token_csr_only/client/src/api.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use api_boundary::*;
|
||||
use gloo_net::http::{Request, RequestBuilder, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UnauthorizedApi {
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthorizedApi {
|
||||
url: &'static str,
|
||||
token: ApiToken,
|
||||
}
|
||||
|
||||
impl UnauthorizedApi {
|
||||
pub const fn new(url: &'static str) -> Self {
|
||||
Self { url }
|
||||
}
|
||||
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
|
||||
let url = format!("{}/users", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn login(
|
||||
&self,
|
||||
credentials: &Credentials,
|
||||
) -> Result<AuthorizedApi> {
|
||||
let url = format!("{}/login", self.url);
|
||||
let response = Request::post(&url).json(credentials)?.send().await?;
|
||||
let token = into_json(response).await?;
|
||||
Ok(AuthorizedApi::new(self.url, token))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthorizedApi {
|
||||
pub const fn new(url: &'static str, token: ApiToken) -> Self {
|
||||
Self { url, token }
|
||||
}
|
||||
fn auth_header_value(&self) -> String {
|
||||
format!("Bearer {}", self.token.token)
|
||||
}
|
||||
async fn send<T>(&self, req: RequestBuilder) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response = req
|
||||
.header("Authorization", &self.auth_header_value())
|
||||
.send()
|
||||
.await?;
|
||||
into_json(response).await
|
||||
}
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
let url = format!("{}/logout", self.url);
|
||||
self.send(Request::post(&url)).await
|
||||
}
|
||||
pub async fn user_info(&self) -> Result<UserInfo> {
|
||||
let url = format!("{}/users", self.url);
|
||||
self.send(Request::get(&url)).await
|
||||
}
|
||||
pub fn token(&self) -> &ApiToken {
|
||||
&self.token
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Fetch(#[from] gloo_net::Error),
|
||||
#[error("{0:?}")]
|
||||
Api(api_boundary::Error),
|
||||
}
|
||||
|
||||
impl From<api_boundary::Error> for Error {
|
||||
fn from(e: api_boundary::Error) -> Self {
|
||||
Self::Api(e)
|
||||
}
|
||||
}
|
||||
|
||||
async fn into_json<T>(response: Response) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
// ensure we've got 2xx status
|
||||
if response.ok() {
|
||||
Ok(response.json().await?)
|
||||
} else {
|
||||
Err(response.json::<api_boundary::Error>().await?.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn CredentialsForm(
|
||||
title: &'static str,
|
||||
action_label: &'static str,
|
||||
action: Action<(String, String), ()>,
|
||||
error: Signal<Option<String>>,
|
||||
disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let (password, set_password) = create_signal(String::new());
|
||||
let (email, set_email) = create_signal(String::new());
|
||||
|
||||
let dispatch_action =
|
||||
move || action.dispatch((email.get(), password.get()));
|
||||
|
||||
let button_is_disabled = Signal::derive(move || {
|
||||
disabled.get() || password.get().is_empty() || email.get().is_empty()
|
||||
});
|
||||
|
||||
view! {
|
||||
<form on:submit=|ev| ev.prevent_default()>
|
||||
<p>{title}</p>
|
||||
{move || {
|
||||
error
|
||||
.get()
|
||||
.map(|err| {
|
||||
view! { <p style="color:red;">{err}</p> }
|
||||
})
|
||||
}}
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
prop:disabled=move || disabled.get()
|
||||
on:keyup=move |ev: ev::KeyboardEvent| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v| *v = val);
|
||||
}
|
||||
on:change=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_email.update(|v| *v = val);
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="Password"
|
||||
prop:disabled=move || disabled.get()
|
||||
on:keyup=move |ev: ev::KeyboardEvent| {
|
||||
match &*ev.key() {
|
||||
"Enter" => {
|
||||
dispatch_action();
|
||||
}
|
||||
_ => {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p| *p = val);
|
||||
}
|
||||
}
|
||||
}
|
||||
on:change=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
set_password.update(|p| *p = val);
|
||||
}
|
||||
/>
|
||||
<button
|
||||
prop:disabled=move || button_is_disabled.get()
|
||||
on:click=move |_| dispatch_action()
|
||||
>
|
||||
{action_label}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod credentials;
|
||||
pub mod navbar;
|
||||
|
||||
pub use self::navbar::*;
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::Page;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn NavBar(
|
||||
logged_in: Signal<bool>,
|
||||
#[prop(into)] on_logout: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<nav>
|
||||
<Show
|
||||
when=move || logged_in.get()
|
||||
fallback=|| {
|
||||
view! {
|
||||
<A href=Page::Login.path()>"Login"</A>
|
||||
" | "
|
||||
<A href=Page::Register.path()>"Register"</A>
|
||||
}
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
on:click=move |_| on_logout.call(())
|
||||
>
|
||||
"Logout"
|
||||
</a>
|
||||
</Show>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
135
projects/login_with_token_csr_only/client/src/lib.rs
Normal file
135
projects/login_with_token_csr_only/client/src/lib.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use api_boundary::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::*;
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
mod pages;
|
||||
|
||||
use self::{components::*, pages::*};
|
||||
|
||||
const DEFAULT_API_URL: &str = "/api";
|
||||
const API_TOKEN_STORAGE_KEY: &str = "api-token";
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// -- signals -- //
|
||||
|
||||
let authorized_api = RwSignal::new(None::<api::AuthorizedApi>);
|
||||
let user_info = RwSignal::new(None::<UserInfo>);
|
||||
let logged_in = Signal::derive(move || authorized_api.get().is_some());
|
||||
|
||||
// -- actions -- //
|
||||
|
||||
let fetch_user_info = create_action(move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.user_info().await {
|
||||
Ok(info) => {
|
||||
user_info.update(|i| *i = Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to fetch user info: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to fetch user info: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let logout = create_action(move |_| async move {
|
||||
match authorized_api.get() {
|
||||
Some(api) => match api.logout().await {
|
||||
Ok(_) => {
|
||||
authorized_api.update(|a| *a = None);
|
||||
user_info.update(|i| *i = None);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to logout: {err}")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Unable to logout user: not logged in")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -- callbacks -- //
|
||||
|
||||
let on_logout = move |_| {
|
||||
logout.dispatch(());
|
||||
};
|
||||
|
||||
// -- init API -- //
|
||||
|
||||
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
|
||||
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
|
||||
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
|
||||
authorized_api.update(|a| *a = Some(api));
|
||||
fetch_user_info.dispatch(());
|
||||
}
|
||||
|
||||
log::debug!("User is logged in: {}", logged_in.get_untracked());
|
||||
|
||||
// -- effects -- //
|
||||
|
||||
create_effect(move |_| {
|
||||
log::debug!("API authorization state changed");
|
||||
match authorized_api.get() {
|
||||
Some(api) => {
|
||||
log::debug!(
|
||||
"API is now authorized: save token in LocalStorage"
|
||||
);
|
||||
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token())
|
||||
.expect("LocalStorage::set");
|
||||
}
|
||||
None => {
|
||||
log::debug!(
|
||||
"API is no longer authorized: delete token from \
|
||||
LocalStorage"
|
||||
);
|
||||
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Router>
|
||||
<NavBar logged_in on_logout/>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
path=Page::Home.path()
|
||||
view=move || {
|
||||
view! { <Home user_info=user_info.into()/> }
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Login.path()
|
||||
view=move || {
|
||||
view! {
|
||||
<Login
|
||||
api=unauthorized_api
|
||||
on_success=move |api| {
|
||||
log::info!("Successfully logged in");
|
||||
authorized_api.update(|v| *v = Some(api));
|
||||
let navigate = use_navigate();
|
||||
navigate(Page::Home.path(), Default::default());
|
||||
fetch_user_info.dispatch(());
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=Page::Register.path()
|
||||
view=move || {
|
||||
view! { <Register api=unauthorized_api/> }
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
8
projects/login_with_token_csr_only/client/src/main.rs
Normal file
8
projects/login_with_token_csr_only/client/src/main.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use client::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
pub fn main() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|| view! { <App/> })
|
||||
}
|
||||
24
projects/login_with_token_csr_only/client/src/pages/home.rs
Normal file
24
projects/login_with_token_csr_only/client/src/pages/home.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::Page;
|
||||
use api_boundary::UserInfo;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Home(user_info: Signal<Option<UserInfo>>) -> impl IntoView {
|
||||
view! {
|
||||
<h2>"Leptos Login example"</h2>
|
||||
{move || match user_info.get() {
|
||||
Some(info) => {
|
||||
view! { <p>"You are logged in with " {info.email} "."</p> }
|
||||
.into_view()
|
||||
}
|
||||
None => {
|
||||
view! {
|
||||
<p>"You are not logged in."</p>
|
||||
<A href=Page::Login.path()>"Login now."</A>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
63
projects/login_with_token_csr_only/client/src/pages/login.rs
Normal file
63
projects/login_with_token_csr_only/client/src/pages/login.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use crate::{
|
||||
api::{self, AuthorizedApi, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
use api_boundary::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Login(
|
||||
api: UnauthorizedApi,
|
||||
#[prop(into)] on_success: Callback<AuthorizedApi>,
|
||||
) -> impl IntoView {
|
||||
let (login_error, set_login_error) = create_signal(None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(false);
|
||||
|
||||
let login_action =
|
||||
create_action(move |(email, password): &(String, String)| {
|
||||
log::debug!("Try to login with {email}");
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.login(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_login_error.update(|e| *e = None);
|
||||
on_success.call(res);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
log::error!(
|
||||
"Unable to login with {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_login_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(move || wait_for_response.get());
|
||||
|
||||
view! {
|
||||
<CredentialsForm
|
||||
title="Please login to your account"
|
||||
action_label="Login"
|
||||
action=login_action
|
||||
error=login_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Don't have an account?"</p>
|
||||
<A href=Page::Register.path()>"Register"</A>
|
||||
}
|
||||
}
|
||||
23
projects/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
23
projects/login_with_token_csr_only/client/src/pages/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
pub use self::{home::*, login::*, register::*};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum Page {
|
||||
#[default]
|
||||
Home,
|
||||
Login,
|
||||
Register,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn path(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "/",
|
||||
Self::Login => "/login",
|
||||
Self::Register => "/register",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
api::{self, UnauthorizedApi},
|
||||
components::credentials::*,
|
||||
Page,
|
||||
};
|
||||
use api_boundary::*;
|
||||
use leptos::{logging::log, *};
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Register(api: UnauthorizedApi) -> impl IntoView {
|
||||
let (register_response, set_register_response) = create_signal(None::<()>);
|
||||
let (register_error, set_register_error) = create_signal(None::<String>);
|
||||
let (wait_for_response, set_wait_for_response) = create_signal(false);
|
||||
|
||||
let register_action =
|
||||
create_action(move |(email, password): &(String, String)| {
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let credentials = Credentials { email, password };
|
||||
log!("Try to register new account for {}", credentials.email);
|
||||
async move {
|
||||
set_wait_for_response.update(|w| *w = true);
|
||||
let result = api.register(&credentials).await;
|
||||
set_wait_for_response.update(|w| *w = false);
|
||||
match result {
|
||||
Ok(res) => {
|
||||
set_register_response.update(|v| *v = Some(res));
|
||||
set_register_error.update(|e| *e = None);
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = match err {
|
||||
api::Error::Fetch(js_err) => {
|
||||
format!("{js_err:?}")
|
||||
}
|
||||
api::Error::Api(err) => err.message,
|
||||
};
|
||||
log::warn!(
|
||||
"Unable to register new account for {}: {msg}",
|
||||
credentials.email
|
||||
);
|
||||
set_register_error.update(|e| *e = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let disabled = Signal::derive(move || wait_for_response.get());
|
||||
|
||||
view! {
|
||||
<Show
|
||||
when=move || register_response.get().is_some()
|
||||
fallback=move || {
|
||||
view! {
|
||||
<CredentialsForm
|
||||
title="Please enter the desired credentials"
|
||||
action_label="Register"
|
||||
action=register_action
|
||||
error=register_error.into()
|
||||
disabled
|
||||
/>
|
||||
<p>"Your already have an account?"</p>
|
||||
<A href=Page::Login.path()>"Login"</A>
|
||||
}
|
||||
}
|
||||
>
|
||||
<p>"You have successfully registered."</p>
|
||||
<p>"You can now " <A href=Page::Login.path()>"login"</A> " with your new account."</p>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
22
projects/login_with_token_csr_only/server/Cargo.toml
Normal file
22
projects/login_with_token_csr_only/server/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
api-boundary = "=0.0.0"
|
||||
|
||||
anyhow = "1.0"
|
||||
axum = "0.7"
|
||||
axum-extra = { version = "0.9.2", features = ["typed-header"] }
|
||||
env_logger = "0.10"
|
||||
log = "0.4"
|
||||
mailparse = "0.14"
|
||||
pwhash = "1.0"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
parking_lot = "0.12.1"
|
||||
headers = "0.4.0"
|
||||
4
projects/login_with_token_csr_only/server/Makefile.toml
Normal file
4
projects/login_with_token_csr_only/server/Makefile.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.check-format]
|
||||
env = { LEPTOS_PROJECT_DIRECTORY = "../../../" }
|
||||
113
projects/login_with_token_csr_only/server/src/adapters.rs
Normal file
113
projects/login_with_token_csr_only/server/src/adapters.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::{application::*, Error};
|
||||
use api_boundary as json;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
impl From<InvalidEmailAddress> for json::Error {
|
||||
fn from(_: InvalidEmailAddress) -> Self {
|
||||
Self {
|
||||
message: "Invalid email address".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InvalidPassword> for json::Error {
|
||||
fn from(err: InvalidPassword) -> Self {
|
||||
let InvalidPassword::TooShort(min_len) = err;
|
||||
Self {
|
||||
message: format!("Invalid password (min. length = {min_len})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateUserError> for json::Error {
|
||||
fn from(err: CreateUserError) -> Self {
|
||||
let message = match err {
|
||||
CreateUserError::UserExists => "User already exits".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginError> for json::Error {
|
||||
fn from(err: LoginError) -> Self {
|
||||
let message = match err {
|
||||
LoginError::InvalidEmailOrPassword => {
|
||||
"Invalid email or password".to_string()
|
||||
}
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogoutError> for json::Error {
|
||||
fn from(err: LogoutError) -> Self {
|
||||
let message = match err {
|
||||
LogoutError::NotLoggedIn => "No user is logged in".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for json::Error {
|
||||
fn from(err: AuthError) -> Self {
|
||||
let message = match err {
|
||||
AuthError::NotAuthorized => "Not authorized".to_string(),
|
||||
};
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CredentialParsingError> for json::Error {
|
||||
fn from(err: CredentialParsingError) -> Self {
|
||||
match err {
|
||||
CredentialParsingError::EmailAddress(err) => err.into(),
|
||||
CredentialParsingError::Password(err) => err.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CredentialParsingError {
|
||||
#[error(transparent)]
|
||||
EmailAddress(#[from] InvalidEmailAddress),
|
||||
#[error(transparent)]
|
||||
Password(#[from] InvalidPassword),
|
||||
}
|
||||
|
||||
impl TryFrom<json::Credentials> for Credentials {
|
||||
type Error = CredentialParsingError;
|
||||
fn try_from(
|
||||
json::Credentials { email, password }: json::Credentials,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let email: EmailAddress = email.parse()?;
|
||||
let password = Password::try_from(password)?;
|
||||
Ok(Self { email, password })
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let (code, value) = match self {
|
||||
Self::Logout(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Login(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Credentials(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::CreateUser(err) => {
|
||||
(StatusCode::BAD_REQUEST, json::Error::from(err))
|
||||
}
|
||||
Self::Auth(err) => {
|
||||
(StatusCode::UNAUTHORIZED, json::Error::from(err))
|
||||
}
|
||||
};
|
||||
(code, Json(value)).into_response()
|
||||
}
|
||||
}
|
||||
156
projects/login_with_token_csr_only/server/src/application.rs
Normal file
156
projects/login_with_token_csr_only/server/src/application.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use mailparse::addrparse;
|
||||
use pwhash::bcrypt;
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
users: HashMap<EmailAddress, Password>,
|
||||
tokens: HashMap<Uuid, EmailAddress>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn create_user(
|
||||
&mut self,
|
||||
credentials: Credentials,
|
||||
) -> Result<(), CreateUserError> {
|
||||
let Credentials { email, password } = credentials;
|
||||
let user_exists = self.users.contains_key(&email);
|
||||
if user_exists {
|
||||
return Err(CreateUserError::UserExists);
|
||||
}
|
||||
self.users.insert(email, password);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(
|
||||
&mut self,
|
||||
email: EmailAddress,
|
||||
password: &str,
|
||||
) -> Result<Uuid, LoginError> {
|
||||
let valid_credentials = self
|
||||
.users
|
||||
.get(&email)
|
||||
.map(|hashed_password| hashed_password.verify(password))
|
||||
.unwrap_or(false);
|
||||
if !valid_credentials {
|
||||
Err(LoginError::InvalidEmailOrPassword)
|
||||
} else {
|
||||
let token = Uuid::new_v4();
|
||||
self.tokens.insert(token, email);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(&mut self, token: &str) -> Result<(), LogoutError> {
|
||||
let token = token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| LogoutError::NotLoggedIn)?;
|
||||
self.tokens.remove(&token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn authorize_user(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<CurrentUser, AuthError> {
|
||||
token
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| AuthError::NotAuthorized)
|
||||
.and_then(|token| {
|
||||
self.tokens
|
||||
.get(&token)
|
||||
.cloned()
|
||||
.map(|email| CurrentUser { email, token })
|
||||
.ok_or(AuthError::NotAuthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CreateUserError {
|
||||
#[error("The user already exists")]
|
||||
UserExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoginError {
|
||||
#[error("Invalid email or password")]
|
||||
InvalidEmailOrPassword,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LogoutError {
|
||||
#[error("You are not logged in")]
|
||||
NotLoggedIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("You are not authorized")]
|
||||
NotAuthorized,
|
||||
}
|
||||
|
||||
pub struct Credentials {
|
||||
pub email: EmailAddress,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||
pub struct EmailAddress(String);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("The given email address is invalid")]
|
||||
pub struct InvalidEmailAddress;
|
||||
|
||||
impl FromStr for EmailAddress {
|
||||
type Err = InvalidEmailAddress;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
addrparse(s)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.extract_single_info())
|
||||
.map(|single_info| Self(single_info.addr))
|
||||
.ok_or(InvalidEmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CurrentUser {
|
||||
pub email: EmailAddress,
|
||||
#[allow(dead_code)] // possibly a lint regression, this is used at line 65
|
||||
pub token: Uuid,
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LEN: usize = 3;
|
||||
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
pub fn verify(&self, password: &str) -> bool {
|
||||
bcrypt::verify(password, &self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InvalidPassword {
|
||||
#[error("Password is too short (min. length is {0})")]
|
||||
TooShort(usize),
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = InvalidPassword;
|
||||
fn try_from(p: String) -> Result<Self, Self::Error> {
|
||||
if p.len() < MIN_PASSWORD_LEN {
|
||||
return Err(InvalidPassword::TooShort(MIN_PASSWORD_LEN));
|
||||
}
|
||||
let hashed = bcrypt::hash(&p).unwrap();
|
||||
Ok(Self(hashed))
|
||||
}
|
||||
}
|
||||
119
projects/login_with_token_csr_only/server/src/main.rs
Normal file
119
projects/login_with_token_csr_only/server/src/main.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use api_boundary as json;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::Method,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::TypedHeader;
|
||||
use headers::{authorization::Bearer, Authorization};
|
||||
use parking_lot::RwLock;
|
||||
use std::{env, net::SocketAddr, sync::Arc};
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
mod adapters;
|
||||
mod application;
|
||||
|
||||
use self::application::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
if let Err(err) = env::var("RUST_LOG") {
|
||||
match err {
|
||||
env::VarError::NotPresent => {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
}
|
||||
env::VarError::NotUnicode(_) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The value of 'RUST_LOG' does not contain valid unicode \
|
||||
data."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
env_logger::init();
|
||||
|
||||
let shared_state = Arc::new(RwLock::new(AppState::default()));
|
||||
|
||||
let cors_layer = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_origin(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/users", post(create_user))
|
||||
.route("/users", get(get_user_info))
|
||||
.route_layer(cors_layer)
|
||||
.with_state(shared_state);
|
||||
|
||||
let addr = "0.0.0.0:3000".parse::<SocketAddr>()?;
|
||||
log::info!("Start listening on http://{addr}");
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<Json<T>, Error>;
|
||||
|
||||
/// API error
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
enum Error {
|
||||
#[error(transparent)]
|
||||
CreateUser(#[from] CreateUserError),
|
||||
#[error(transparent)]
|
||||
Login(#[from] LoginError),
|
||||
#[error(transparent)]
|
||||
Logout(#[from] LogoutError),
|
||||
#[error(transparent)]
|
||||
Auth(#[from] AuthError),
|
||||
#[error(transparent)]
|
||||
Credentials(#[from] adapters::CredentialParsingError),
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
State(state): State<Arc<RwLock<AppState>>>,
|
||||
Json(credentials): Json<json::Credentials>,
|
||||
) -> Result<()> {
|
||||
let credentials = Credentials::try_from(credentials)?;
|
||||
state.write().create_user(credentials)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<Arc<RwLock<AppState>>>,
|
||||
Json(credentials): Json<json::Credentials>,
|
||||
) -> Result<json::ApiToken> {
|
||||
let json::Credentials { email, password } = credentials;
|
||||
log::debug!("{email} tries to login");
|
||||
let email = email.parse().map_err(|_|
|
||||
// Here we don't want to leak detailed info.
|
||||
LoginError::InvalidEmailOrPassword)?;
|
||||
let token = state
|
||||
.write()
|
||||
.login(email, &password)
|
||||
.map(|s| s.to_string())?;
|
||||
Ok(Json(json::ApiToken { token }))
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(state): State<Arc<RwLock<AppState>>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<()> {
|
||||
state.write().logout(auth.token())?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
State(state): State<Arc<RwLock<AppState>>>,
|
||||
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<json::UserInfo> {
|
||||
let user = state.read().authorize_user(auth.token())?;
|
||||
let CurrentUser { email, .. } = user;
|
||||
Ok(Json(json::UserInfo {
|
||||
email: email.into_string(),
|
||||
}))
|
||||
}
|
||||
1
projects/session_auth_axum/.env
Normal file
1
projects/session_auth_axum/.env
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=sqlite://Todos.db
|
||||
108
projects/session_auth_axum/Cargo.toml
Normal file
108
projects/session_auth_axum/Cargo.toml
Normal file
@@ -0,0 +1,108 @@
|
||||
[package]
|
||||
name = "session_auth_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
console_log = "1.0"
|
||||
rand = { version = "0.8", features = ["min_const_gen"], optional = true }
|
||||
console_error_panic_hook = "0.1"
|
||||
futures = "0.3"
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_meta = { path = "../../meta"}
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4"
|
||||
simple_logger = "4.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
axum = { version = "0.7", optional = true, features = ["macros"] }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
http = { version = "1.0" }
|
||||
sqlx = { version = "0.7.2", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0"
|
||||
wasm-bindgen = "0.2"
|
||||
axum_session_auth = { version = "0.12.1", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
axum_session = { version = "0.12.4", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
bcrypt = { version = "0.15", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["ssr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:axum_session_auth",
|
||||
"dep:axum_session",
|
||||
"dep:async-trait",
|
||||
"dep:sqlx",
|
||||
"dep:bcrypt",
|
||||
"dep:rand",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
|
||||
skip_feature_sets = [["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "session_auth_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
projects/session_auth_axum/LICENSE
Normal file
21
projects/session_auth_axum/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Greg Johnston
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
8
projects/session_auth_axum/Makefile.toml
Normal file
8
projects/session_auth_axum/Makefile.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
|
||||
CLIENT_PROCESS_NAME = "session_auth_axum"
|
||||
11
projects/session_auth_axum/README.md
Normal file
11
projects/session_auth_axum/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Leptos Authenticated Todo App Sqlite with Axum
|
||||
|
||||
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server. It lets you login, signup, and submit todos as different users, or a guest.
|
||||
|
||||
## Getting Started
|
||||
|
||||
See the [Examples README](../README.md) for setup and run instructions.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run `cargo leptos watch` to run this example.
|
||||
BIN
projects/session_auth_axum/Todos.db
Normal file
BIN
projects/session_auth_axum/Todos.db
Normal file
Binary file not shown.
116
projects/session_auth_axum/flake.lock
generated
Normal file
116
projects/session_auth_axum/flake.lock
generated
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1685573264,
|
||||
"narHash": "sha256-Zffu01pONhs/pqH07cjlF10NnMDLok8ix5Uk4rhOnZQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "380be19fbd2d9079f677978361792cb25e8a3635",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-22.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1703902408,
|
||||
"narHash": "sha256-qXdWvu+tlgNjeoz8yQMRKSom6QyRROfgpmeOhwbujqw=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "319f57cd2c34348c55970a4bf2b35afe82088681",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
37
projects/session_auth_axum/flake.nix
Normal file
37
projects/session_auth_axum/flake.nix
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
|
||||
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
in
|
||||
with pkgs; rec {
|
||||
devShells.default = mkShell {
|
||||
|
||||
shellHook = ''
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
];
|
||||
buildInputs = [
|
||||
trunk
|
||||
sqlite
|
||||
sass
|
||||
openssl
|
||||
(rust-bin.nightly.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
})
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- INSERT INTO users (id, anonymous, username, password)
|
||||
-- SELECT 0, true, 'Guest', ''
|
||||
-- ON CONFLICT(id) DO UPDATE SET
|
||||
-- anonymous = EXCLUDED.anonymous,
|
||||
-- username = EXCLUDED.username;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
completed BOOLEAN,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
-- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
BIN
projects/session_auth_axum/public/favicon.ico
Normal file
BIN
projects/session_auth_axum/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
projects/session_auth_axum/rust-toolchain.toml
Normal file
2
projects/session_auth_axum/rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable" # test change
|
||||
274
projects/session_auth_axum/src/auth.rs
Normal file
274
projects/session_auth_axum/src/auth.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub permissions: HashSet<String>,
|
||||
}
|
||||
|
||||
// Explicitly is not Serialize/Deserialize!
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserPasshash(String);
|
||||
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
let permissions = HashSet::new();
|
||||
|
||||
Self {
|
||||
id: -1,
|
||||
username: "Guest".into(),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr {
|
||||
pub use super::{User, UserPasshash};
|
||||
pub use axum_session_auth::{
|
||||
Authentication, HasPermission, SessionSqlitePool,
|
||||
};
|
||||
pub use sqlx::SqlitePool;
|
||||
pub use std::collections::HashSet;
|
||||
pub type AuthSession = axum_session_auth::AuthSession<
|
||||
User,
|
||||
i64,
|
||||
SessionSqlitePool,
|
||||
SqlitePool,
|
||||
>;
|
||||
pub use crate::todo::ssr::{auth, pool};
|
||||
pub use async_trait::async_trait;
|
||||
pub use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
impl User {
|
||||
pub async fn get_with_passhash(
|
||||
id: i64,
|
||||
pool: &SqlitePool,
|
||||
) -> Option<(Self, UserPasshash)> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
|
||||
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
|
||||
User::get_with_passhash(id, pool)
|
||||
.await
|
||||
.map(|(user, _)| user)
|
||||
}
|
||||
|
||||
pub async fn get_from_username_with_passhash(
|
||||
name: String,
|
||||
pool: &SqlitePool,
|
||||
) -> Option<(Self, UserPasshash)> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(sqluser.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
|
||||
pub async fn get_from_username(
|
||||
name: String,
|
||||
pool: &SqlitePool,
|
||||
) -> Option<Self> {
|
||||
User::get_from_username_with_passhash(name, pool)
|
||||
.await
|
||||
.map(|(user, _)| user)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlPermissionTokens {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authentication<User, i64, SqlitePool> for User {
|
||||
async fn load_user(
|
||||
userid: i64,
|
||||
pool: Option<&SqlitePool>,
|
||||
) -> Result<User, anyhow::Error> {
|
||||
let pool = pool.unwrap();
|
||||
|
||||
User::get(userid, pool)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot get user"))
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_anonymous(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HasPermission<SqlitePool> for User {
|
||||
async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool {
|
||||
self.permissions.contains(perm)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl SqlUser {
|
||||
pub fn into_user(
|
||||
self,
|
||||
sql_user_perms: Option<Vec<SqlPermissionTokens>>,
|
||||
) -> (User, UserPasshash) {
|
||||
(
|
||||
User {
|
||||
id: self.id,
|
||||
username: self.username,
|
||||
permissions: if let Some(user_perms) = sql_user_perms {
|
||||
user_perms
|
||||
.into_iter()
|
||||
.map(|x| x.token)
|
||||
.collect::<HashSet<String>>()
|
||||
} else {
|
||||
HashSet::<String>::new()
|
||||
},
|
||||
},
|
||||
UserPasshash(self.password),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn foo() -> Result<String, ServerFnError> {
|
||||
Ok(String::from("Bar!"))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_user() -> Result<Option<User>, ServerFnError> {
|
||||
use crate::todo::ssr::auth;
|
||||
|
||||
let auth = auth()?;
|
||||
|
||||
Ok(auth.current_user)
|
||||
}
|
||||
|
||||
#[server(Login, "/api")]
|
||||
pub async fn login(
|
||||
username: String,
|
||||
password: String,
|
||||
remember: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use self::ssr::*;
|
||||
|
||||
let pool = pool()?;
|
||||
let auth = auth()?;
|
||||
|
||||
let (user, UserPasshash(expected_passhash)) =
|
||||
User::get_from_username_with_passhash(username, &pool)
|
||||
.await
|
||||
.ok_or_else(|| ServerFnError::new("User does not exist."))?;
|
||||
|
||||
match verify(password, &expected_passhash)? {
|
||||
true => {
|
||||
auth.login_user(user.id);
|
||||
auth.remember_user(remember.is_some());
|
||||
leptos_axum::redirect("/");
|
||||
Ok(())
|
||||
}
|
||||
false => Err(ServerFnError::ServerError(
|
||||
"Password does not match.".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(Signup, "/api")]
|
||||
pub async fn signup(
|
||||
username: String,
|
||||
password: String,
|
||||
password_confirmation: String,
|
||||
remember: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use self::ssr::*;
|
||||
|
||||
let pool = pool()?;
|
||||
let auth = auth()?;
|
||||
|
||||
if password != password_confirmation {
|
||||
return Err(ServerFnError::ServerError(
|
||||
"Passwords did not match.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let password_hashed = hash(password, DEFAULT_COST).unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO users (username, password) VALUES (?,?)")
|
||||
.bind(username.clone())
|
||||
.bind(password_hashed)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let user =
|
||||
User::get_from_username(username, &pool)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ServerFnError::new("Signup failed: User does not exist.")
|
||||
})?;
|
||||
|
||||
auth.login_user(user.id);
|
||||
auth.remember_user(remember.is_some());
|
||||
|
||||
leptos_axum::redirect("/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(Logout, "/api")]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
use self::ssr::*;
|
||||
|
||||
let auth = auth()?;
|
||||
|
||||
auth.logout_user();
|
||||
leptos_axum::redirect("/");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
57
projects/session_auth_axum/src/error_template.rs
Normal file
57
projects/session_auth_axum/src/error_template.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::errors::TodoAppError;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "ssr")]
|
||||
use leptos_axum::ResponseOptions;
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
#[prop(optional)] outside_errors: Option<Errors>,
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => RwSignal::new(e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
},
|
||||
};
|
||||
|
||||
// Get Errors from Signal
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<TodoAppError> = errors
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
|
||||
.collect();
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
let response = use_context::<ResponseOptions>();
|
||||
if let Some(response) = response {
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
|
||||
view! {
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each=move || { errors.clone().into_iter().enumerate() }
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
children=move |error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code = error.1.status_code();
|
||||
view! {
|
||||
<h2>{error_code.to_string()}</h2>
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
21
projects/session_auth_axum/src/errors.rs
Normal file
21
projects/session_auth_axum/src/errors.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use http::status::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum TodoAppError {
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
#[error("Internal Server Error")]
|
||||
InternalServerError,
|
||||
}
|
||||
|
||||
impl TodoAppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
TodoAppError::NotFound => StatusCode::NOT_FOUND,
|
||||
TodoAppError::InternalServerError => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
projects/session_auth_axum/src/fallback.rs
Normal file
50
projects/session_auth_axum/src/fallback.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::{error_template::ErrorTemplate, errors::TodoAppError};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
response::{IntoResponse, Response as AxumResponse},
|
||||
};
|
||||
use leptos::{view, Errors, LeptosOptions};
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub async fn file_and_error_handler(
|
||||
uri: Uri,
|
||||
State(options): State<LeptosOptions>,
|
||||
req: Request<Body>,
|
||||
) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
let mut errors = Errors::default();
|
||||
errors.insert_with_default_key(TodoAppError::NotFound);
|
||||
let handler = leptos_axum::render_app_to_stream(
|
||||
options.to_owned(),
|
||||
move || view! {<ErrorTemplate outside_errors=errors.clone()/>},
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
let req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
18
projects/session_auth_axum/src/lib.rs
Normal file
18
projects/session_auth_axum/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub mod auth;
|
||||
pub mod error_template;
|
||||
pub mod errors;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod fallback;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod state;
|
||||
pub mod todo;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use crate::todo::*;
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(TodoApp);
|
||||
}
|
||||
131
projects/session_auth_axum/src/main.rs
Normal file
131
projects/session_auth_axum/src/main.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use axum::{
|
||||
body::Body as AxumBody,
|
||||
extract::{Path, State},
|
||||
http::Request,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_session::{SessionConfig, SessionLayer, SessionStore};
|
||||
use axum_session_auth::{AuthConfig, AuthSessionLayer, SessionSqlitePool};
|
||||
use leptos::{get_configuration, logging::log, provide_context};
|
||||
use leptos_axum::{
|
||||
generate_route_list, handle_server_fns_with_context, LeptosRoutes,
|
||||
};
|
||||
use session_auth_axum::{
|
||||
auth::{ssr::AuthSession, User},
|
||||
fallback::file_and_error_handler,
|
||||
state::AppState,
|
||||
todo::*,
|
||||
};
|
||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(app_state): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
path: Path<String>,
|
||||
request: Request<AxumBody>,
|
||||
) -> impl IntoResponse {
|
||||
log!("{:?}", path);
|
||||
|
||||
handle_server_fns_with_context(
|
||||
move || {
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn leptos_routes_handler(
|
||||
auth_session: AuthSession,
|
||||
State(app_state): State<AppState>,
|
||||
req: Request<AxumBody>,
|
||||
) -> Response {
|
||||
let handler = leptos_axum::render_route_with_context(
|
||||
app_state.leptos_options.clone(),
|
||||
app_state.routes.clone(),
|
||||
move || {
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
TodoApp,
|
||||
);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info)
|
||||
.expect("couldn't initialize logging");
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite:Todos.db")
|
||||
.await
|
||||
.expect("Could not make pool.");
|
||||
|
||||
// Auth section
|
||||
let session_config =
|
||||
SessionConfig::default().with_table_name("axum_sessions");
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(
|
||||
Some(SessionSqlitePool::from(pool.clone())),
|
||||
session_config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Err(e) = sqlx::migrate!().run(&pool).await {
|
||||
eprintln!("{e:?}");
|
||||
}
|
||||
|
||||
// Explicit server function registration is no longer required
|
||||
// on the main branch. On 0.3.0 and earlier, uncomment the lines
|
||||
// below to register the server functions.
|
||||
// _ = GetTodos::register();
|
||||
// _ = AddTodo::register();
|
||||
// _ = DeleteTodo::register();
|
||||
// _ = Login::register();
|
||||
// _ = Logout::register();
|
||||
// _ = Signup::register();
|
||||
// _ = GetUser::register();
|
||||
// _ = Foo::register();
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(TodoApp);
|
||||
|
||||
let app_state = AppState {
|
||||
leptos_options,
|
||||
pool: pool.clone(),
|
||||
routes: routes.clone(),
|
||||
};
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/*fn_name",
|
||||
get(server_fn_handler).post(server_fn_handler),
|
||||
)
|
||||
.leptos_routes_with_handler(routes, get(leptos_routes_handler))
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(
|
||||
AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(
|
||||
Some(pool.clone()),
|
||||
)
|
||||
.with_config(auth_config),
|
||||
)
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.with_state(app_state);
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on http://{}", &addr);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
13
projects/session_auth_axum/src/state.rs
Normal file
13
projects/session_auth_axum/src/state.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use axum::extract::FromRef;
|
||||
use leptos::LeptosOptions;
|
||||
use leptos_router::RouteListing;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// This takes advantage of Axum's SubStates feature by deriving FromRef. This is the only way to have more than one
|
||||
/// item in Axum's State. Leptos requires you to have leptosOptions in your State struct for the leptos route handlers
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState {
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool,
|
||||
pub routes: Vec<RouteListing>,
|
||||
}
|
||||
377
projects/session_auth_axum/src/todo.rs
Normal file
377
projects/session_auth_axum/src/todo.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use crate::{auth::*, error_template::ErrorTemplate};
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Todo {
|
||||
id: u32,
|
||||
user: Option<User>,
|
||||
title: String,
|
||||
created_at: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr {
|
||||
use super::Todo;
|
||||
use crate::auth::{ssr::AuthSession, User};
|
||||
use leptos::prelude::*;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub fn pool() -> Result<SqlitePool, ServerFnError> {
|
||||
use_context::<SqlitePool>()
|
||||
.ok_or_else(|| ServerFnError::ServerError("Pool missing.".into()))
|
||||
}
|
||||
|
||||
pub fn auth() -> Result<AuthSession, ServerFnError> {
|
||||
use_context::<AuthSession>().ok_or_else(|| {
|
||||
ServerFnError::ServerError("Auth session missing.".into())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlTodo {
|
||||
id: u32,
|
||||
user_id: i64,
|
||||
title: String,
|
||||
created_at: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
impl SqlTodo {
|
||||
pub async fn into_todo(self, pool: &SqlitePool) -> Todo {
|
||||
Todo {
|
||||
id: self.id,
|
||||
user: User::get(self.user_id, pool).await,
|
||||
title: self.title,
|
||||
created_at: self.created_at,
|
||||
completed: self.completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[server(GetTodos, "/api")]
|
||||
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
|
||||
use self::ssr::{pool, SqlTodo};
|
||||
use futures::future::join_all;
|
||||
|
||||
let pool = pool()?;
|
||||
|
||||
Ok(join_all(
|
||||
sqlx::query_as::<_, SqlTodo>("SELECT * FROM todos")
|
||||
.fetch_all(&pool)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|todo: &SqlTodo| todo.clone().into_todo(&pool)),
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
#[server(AddTodo, "/api")]
|
||||
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
|
||||
use self::ssr::*;
|
||||
|
||||
let user = get_user().await?;
|
||||
let pool = pool()?;
|
||||
|
||||
let id = match user {
|
||||
Some(user) => user.id,
|
||||
None => -1,
|
||||
};
|
||||
|
||||
// fake API delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(1250));
|
||||
|
||||
Ok(sqlx::query(
|
||||
"INSERT INTO todos (title, user_id, completed) VALUES (?, ?, false)",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map(|_| ())?)
|
||||
}
|
||||
|
||||
// The struct name and path prefix arguments are optional.
|
||||
#[server]
|
||||
pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> {
|
||||
use self::ssr::*;
|
||||
|
||||
let pool = pool()?;
|
||||
|
||||
Ok(sqlx::query("DELETE FROM todos WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map(|_| ())?)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TodoApp() -> impl IntoView {
|
||||
let login = create_server_action::<Login>();
|
||||
let logout = create_server_action::<Logout>();
|
||||
let signup = create_server_action::<Signup>();
|
||||
|
||||
let user = create_resource(
|
||||
move || {
|
||||
(
|
||||
login.version().get(),
|
||||
signup.version().get(),
|
||||
logout.version().get(),
|
||||
)
|
||||
},
|
||||
move |_| get_user(),
|
||||
);
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Stylesheet id="leptos" href="/pkg/session_auth_axum.css"/>
|
||||
<Router>
|
||||
<header>
|
||||
<A href="/">
|
||||
<h1>"My Tasks"</h1>
|
||||
</A>
|
||||
<Transition fallback=move || {
|
||||
view! { <span>"Loading..."</span> }
|
||||
}>
|
||||
{move || {
|
||||
user.get()
|
||||
.map(|user| match user {
|
||||
Err(e) => {
|
||||
view! {
|
||||
<A href="/signup">"Signup"</A>
|
||||
", "
|
||||
<A href="/login">"Login"</A>
|
||||
", "
|
||||
<span>{format!("Login error: {}", e)}</span>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
Ok(None) => {
|
||||
view! {
|
||||
<A href="/signup">"Signup"</A>
|
||||
", "
|
||||
<A href="/login">"Login"</A>
|
||||
", "
|
||||
<span>"Logged out."</span>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
Ok(Some(user)) => {
|
||||
view! {
|
||||
<A href="/settings">"Settings"</A>
|
||||
", "
|
||||
<span>
|
||||
{format!("Logged in as: {} ({})", user.username, user.id)}
|
||||
</span>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
</Transition>
|
||||
</header>
|
||||
<hr/>
|
||||
<main>
|
||||
<Routes>
|
||||
// Route
|
||||
<Route path="" view=Todos/>
|
||||
<Route path="signup" view=move || view! { <Signup action=signup/> }/>
|
||||
<Route path="login" view=move || view! { <Login action=login/> }/>
|
||||
<Route
|
||||
path="settings"
|
||||
view=move || {
|
||||
view! {
|
||||
<h1>"Settings"</h1>
|
||||
<Logout action=logout/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Todos() -> impl IntoView {
|
||||
let add_todo = create_server_multi_action::<AddTodo>();
|
||||
let delete_todo = create_server_action::<DeleteTodo>();
|
||||
let submissions = add_todo.submissions();
|
||||
|
||||
// list of todos is loaded from the server in reaction to changes
|
||||
let todos = create_resource(
|
||||
move || (add_todo.version().get(), delete_todo.version().get()),
|
||||
move |_| get_todos(),
|
||||
);
|
||||
|
||||
view! {
|
||||
<div>
|
||||
<MultiActionForm action=add_todo>
|
||||
<label>"Add a Todo" <input type="text" name="title"/></label>
|
||||
<input type="submit" value="Add"/>
|
||||
</MultiActionForm>
|
||||
<Transition fallback=move || view! { <p>"Loading..."</p> }>
|
||||
<ErrorBoundary fallback=|errors| {
|
||||
view! { <ErrorTemplate errors=errors/> }
|
||||
}>
|
||||
{move || {
|
||||
let existing_todos = {
|
||||
move || {
|
||||
todos
|
||||
.get()
|
||||
.map(move |todos| match todos {
|
||||
Err(e) => {
|
||||
view! {
|
||||
<pre class="error">"Server Error: " {e.to_string()}</pre>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
Ok(todos) => {
|
||||
if todos.is_empty() {
|
||||
view! { <p>"No tasks were found."</p> }.into_view()
|
||||
} else {
|
||||
todos
|
||||
.into_iter()
|
||||
.map(move |todo| {
|
||||
view! {
|
||||
<li>
|
||||
{todo.title} ": Created at " {todo.created_at} " by "
|
||||
{todo.user.unwrap_or_default().username}
|
||||
<ActionForm action=delete_todo>
|
||||
<input type="hidden" name="id" value=todo.id/>
|
||||
<input type="submit" value="X"/>
|
||||
</ActionForm>
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let pending_todos = move || {
|
||||
submissions
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter(|submission| submission.pending().get())
|
||||
.map(|submission| {
|
||||
view! {
|
||||
<li class="pending">
|
||||
{move || submission.input.get().map(|data| data.title)}
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
view! { <ul>{existing_todos} {pending_todos}</ul> }
|
||||
}}
|
||||
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Login(
|
||||
action: Action<Login, Result<(), ServerFnError>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<ActionForm action=action>
|
||||
<h1>"Log In"</h1>
|
||||
<label>
|
||||
"User ID:"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID"
|
||||
maxlength="32"
|
||||
name="username"
|
||||
class="auth-input"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Password:"
|
||||
<input type="password" placeholder="Password" name="password" class="auth-input"/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
<input type="checkbox" name="remember" class="auth-input"/>
|
||||
"Remember me?"
|
||||
</label>
|
||||
<br/>
|
||||
<button type="submit" class="button">
|
||||
"Log In"
|
||||
</button>
|
||||
</ActionForm>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Signup(
|
||||
action: Action<Signup, Result<(), ServerFnError>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<ActionForm action=action>
|
||||
<h1>"Sign Up"</h1>
|
||||
<label>
|
||||
"User ID:"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID"
|
||||
maxlength="32"
|
||||
name="username"
|
||||
class="auth-input"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Password:"
|
||||
<input type="password" placeholder="Password" name="password" class="auth-input"/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Confirm Password:"
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password again"
|
||||
name="password_confirmation"
|
||||
class="auth-input"
|
||||
/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>
|
||||
"Remember me?" <input type="checkbox" name="remember" class="auth-input"/>
|
||||
</label>
|
||||
|
||||
<br/>
|
||||
<button type="submit" class="button">
|
||||
"Sign Up"
|
||||
</button>
|
||||
</ActionForm>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Logout(
|
||||
action: Action<Logout, Result<(), ServerFnError>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div id="loginbox">
|
||||
<ActionForm action=action>
|
||||
<button type="submit" class="button">
|
||||
"Log Out"
|
||||
</button>
|
||||
</ActionForm>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
7
projects/session_auth_axum/style.css
Normal file
7
projects/session_auth_axum/style.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.pending {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
a {
|
||||
color: black;
|
||||
}
|
||||
111
projects/sso_auth_axum/Cargo.toml
Normal file
111
projects/sso_auth_axum/Cargo.toml
Normal file
@@ -0,0 +1,111 @@
|
||||
[package]
|
||||
name = "sso_auth_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
oauth2 = { version = "4.4.2", optional = true }
|
||||
anyhow = "1.0.66"
|
||||
console_log = "1.0.0"
|
||||
rand = { version = "0.8.5", features = ["min_const_gen"], optional = true }
|
||||
console_error_panic_hook = "0.1.7"
|
||||
futures = "0.3.25"
|
||||
leptos = { path = "../../leptos" }
|
||||
leptos_meta = { path = "../../meta" }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
serde_json = { version = "1.0.108", optional = true }
|
||||
axum = { version = "0.7", optional = true, features = ["macros"] }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "1" }
|
||||
sqlx = { version = "0.7", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0.38"
|
||||
wasm-bindgen = "0.2"
|
||||
axum_session_auth = { version = "0.12", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
axum_session = { version = "0.12", features = [
|
||||
"sqlite-rustls",
|
||||
], optional = true }
|
||||
async-trait = { version = "0.1.64", optional = true }
|
||||
reqwest = { version = "0.12", optional = true, features = ["json"] }
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:serde_json",
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:reqwest",
|
||||
"dep:oauth2",
|
||||
"dep:axum_session_auth",
|
||||
"dep:axum_session",
|
||||
"dep:async-trait",
|
||||
"dep:sqlx",
|
||||
"dep:rand",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:leptos_axum",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "sso_auth_axum"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
# [Optional] Files in the asset-dir will be copied to the site-root directory
|
||||
assets-dir = "public"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "127.0.0.1:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
end2end-cmd = "npx playwright test"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
projects/sso_auth_axum/LICENSE
Normal file
21
projects/sso_auth_axum/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Greg Johnston
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
8
projects/sso_auth_axum/Makefile.toml
Normal file
8
projects/sso_auth_axum/Makefile.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
|
||||
CLIENT_PROCESS_NAME = "sso_auth_axum"
|
||||
83
projects/sso_auth_axum/README.md
Normal file
83
projects/sso_auth_axum/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Leptos SSO Authenticated Email Display App with Axum
|
||||
|
||||
## Overview
|
||||
This project demonstrates various methods of implementing Single Sign-On (SSO) authorization using OAuth, specifically with the OAuth2 library. The primary focus is on the Authorization Code Grant flow.
|
||||
|
||||
### Process Flow
|
||||
1. **Initiating Sign-In:** When a user clicks the 'Sign In With {THIRD PARTY SERVICE}' button, the request is sent to a server function. This function retrieves an authorization URL from the third-party service.
|
||||
|
||||
2. **CSRF Token Handling:** During the URL fetch, a CSRF_TOKEN is generated and confirmed by the service to mitigate Cross-Site Request Forgery attacks. Learn more about CSRF [here](https://en.wikipedia.org/wiki/Cross-site_request_forgery). This token is stored on our server.
|
||||
|
||||
3. **User Redirection:** Post-login, users are redirected to our server with a URL formatted as follows:
|
||||
`http://your-redirect-uri.com/callback?code=AUTHORIZATION_CODE&state=CSRF_TOKEN`
|
||||
Note: Additional parameters like Scope and Client_ID may be included by the service.
|
||||
|
||||
4. **Token Acquisition:** The 'code' parameter in the URL is not the actual service token. Instead, it's used to fetch the token. We verify the CSRF_TOKEN in the URL against our server's stored token for security.
|
||||
|
||||
5. **Access Token Usage:** With a valid CSRF_TOKEN, we use the AUTHORIZATION_CODE in an HTTP Request to the third-party service. The response typically includes:
|
||||
- An `access token`
|
||||
- An `expires_in` value (time in seconds until token expiration)
|
||||
- A `refresh token` (used to renew the access token)
|
||||
|
||||
6. **Email Retrieval and Display:** The access token allows us to retrieve the user's email. This email is then displayed in our Email Display App.
|
||||
|
||||
7. **Session Management:** The `expires_in` value is sent to the client. The client uses this to set a timeout, ensuring that if the session is still active (the window hasn't been closed), it automatically triggers a token refresh when required.
|
||||
|
||||
|
||||
|
||||
## Client Side Rendering
|
||||
This example cannot be built as a trunk standalone CSR-only app. Only the server may directly connect to the database.
|
||||
|
||||
## Server Side Rendering with cargo-leptos
|
||||
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
## Env Vars
|
||||
Commands that run the program, cargo leptos watch, cargo leptos serve, cargo run etc... All need the following Environment variables
|
||||
G_AUTH_CLIENT_ID : This is the client ID given to you by google.
|
||||
G_AUTH_SECRET : This is the secret given to you by google.
|
||||
NGROK : this is the ngrok endpoint you get when you run ngrok http 3000
|
||||
|
||||
## Ngrok Google Set Up
|
||||
After running your app, run
|
||||
```bash
|
||||
ngrok http 3000
|
||||
```
|
||||
Then use google api's and services, go to credentials, create credentials, add your app name, and use the ngrok url as the origin
|
||||
and use the ngrok url with /g_auth as the redirect url. That will look like this `https://362b-24-34-20-189.ngrok-free.app/g_auth`
|
||||
Save you client ID and secret given to you by google. Use them as Envars when you run the program as below
|
||||
```bash
|
||||
REDIRECT_URL={ngrok_redirect_url} G_AUTH_CLIENT_ID={google_credential_client_id} G_AUTH_SECRET={google_credential_secret} {your command here...}
|
||||
```
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
116
projects/sso_auth_axum/flake.lock
generated
Normal file
116
projects/sso_auth_axum/flake.lock
generated
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1672580127,
|
||||
"narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0874168639713f547c05947c76124f78441ea46c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-22.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681525152,
|
||||
"narHash": "sha256-KzI+ILcmU03iFWtB+ysPqtNmp8TP8v1BBReTuPP8MJY=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b6f8d87208336d7cb85003b2e439fc707c38f92a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
37
projects/sso_auth_axum/flake.nix
Normal file
37
projects/sso_auth_axum/flake.nix
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
|
||||
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
in
|
||||
with pkgs; rec {
|
||||
devShells.default = mkShell {
|
||||
|
||||
shellHook = ''
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
];
|
||||
buildInputs = [
|
||||
trunk
|
||||
sqlite
|
||||
sass
|
||||
openssl
|
||||
(rust-bin.nightly.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
})
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS csrf_tokens (
|
||||
csrf_token TEXT NOT NULL PRIMARY KEY UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS google_tokens (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
access_secret TEXT NOT NULL,
|
||||
refresh_secret TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) CONFLICT REPLACE
|
||||
);
|
||||
|
||||
BIN
projects/sso_auth_axum/public/favicon.ico
Normal file
BIN
projects/sso_auth_axum/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
160
projects/sso_auth_axum/src/auth.rs
Normal file
160
projects/sso_auth_axum/src/auth.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub email: String,
|
||||
pub permissions: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
let permissions = HashSet::new();
|
||||
|
||||
Self {
|
||||
id: -1,
|
||||
email: "example@example.com".into(),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr_imports {
|
||||
use super::User;
|
||||
pub use axum_session_auth::{
|
||||
Authentication, HasPermission, SessionSqlitePool,
|
||||
};
|
||||
pub use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
pub type AuthSession = axum_session_auth::AuthSession<
|
||||
User,
|
||||
i64,
|
||||
SessionSqlitePool,
|
||||
SqlitePool,
|
||||
>;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
impl User {
|
||||
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
|
||||
pub async fn get_from_email(
|
||||
email: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> Option<Self> {
|
||||
let sqluser = sqlx::query_as::<_, SqlUser>(
|
||||
"SELECT * FROM users WHERE email = ?",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
//lets just get all the tokens the user can use, we will only use the full permissions if modifying them.
|
||||
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
|
||||
"SELECT token FROM user_permissions WHERE user_id = ?;",
|
||||
)
|
||||
.bind(sqluser.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(sqluser.into_user(Some(sql_user_perms)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlPermissionTokens {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlCsrfToken {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authentication<User, i64, SqlitePool> for User {
|
||||
async fn load_user(
|
||||
userid: i64,
|
||||
pool: Option<&SqlitePool>,
|
||||
) -> Result<User, anyhow::Error> {
|
||||
let pool = pool.unwrap();
|
||||
|
||||
User::get(userid, pool)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot get user"))
|
||||
}
|
||||
|
||||
fn is_authenticated(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_anonymous(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HasPermission<SqlitePool> for User {
|
||||
async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool {
|
||||
self.permissions.contains(perm)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlUser {
|
||||
pub id: i64,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Clone)]
|
||||
pub struct SqlRefreshToken {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl SqlUser {
|
||||
pub fn into_user(
|
||||
self,
|
||||
sql_user_perms: Option<Vec<SqlPermissionTokens>>,
|
||||
) -> User {
|
||||
User {
|
||||
id: self.id,
|
||||
email: self.email,
|
||||
permissions: if let Some(user_perms) = sql_user_perms {
|
||||
user_perms
|
||||
.into_iter()
|
||||
.map(|x| x.token)
|
||||
.collect::<HashSet<String>>()
|
||||
} else {
|
||||
HashSet::<String>::new()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
projects/sso_auth_axum/src/error_template.rs
Normal file
23
projects/sso_auth_axum/src/error_template.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use leptos::{view, Errors, For, IntoView, RwSignal, SignalGet, View};
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
pub fn error_template(errors: RwSignal<Errors>) -> View {
|
||||
view! {
|
||||
<h1>"Errors"</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each=move || errors.get()
|
||||
// a unique key for each item as a reference
|
||||
key=|(key, _)| key.clone()
|
||||
// renders each item to a view
|
||||
children= move | (_, error)| {
|
||||
let error_string = error.to_string();
|
||||
view! {
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
49
projects/sso_auth_axum/src/fallback.rs
Normal file
49
projects/sso_auth_axum/src/fallback.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::error_template::error_template;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
response::{IntoResponse, Response as AxumResponse},
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub async fn file_and_error_handler(
|
||||
uri: Uri,
|
||||
State(options): State<LeptosOptions>,
|
||||
req: Request<Body>,
|
||||
) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
leptos::logging::log!("{:?}:{}", res.status(), uri);
|
||||
let handler =
|
||||
leptos_axum::render_app_to_stream(options.to_owned(), || {
|
||||
error_template(RwSignal::new(leptos::Errors::default()))
|
||||
});
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
let req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
)),
|
||||
}
|
||||
}
|
||||
151
projects/sso_auth_axum/src/lib.rs
Normal file
151
projects/sso_auth_axum/src/lib.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
pub mod auth;
|
||||
pub mod error_template;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod fallback;
|
||||
pub mod sign_in_sign_up;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod state;
|
||||
use leptos::{leptos_dom::helpers::TimeoutHandle, *};
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use sign_in_sign_up::*;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod ssr_imports {
|
||||
pub use crate::auth::ssr_imports::{AuthSession, SqlRefreshToken};
|
||||
pub use leptos::{use_context, ServerFnError};
|
||||
pub use oauth2::{reqwest::async_http_client, TokenResponse};
|
||||
pub use sqlx::SqlitePool;
|
||||
|
||||
pub fn pool() -> Result<SqlitePool, ServerFnError> {
|
||||
use_context::<SqlitePool>()
|
||||
.ok_or_else(|| ServerFnError::new("Pool missing."))
|
||||
}
|
||||
|
||||
pub fn auth() -> Result<AuthSession, ServerFnError> {
|
||||
use_context::<AuthSession>()
|
||||
.ok_or_else(|| ServerFnError::new("Auth session missing."))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Email(RwSignal<Option<String>>);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExpiresIn(RwSignal<u64>);
|
||||
#[server]
|
||||
pub async fn refresh_token(email: String) -> Result<u64, ServerFnError> {
|
||||
use crate::{auth::User, state::AppState};
|
||||
use ssr_imports::*;
|
||||
|
||||
let pool = pool()?;
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let user = User::get_from_email(&email, &pool)
|
||||
.await
|
||||
.ok_or(ServerFnError::new("User not found"))?;
|
||||
|
||||
let refresh_secret = sqlx::query_as::<_, SqlRefreshToken>(
|
||||
"SELECT secret FROM google_refresh_tokens WHERE user_id = ?",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(&pool)
|
||||
.await?
|
||||
.secret;
|
||||
|
||||
let token_response = oauth_client
|
||||
.exchange_refresh_token(&oauth2::RefreshToken::new(refresh_secret))
|
||||
.request_async(async_http_client)
|
||||
.await?;
|
||||
|
||||
let access_token = token_response.access_token().secret();
|
||||
let expires_in = token_response.expires_in().unwrap().as_secs();
|
||||
let refresh_secret = token_response.refresh_token().unwrap().secret();
|
||||
sqlx::query("DELETE FROM google_tokens WHERE user_id == ?")
|
||||
.bind(user.id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO google_tokens (user_id,access_secret,refresh_secret) \
|
||||
VALUES (?,?,?)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(access_token)
|
||||
.bind(refresh_secret)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
Ok(expires_in)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
let email = RwSignal::new(None::<String>);
|
||||
let rw_expires_in = RwSignal::new(0);
|
||||
provide_context(Email(email));
|
||||
provide_context(ExpiresIn(rw_expires_in));
|
||||
|
||||
let display_email =
|
||||
move || email.get().unwrap_or(String::from("No email to display"));
|
||||
let refresh_token = create_server_action::<RefreshToken>();
|
||||
|
||||
create_effect(move |handle: Option<Option<TimeoutHandle>>| {
|
||||
// If this effect is called, try to cancel the previous handle.
|
||||
if let Some(prev_handle) = handle.flatten() {
|
||||
prev_handle.clear();
|
||||
};
|
||||
// if expires_in isn't 0, then set a timeout that rerfresh a minute short of the refresh.
|
||||
let expires_in = rw_expires_in.get();
|
||||
if expires_in != 0 && email.get_untracked().is_some() {
|
||||
let handle = set_timeout_with_handle(
|
||||
move || {
|
||||
refresh_token.dispatch(RefreshToken {
|
||||
email: email.get_untracked().unwrap(),
|
||||
})
|
||||
},
|
||||
std::time::Duration::from_secs(
|
||||
// Google tokens last 3599 seconds, so we'll get a refresh token every 14 seconds.
|
||||
expires_in.checked_sub(3545).unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
Some(handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
create_effect(move |_| {
|
||||
if let Some(Ok(expires_in)) = refresh_token.value().get() {
|
||||
rw_expires_in.set(expires_in);
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/pkg/sso_auth_axum.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Title text="SSO Auth Axum"/>
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="" view=move || {
|
||||
view!{
|
||||
{display_email}
|
||||
<Show when=move || email.get().is_some() fallback=||view!{<SignIn/>}>
|
||||
<LogOut/>
|
||||
</Show>
|
||||
}
|
||||
}/>
|
||||
<Route path="g_auth" view=||view!{<HandleGAuth/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(App);
|
||||
}
|
||||
154
projects/sso_auth_axum/src/main.rs
Normal file
154
projects/sso_auth_axum/src/main.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use crate::ssr_imports::*;
|
||||
use axum::{
|
||||
body::Body as AxumBody,
|
||||
extract::{Path, State},
|
||||
http::Request,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_session::{Key, SessionConfig, SessionLayer, SessionStore};
|
||||
use axum_session_auth::{AuthConfig, AuthSessionLayer};
|
||||
use leptos::{get_configuration, logging::log, provide_context, view};
|
||||
use leptos_axum::{
|
||||
generate_route_list, handle_server_fns_with_context, LeptosRoutes,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sso_auth_axum::{
|
||||
auth::*, fallback::file_and_error_handler, state::AppState,
|
||||
};
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(app_state): State<AppState>,
|
||||
auth_session: AuthSession,
|
||||
path: Path<String>,
|
||||
request: Request<AxumBody>,
|
||||
) -> impl IntoResponse {
|
||||
log!("{:?}", path);
|
||||
|
||||
handle_server_fns_with_context(
|
||||
move || {
|
||||
provide_context(app_state.clone());
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn leptos_routes_handler(
|
||||
auth_session: AuthSession,
|
||||
State(app_state): State<AppState>,
|
||||
axum::extract::State(option): axum::extract::State<leptos::LeptosOptions>,
|
||||
request: Request<AxumBody>,
|
||||
) -> axum::response::Response {
|
||||
let handler = leptos_axum::render_app_async_with_context(
|
||||
option.clone(),
|
||||
move || {
|
||||
provide_context(app_state.clone());
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
},
|
||||
move || view! { <sso_auth_axum::App/> },
|
||||
);
|
||||
|
||||
handler(request).await.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info)
|
||||
.expect("couldn't initialize logging");
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite:sso.db")
|
||||
.await
|
||||
.expect("Could not make pool.");
|
||||
|
||||
// Auth section
|
||||
let session_config = SessionConfig::default()
|
||||
.with_table_name("sessions_table")
|
||||
.with_key(Key::generate())
|
||||
.with_database_key(Key::generate());
|
||||
// .with_security_mode(SecurityMode::PerSession); // FIXME did this disappear?
|
||||
|
||||
let auth_config = AuthConfig::<i64>::default();
|
||||
let session_store = SessionStore::<SessionSqlitePool>::new(
|
||||
Some(pool.clone().into()),
|
||||
session_config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("could not run SQLx migrations");
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(sso_auth_axum::App);
|
||||
|
||||
// We create our client using provided environment variables.
|
||||
let client = oauth2::basic::BasicClient::new(
|
||||
oauth2::ClientId::new(
|
||||
std::env::var("G_AUTH_CLIENT_ID")
|
||||
.expect("G_AUTH_CLIENT Env var to be set."),
|
||||
),
|
||||
Some(oauth2::ClientSecret::new(
|
||||
std::env::var("G_AUTH_SECRET")
|
||||
.expect("G_AUTH_SECRET Env var to be set"),
|
||||
)),
|
||||
oauth2::AuthUrl::new(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
Some(
|
||||
oauth2::TokenUrl::new(
|
||||
"https://oauth2.googleapis.com/token".to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.set_redirect_uri(
|
||||
oauth2::RedirectUrl::new(
|
||||
std::env::var("REDIRECT_URL")
|
||||
.expect("REDIRECT_URL Env var to be set"),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let app_state = AppState {
|
||||
leptos_options,
|
||||
pool: pool.clone(),
|
||||
client,
|
||||
};
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/*fn_name",
|
||||
get(server_fn_handler).post(server_fn_handler),
|
||||
)
|
||||
.leptos_routes_with_handler(routes, get(leptos_routes_handler))
|
||||
.fallback(file_and_error_handler)
|
||||
.layer(
|
||||
AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(
|
||||
Some(pool.clone()),
|
||||
)
|
||||
.with_config(auth_config),
|
||||
)
|
||||
.layer(SessionLayer::new(session_store))
|
||||
.with_state(app_state);
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
log!("listening on http://{}", &addr);
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
211
projects/sso_auth_axum/src/sign_in_sign_up.rs
Normal file
211
projects/sso_auth_axum/src/sign_in_sign_up.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod ssr_imports {
|
||||
pub use crate::{
|
||||
auth::{ssr_imports::SqlCsrfToken, User},
|
||||
state::AppState,
|
||||
};
|
||||
pub use oauth2::{
|
||||
reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope,
|
||||
TokenResponse,
|
||||
};
|
||||
pub use serde_json::Value;
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn google_sso() -> Result<String, ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
use ssr_imports::*;
|
||||
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let pool = pool()?;
|
||||
|
||||
// We get the authorization URL and CSRF_TOKEN
|
||||
let (authorize_url, csrf_token) = oauth_client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
// required for google auth refresh token to be part of the response.
|
||||
.add_extra_param("access_type", "offline")
|
||||
.add_extra_param("prompt", "consent")
|
||||
.url();
|
||||
let url = authorize_url.to_string();
|
||||
leptos::logging::log!("{url:?}");
|
||||
// Store the CSRF_TOKEN in our sqlite db.
|
||||
sqlx::query("INSERT INTO csrf_tokens (csrf_token) VALUES (?)")
|
||||
.bind(csrf_token.secret())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map(|_| ())?;
|
||||
|
||||
// Send the url to the client.
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SignIn() -> impl IntoView {
|
||||
let g_auth = Action::<GoogleSso, _>::server();
|
||||
|
||||
create_effect(move |_| {
|
||||
if let Some(Ok(redirect)) = g_auth.value().get() {
|
||||
window().location().set_href(&redirect).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div style="
|
||||
display:flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
">
|
||||
<div> {"Sign Up Sign In"} </div>
|
||||
<button style="display:flex;" on:click=move|_| g_auth.dispatch(GoogleSso{})>
|
||||
<svg style="width:2rem;" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</svg>
|
||||
<span style="margin-left:0.5rem;">"Sign in with Google"</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn handle_g_auth_redirect(
|
||||
provided_csrf: String,
|
||||
code: String,
|
||||
) -> Result<(String, u64), ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
use ssr_imports::*;
|
||||
|
||||
let oauth_client = expect_context::<AppState>().client;
|
||||
let pool = pool()?;
|
||||
let auth_session = auth()?;
|
||||
// If there's no match we'll return an error.
|
||||
let _ = sqlx::query_as::<_, SqlCsrfToken>(
|
||||
"SELECT csrf_token FROM csrf_tokens WHERE csrf_token = ?",
|
||||
)
|
||||
.bind(provided_csrf)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|err| ServerFnError::new(format!("CSRF_TOKEN error : {err:?}")))?;
|
||||
|
||||
let token_response = oauth_client
|
||||
.exchange_code(AuthorizationCode::new(code.clone()))
|
||||
.request_async(async_http_client)
|
||||
.await?;
|
||||
leptos::logging::log!("{:?}", &token_response);
|
||||
let access_token = token_response.access_token().secret();
|
||||
let expires_in = token_response.expires_in().unwrap().as_secs();
|
||||
let refresh_secret = token_response.refresh_token().unwrap().secret();
|
||||
let user_info_url = "https://www.googleapis.com/oauth2/v3/userinfo";
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(user_info_url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let email = if response.status().is_success() {
|
||||
let response_json: Value = response.json().await?;
|
||||
leptos::logging::log!("{response_json:?}");
|
||||
response_json["email"]
|
||||
.as_str()
|
||||
.expect("email to parse to string")
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Response from google has status of {}",
|
||||
response.status()
|
||||
)));
|
||||
};
|
||||
|
||||
let user = if let Some(user) = User::get_from_email(&email, &pool).await {
|
||||
user
|
||||
} else {
|
||||
sqlx::query("INSERT INTO users (email) VALUES (?)")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
User::get_from_email(&email, &pool).await.unwrap()
|
||||
};
|
||||
|
||||
auth_session.login_user(user.id);
|
||||
|
||||
sqlx::query("DELETE FROM google_tokens WHERE user_id == ?")
|
||||
.bind(user.id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO google_tokens (user_id,access_secret,refresh_secret) \
|
||||
VALUES (?,?,?)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(access_token)
|
||||
.bind(refresh_secret)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok((user.email, expires_in as u64))
|
||||
}
|
||||
|
||||
#[derive(Params, Debug, PartialEq, Clone)]
|
||||
pub struct OAuthParams {
|
||||
pub code: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HandleGAuth() -> impl IntoView {
|
||||
let handle_g_auth_redirect = Action::<HandleGAuthRedirect, _>::server();
|
||||
|
||||
let query = use_query::<OAuthParams>();
|
||||
let navigate = leptos_router::use_navigate();
|
||||
let rw_email = expect_context::<Email>().0;
|
||||
let rw_expires_in = expect_context::<ExpiresIn>().0;
|
||||
create_effect(move |_| {
|
||||
if let Some(Ok((email, expires_in))) =
|
||||
handle_g_auth_redirect.value().get()
|
||||
{
|
||||
rw_email.set(Some(email));
|
||||
rw_expires_in.set(expires_in);
|
||||
navigate("/", NavigateOptions::default());
|
||||
}
|
||||
});
|
||||
|
||||
create_effect(move |_| {
|
||||
if let Ok(OAuthParams { code, state }) = query.get_untracked() {
|
||||
handle_g_auth_redirect.dispatch(HandleGAuthRedirect {
|
||||
provided_csrf: state.unwrap(),
|
||||
code: code.unwrap(),
|
||||
});
|
||||
} else {
|
||||
leptos::logging::log!("error parsing oauth params");
|
||||
}
|
||||
});
|
||||
view! {}
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
use crate::ssr_imports::*;
|
||||
|
||||
let auth = auth()?;
|
||||
auth.logout_user();
|
||||
leptos_axum::redirect("/");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LogOut() -> impl IntoView {
|
||||
let log_out = create_server_action::<Logout>();
|
||||
view! {
|
||||
<button on:click=move|_|log_out.dispatch(Logout{})>{"log out"}</button>
|
||||
}
|
||||
}
|
||||
12
projects/sso_auth_axum/src/state.rs
Normal file
12
projects/sso_auth_axum/src/state.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use axum::extract::FromRef;
|
||||
use leptos::LeptosOptions;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// This takes advantage of Axum's SubStates feature by deriving FromRef. This is the only way to have more than one
|
||||
/// item in Axum's State. Leptos requires you to have leptosOptions in your State struct for the leptos route handlers
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState {
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool,
|
||||
pub client: oauth2::basic::BasicClient,
|
||||
}
|
||||
0
projects/sso_auth_axum/sso.db
Normal file
0
projects/sso_auth_axum/sso.db
Normal file
7
projects/sso_auth_axum/style.css
Normal file
7
projects/sso_auth_axum/style.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.pending {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
a {
|
||||
color: black;
|
||||
}
|
||||
Reference in New Issue
Block a user