diff --git a/docs/next/en-US/SUMMARY.md b/docs/next/en-US/SUMMARY.md index a99f9f8701..2221724103 100644 --- a/docs/next/en-US/SUMMARY.md +++ b/docs/next/en-US/SUMMARY.md @@ -80,3 +80,9 @@ - [Routing](/docs/advanced/routing) - [`define_app` in Detail](/docs/advanced/define_app) - [Route Announcer](/docs/advanced/route-announcer) + +--- + +# Further Tutorials + +- [Authentication](docs/tutorials/auth) diff --git a/docs/next/en-US/tutorials/auth.md b/docs/next/en-US/tutorials/auth.md new file mode 100644 index 0000000000..c33ad23e7e --- /dev/null +++ b/docs/next/en-US/tutorials/auth.md @@ -0,0 +1,85 @@ +# Authentication + +If you're building an app with multiple users that might have different preferences or the like, chances are you'll need some way for those users to log in, you'll need an authentication system. This is fairly easy to achieve in Perseus with the [global state](:reference/state/global) system, though there are a few gotchas -- hence this tutorial! + +## Concepts + +Authentication as a process involves the user wanting to go to some page that they need to be logged in to access (e.g. a dashboard of bank statements), logging in, accessing that page, and then maybe logging back out a little later. All that really boils down to in terms of code is a system to manage whether or not the user is logged in, a system of authenticating the user's password (or however else they're logging in), and a system of providing access on certain pages only to authenticated users. + +The first part can be achieved through an entry in an app's global state that describes whether or not the user is logged in, what their username is, etc. Notably, this could be saved through [state freezing](:reference/state/freezing) to IndexedDB (which would preserve all the properties of the user's login) (not covered in this tutorial), though we still need a way of securely confirming that the user is who they say they are. For that, we would need to store a token, often done in the browser's [local storage](TODO) API, which we'll use in this example. As for how this token works, that requires a good deal of thought to security and how your app will work, and so is elided here (we'll just use a very insecure 'token' that tells us what the user's username is). + +The final part of this is controlling access to protected pages, which is the part where Perseus becomes more relevant as a framework. There are two types of protected pages that you might have, user-specific and non-user-specific. If a protected page is user-specific, then it's useless without the user's personal data. For example, a list of bank statements is completely worthless to an attacker without the user's bank statements populating it, rather than a loading skeleton. For these kinds of pages, we can render a skeleton on the server that's then populated with the user's information once the page is loaded in the browser. This means we don't have to go to any extra lengths to prevent access to the skeleton, since we'll assume that the user's data can only be accessed over an APi that needs some unique token that can only be generated with the user's password, or something similar. + +If a protected page is non-user-specific, that means it contains content that's the same for all users, but that should only be accessible to users who have logged in. These are more complex because protecting them requires that you don't prerender them on the server at all, and that the code for the protected content not be in your codebase. That may seem weird -- how can you render it at all if it's not in your codebase? Well, you'd have to check if the user is authenticated, and then use some token to fetch the protected content from a server and then display that. If you were to have the protected content anywhere in your code, then it would be accessible to any user willing to reverse-engineer the generated WebAssembly (which isn't too tricky), and hence not really protected at all. + +## Secure Authentication + +TODO + +## Building the App + +### Setup + +To start with, we'll set up a fairly typical Perseus app by initializing a new Rust project with `cargo new --lib`. Then, put the following in `Cargo.toml` (changing the package name as you want): + +```toml +{{#include ../../../../examples/auth/Cargo.toml}} +``` + +The only things of particular note here are the dependency on `web-sys`, from which we use the `Storage` feature (important later), as well as not using Sycamore's [hydration](:reference/hydration) system, as it doesn't handle the kinds of page changes from unauthenticated to authenticated that we'll need in this app. Note that hydration will likely work fine with this in future version of Sycamore (it's currently experimental though). + +Now add the following to `src/lib.rs`: + +```rust +{{#include ../../../../examples/auth/src/lib.rs}} +``` + +This is a very typical scaffold, but the use of the global state creator is important, and that's what we'll look at next. You can put whatever you want into `src/error_pages.rs` to serve as your app's error pages, but that isn't the subject of this tutorial. You can read more about error pages [here](:reference/error-pages) though. + +In `src/global_state.rs`, put the following code: + +```rust +{{#include ../../../../examples/auth/src/global_state.rs}} +``` + +This is fairly intense, so let's break it down. + +The first thing we do is create the function we call from `src/lib.rs`, `get_global_state_creator`, which initializes a `GlobalStateCreator` with the `get_build_state` function to create the initial global state (generated on the server and passed to the client). What that function does is generates an instance of `AppState`, a `struct` that will store our app's global state (which can include anything you want), which crucially has the `auth` field, an instance of `AuthData`, which will store the data for user authentication. Notably, all these `struct`s are annotated with `.make_rx()` to make them work with Perseus' state platform (note that `AppState` declares nested reactivity for the `auth` field, which you can read more about [here](:reference/state/global)). + +`AuthData` has two fields: `state` and `username`. The first is a `LoginState`, which can be `Yes` (the user is logged in), `No` (the user is not logged in), or `Server` (the page has been rendered on the server and we don't have any information about the user's login status yet). The reason for these three possibilities is so we don't assume the user to be logged out before we've even gotten to their browser, as that might result in an ugly flash between pages, or worse an inappropriate redirection to a login page. By forcing ourselves to handle the `Server` case, we make our code more robust and clearer. + +You might be wondering why we don't store `username`, which is just a `String`, as a property of `LoginState::Yes`, which would seem to be a much smarter data structure. This is absolutely true, but the problem is that the `make_rx` macro isn't smart enough to handle `enum`s, so we'd have to implement the `MakeRx` trait manually, which is a little tedious. To keep things simple, we'll go with storing `username` separately, but if you have multiple fields of information only relevant to authenticated users, you may want to take the more complex approach for cleanliness. + +Next, we implement some functions on `AuthDataRx`, the reactive version of `AuthData`, not bothering to do so on the original because we'll only use these functions in templates, in which we have the reactive version. The first method is `.detect_state()`, which will, if the state is `LoginState::Server`, check if the user is logged in by checking the `username` key in the browser's storage (not IndexedDB, local storage instead, which is more appropriate for this sort of thing). Note that this kind of 'token' management is absolutely atrocious and completely insecure, and serves only as an example of how you might start with authentication. Do NOT use this in a production app! + +The only other two functions are very simple, just `.login()` and `.logout()`, which alter the storage key and the global state to register a new login state. + +## Templates + +Okay, let's get into writing some views based on all this! We'll create an index page and an about page for demonstration, so set up a `src/templates/` directory with a `mod.rs` that declares both files. Then put the following in `src/templates/index.rs`: + +```rust +{{#include ../../../../examples/demos/auth/src/templates/index.rs}} +``` + +The only strange stuff in here is in `index_view()`, the rest is pretty much bog-standard Perseus template code. In `index_view()`, we don't take any template sttate, for demonstration purposes (you easily could), but we do take in the global state, which you'll remember contains all the authentication properties. Then we set up some `Signal`s outside the data model for handling a very simple login input (again, demonstrations). The important thing is the call to `auth.detect_state()`, which will refresh the authentication status by checking the user's browser for the login 'token' being stored. Note that, because we coded this to return straight away if we already know the login state, it's perfectly safe to put this at the start of every template you want to be authentication-protected. We also gate this with `#[cfg(target_arch = "wasm32")]` to make sure it only runs on the browser (because we can't check for storage tokens in the server build process, that will throw plenty of errors!). + +Skipping past the scary `let view = ...` block for a moment, the end of this function is dead simple: we just display a Sycamore `View` stored in a `Signal` (that's in the `view` variable), and then a link to the about page. Anything other than that `(*view.get())` call will be displayed *whether the user is authenticated or not*. + +Now for the fun part. To give us maximum editor support and cleanliness, we define the bulk of the view code outside the `view!` macro and in a variable called `view` instead, a derived `Signal` built with `create_memo` running on `auth.state`. So, if `auth.state` changes, this will also update immediately and automatically! All we do here is handle each of the three possible authentication states with a `match` statement: if we're on the server, we'll display nothing at all; if the user isn't logged in, a login page; and if they are, a welcome message and a logout button. In a real-world app, you'd probably have some code that redirects the user to a login page in the `LoginState::No` case. + +You might be wondering why we display nothing before the login state is known, because this would seem to undercut the purpose of preloading the page at all. The answer to this question is that it does, and in an ideal world you'd process the user's login data on the server-side before serving them the appropriate prerendered page, which you *could* do, but that would be unnecessarily complex. Instead, we can display a blank page for a moment before redirecting or loading the appropriate skeleton. + +In theory though, on some odler mobile devices, this blank screen might be visible for more than a moment (on 3G networks, it could be 2 seconds or more), which is not good at all. To remedy this, you could make `LoginState::Server` and `LoginState::Yes` render the same skeleton (with some blanks for unfetched user information), so you're essentially assuming the user to be logged in. That means only anonymous users get a flash, from the skeleton to a login page. If your login page is at a central route (e.g. `/login`), you could inject some JavaScript code to run before any of your page is rendered that would check if the user is logged in, and then redirect them to the login page before any of the page loaded if not. This is the best solution, which involves no flashing whatsoever, and the display time of your app is optimized for all users, without needing any server-side code! + +*Note: in future, there will likely be a plugin to perform this optimization automatically. If someone wants to create this now, please open a PR!* + +Finally, add the following into the about page (just a very basic unprotected page for comparison): + +```rust +{{#include ../../../../examples/demos/auth/src/templates/about.rs}} +``` + +## Conclusion + +Authentication in Perseus is fairly easy to implement, easier than in many other frameworks, though there are a few hurdles to get over and patterns to understand that will make your code more idiomatic. In future, nearly all this will likely be handled automatically by a plugin or library, which would enable more rapid and efficient development of complex apps. For now though, authentication must be built manually into Perseus apps. diff --git a/examples/demos/auth/.gitignore b/examples/demos/auth/.gitignore new file mode 100644 index 0000000000..9405098b45 --- /dev/null +++ b/examples/demos/auth/.gitignore @@ -0,0 +1 @@ +.perseus/ diff --git a/examples/demos/auth/Cargo.toml b/examples/demos/auth/Cargo.toml new file mode 100644 index 0000000000..ac2c63b2e7 --- /dev/null +++ b/examples/demos/auth/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "perseus-example-auth" +version = "0.3.3" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# We can't use hydration here yet (it doesn't handle the rapid page changes from unauthenticated to authenticated well) +perseus = { path = "../../../packages/perseus", features = [] } +sycamore = "0.7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# We need the `HtmlDocument` feature to be able to use cookies (which this example does) +web-sys = { version = "0.3", features = [ "Storage" ] } diff --git a/examples/demos/auth/README.md b/examples/demos/auth/README.md new file mode 100644 index 0000000000..0de8a47b85 --- /dev/null +++ b/examples/demos/auth/README.md @@ -0,0 +1,5 @@ +# Authentication Example + +This example demonstrates how to set up a basic authentication system in Perseus. + +*Note: the way this is implemented uses very rudimentary storage of simple identifiers in the browser's local storage. Not only is the 'token' implementation ludicrously insecure and for educational purposes only, using web storage simply will not work in some implementation of private browsing mode (e.g. Safari, which makes using this API effectively impossible in that mode). See [here](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API#private_browsing_incognito_modes) for further details.* diff --git a/examples/demos/auth/src/error_pages.rs b/examples/demos/auth/src/error_pages.rs new file mode 100644 index 0000000000..d472912966 --- /dev/null +++ b/examples/demos/auth/src/error_pages.rs @@ -0,0 +1,17 @@ +use perseus::{ErrorPages, Html}; +use sycamore::view; + +pub fn get_error_pages() -> ErrorPages { + let mut error_pages = ErrorPages::new(|url, status, err, _| { + view! { + p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } + } + }); + error_pages.add_page(404, |_, _, _, _| { + view! { + p { "Page not found." } + } + }); + + error_pages +} diff --git a/examples/demos/auth/src/global_state.rs b/examples/demos/auth/src/global_state.rs new file mode 100644 index 0000000000..c4146e9032 --- /dev/null +++ b/examples/demos/auth/src/global_state.rs @@ -0,0 +1,87 @@ +use perseus::{state::GlobalStateCreator, RenderFnResult}; +use serde::{Deserialize, Serialize}; + +pub fn get_global_state_creator() -> GlobalStateCreator { + GlobalStateCreator::new().build_state_fn(get_build_state) +} + +#[perseus::autoserde(global_build_state)] +pub async fn get_build_state() -> RenderFnResult { + Ok(AppState { + // We explicitly tell the first page that no login state has been checked yet + auth: AuthData { + state: LoginState::Server, + username: String::new(), + }, + }) +} + +#[perseus::make_rx(AppStateRx)] +#[rx::nested("auth", AuthDataRx)] +pub struct AppState { + /// Authentication data accessible to all pages. + pub auth: AuthData, +} + +/// The possible login states, including one for the server. +// A better structure might have `Yes` have an attached `AuthData` and use this as the top-level element, but then we'd have to implement `MakeRx`/`MakeUnrx` manually on this (`make_rx` +// can't handle `enum`s) +#[derive(Clone, Serialize, Deserialize)] +pub enum LoginState { + Yes, + No, + Server, +} + +/// Authentication data for the app. +// In a real app, you might store privileges here, or user preferences, etc. (all the things you'd need to have available constantly and everwhere) +#[perseus::make_rx(AuthDataRx)] +pub struct AuthData { + /// The actual login status. + pub state: LoginState, + /// The user's username. + pub username: String, +} +// We implement a custom function on the reactive version of the global state here (hence the `.get()`s and `.set()`s, all the fields become `Signal`s) +// There's no point in implementing it on the unreactive version, since this will only be called from within the browser, in which we have a reactive version +impl AuthDataRx { + /// Checks whether or not the user is logged in and modifies the internal state accordingly. If this has already been run, it won't do anything (aka. it will only run if it's `Server`) + #[cfg(target_arch = "wasm32")] // This just avoids an unused function warning (since we have to gate the `.update()` call) + pub fn detect_state(&self) { + // If we've checked the login status before, then we should assume the status hasn't changed (we'd change this in a login/logout page) + if let LoginState::Yes | LoginState::No = *self.state.get() { + return; + } + + // See the docs page on authentication to learn how to put something *secure* here + // This example is NOT production-safe, and would result in absolutely terrible security!!! + + // All we're doing in here is checking for the existence of a storage entry that contains a username (any attacker could trivially fake this) + // Note that this storage API may be inaccessible, which we completely ignore here for simplicity + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + let auth_token = storage.get("username").unwrap(); // This is a `Result, E>` + + if let Some(username) = auth_token { + self.username.set(username.to_string()); + self.state.set(LoginState::Yes); + } else { + self.username.set(String::new()); + self.state.set(LoginState::No) + } + } + + /// Logs the user in with the given username. + pub fn login(&self, username: &str) { + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.set("username", username).unwrap(); + self.state.set(LoginState::Yes); + self.username.set(username.to_string()); + } + /// Logs the user out. + pub fn logout(&self) { + let storage = web_sys::window().unwrap().local_storage().unwrap().unwrap(); + storage.delete("username").unwrap(); + self.state.set(LoginState::No); + self.username.set(String::new()); + } +} diff --git a/examples/demos/auth/src/lib.rs b/examples/demos/auth/src/lib.rs new file mode 100644 index 0000000000..418b487283 --- /dev/null +++ b/examples/demos/auth/src/lib.rs @@ -0,0 +1,14 @@ +mod error_pages; +mod global_state; +mod templates; + +use perseus::{Html, PerseusApp}; + +#[perseus::main] +pub fn main() -> PerseusApp { + PerseusApp::new() + .template(crate::templates::index::get_template) + .template(crate::templates::about::get_template) + .error_pages(crate::error_pages::get_error_pages) + .global_state_creator(crate::global_state::get_global_state_creator()) +} diff --git a/examples/demos/auth/src/templates/about.rs b/examples/demos/auth/src/templates/about.rs new file mode 100644 index 0000000000..6612f3704b --- /dev/null +++ b/examples/demos/auth/src/templates/about.rs @@ -0,0 +1,14 @@ +use perseus::Template; +use sycamore::prelude::{view, Html, View}; + +#[perseus::template_rx] +pub fn about_page() -> View { + view! { + p { "About." } + a(href = "") { "Index" } + } +} + +pub fn get_template() -> Template { + Template::new("about").template(about_page) +} diff --git a/examples/demos/auth/src/templates/index.rs b/examples/demos/auth/src/templates/index.rs new file mode 100644 index 0000000000..f0d0db559f --- /dev/null +++ b/examples/demos/auth/src/templates/index.rs @@ -0,0 +1,50 @@ +use crate::global_state::{AppStateRx, LoginState}; +use perseus::{Html, Template}; +use sycamore::prelude::*; + +#[perseus::template_rx] +fn index_view(_: (), AppStateRx { auth }: AppStateRx) -> View { + // This isn't part of our data model because it's only used here to pass to the login function + let entered_username = Signal::new(String::new()); + let eu_2 = entered_username.clone(); + + // We have to trigger this from outside the `create_memo`, and we should only be interacting with storage APIs in the browser (otherwise this would be called on the server too) + // This will only cause a block on the first load, because this function just returns straight away if the state is already known + #[cfg(target_arch = "wasm32")] + auth.detect_state(); + + // We make the view as a memo outside the root `view!` for better editor support (some editors don't like highlighting code in macros) + // We need to clone `global_state` because otherwise the `Signal` updates won't be registered + let view = create_memo(cloned!(auth => move || { + match *auth.state.get() { + LoginState::Yes => { + let username = auth.username.get(); + view! { + h1 { (format!("Welcome back, {}!", &username)) } + button(on:click = cloned!(auth => move |_| { + auth.logout(); + })) { "Logout" } + } + } + // You could also redirect the user to a dedicated login page + LoginState::No => view! { + h1 { "Welcome, stranger!" } + input(bind:value = entered_username.clone(), placeholder = "Username") + button(on:click = cloned!(eu_2, auth => move |_| { + auth.login(&eu_2.get()) + })) { "Login" } + }, + // This will appear for a few moments while we figure out if the user is logged in or not + LoginState::Server => View::empty(), + } + })); + view! { + (*view.get()) + br() + a(href = "about") { "About" } + } +} + +pub fn get_template() -> Template { + Template::new("index").template(index_view) +} diff --git a/examples/demos/auth/src/templates/mod.rs b/examples/demos/auth/src/templates/mod.rs new file mode 100644 index 0000000000..9b9cf18fc5 --- /dev/null +++ b/examples/demos/auth/src/templates/mod.rs @@ -0,0 +1,2 @@ +pub mod about; +pub mod index;