example: Login with API token (CSR only) (#523)

This commit is contained in:
Markus Kohlhase 2023-02-24 23:11:58 +01:00 committed by GitHub
parent 46e6e7629c
commit 0301c7f1cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1021 additions and 0 deletions

View file

@ -0,0 +1,7 @@
[workspace]
members = ["client", "api-boundary", "server"]
[patch.crates-io]
leptos = { path = "../../leptos" }
leptos_router = { path = "../../router" }
api-boundary = { path = "api-boundary" }

View file

@ -0,0 +1,23 @@
# Leptos Login Example
This example demonstrates a scenario of a client-side rendered application
that uses uses an existing API that you cannot or do not want to change.
The authentications of this example are done using an API token.
## Run
First start the example server:
```
cd server/ && cargo run
```
then use [`trunk`](https://trunkrs.dev) to serve the SPA:
```
cd client/ && trunk serve
```
finally you can visit the web application at `http://localhost:8080`
The `api-boundary` crate contains data structures that are used by the server and the client.

View file

@ -0,0 +1,8 @@
[package]
name = "api-boundary"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }

View 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,
}

View file

@ -0,0 +1,19 @@
[package]
name = "client"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
api-boundary = "*"
leptos = { version = "0.2.0-alpha2", features = ["stable"] }
leptos_router = { version = "0.2.0-alpha2", features = ["stable", "csr"] }
log = "0.4"
console_error_panic_hook = "0.1"
console_log = "0.2"
gloo-net = "0.2"
gloo-storage = "0.2"
serde = "1.0"
thiserror = "1.0"

View file

@ -0,0 +1,3 @@
[[proxy]]
rewrite = "/api/"
backend = "http://0.0.0.0:3000/"

View 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>

View file

@ -0,0 +1,94 @@
use gloo_net::http::{Request, Response};
use serde::de::DeserializeOwned;
use thiserror::Error;
use api_boundary::*;
#[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: Request) -> 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())
}
}

View file

@ -0,0 +1,73 @@
use leptos::{ev, *};
#[component]
pub fn CredentialsForm(
cx: Scope,
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(cx, String::new());
let (email, set_email) = create_signal(cx, String::new());
let dispatch_action =
move || action.dispatch((email.get(), password.get()));
let button_is_disabled = Signal::derive(cx, move || {
disabled.get() || password.get().is_empty() || email.get().is_empty()
});
view! { cx,
<form on:submit=|ev|ev.prevent_default()>
<p>{ title }</p>
{move || error.get().map(|err| view!{ cx,
<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);
}
// The `change` event fires when the browser fills the form automatically,
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);
}
}
}
// The `change` event fires when the browser fills the form automatically,
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>
}
}

View file

@ -0,0 +1,4 @@
pub mod credentials;
pub mod navbar;
pub use self::{credentials::*, navbar::*};

View file

@ -0,0 +1,32 @@
use leptos::*;
use leptos_router::*;
use crate::Page;
#[component]
pub fn NavBar<F>(
cx: Scope,
logged_in: Signal<bool>,
on_logout: F,
) -> impl IntoView
where
F: Fn() + 'static + Clone,
{
view! { cx,
<nav>
<Show
when = move || logged_in.get()
fallback = |cx| view! { cx,
<A href=Page::Login.path() >"Login"</A>
" | "
<A href=Page::Register.path() >"Register"</A>
}
>
<a href="#" on:click={
let on_logout = on_logout.clone();
move |_| on_logout()
}>"Logout"</a>
</Show>
</nav>
}
}

View file

@ -0,0 +1,130 @@
use gloo_storage::{LocalStorage, Storage};
use leptos::*;
use leptos_router::*;
use api_boundary::*;
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(cx: Scope) -> impl IntoView {
// -- signals -- //
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
let user_info = create_rw_signal(cx, None::<UserInfo>);
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
// -- actions -- //
let fetch_user_info = create_action(cx, 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(cx, 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());
// -- effects -- //
create_effect(cx, 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! { cx,
<Router>
<NavBar logged_in on_logout />
<main>
<Routes>
<Route
path=Page::Home.path()
view=move |cx| view! { cx,
<Home user_info = user_info.into() />
}
/>
<Route
path=Page::Login.path()
view=move |cx| view! { cx,
<Login
api = unauthorized_api
on_success = move |api| {
log::info!("Successfully logged in");
authorized_api.update(|v| *v = Some(api));
let navigate = use_navigate(cx);
navigate(Page::Home.path(), Default::default()).expect("Home route");
fetch_user_info.dispatch(());
} />
}
/>
<Route
path=Page::Register.path()
view=move |cx| view! { cx,
<Register api = unauthorized_api />
}
/>
</Routes>
</main>
</Router>
}
}

View file

@ -0,0 +1,9 @@
use leptos::*;
use client::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|cx| view! { cx, <App /> })
}

View file

@ -0,0 +1,20 @@
use crate::Page;
use api_boundary::UserInfo;
use leptos::*;
use leptos_router::*;
#[component]
pub fn Home(cx: Scope, user_info: Signal<Option<UserInfo>>) -> impl IntoView {
view! { cx,
<h2>"Leptos Login example"</h2>
{move || match user_info.get() {
Some(info) => view!{ cx,
<p>"You are logged in with "{ info.email }"."</p>
}.into_view(cx),
None => view!{ cx,
<p>"You are not logged in."</p>
<A href=Page::Login.path() >"Login now."</A>
}.into_view(cx)
}}
}
}

View file

@ -0,0 +1,66 @@
use leptos::*;
use leptos_router::*;
use api_boundary::*;
use crate::{
api::{self, AuthorizedApi, UnauthorizedApi},
components::credentials::*,
Page,
};
#[component]
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
where
F: Fn(AuthorizedApi) + 'static + Clone,
{
let (login_error, set_login_error) = create_signal(cx, None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
let login_action =
create_action(cx, 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 };
let on_success = on_success.clone();
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(res);
}
Err(err) => {
let msg = match err {
api::Error::Fetch(js_err) => {
format!("{js_err:?}")
}
api::Error::Api(err) => err.message,
};
error!(
"Unable to login with {}: {msg}",
credentials.email
);
set_login_error.update(|e| *e = Some(msg));
}
}
}
});
let disabled = Signal::derive(cx, move || wait_for_response.get());
view! { cx,
<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>
}
}

View 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",
}
}
}

View file

@ -0,0 +1,77 @@
use leptos::*;
use leptos_router::*;
use api_boundary::*;
use crate::{
api::{self, UnauthorizedApi},
components::credentials::*,
Page,
};
#[component]
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
let (register_response, set_register_response) =
create_signal(cx, None::<()>);
let (register_error, set_register_error) =
create_signal(cx, None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
let register_action =
create_action(cx, 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(cx, move || wait_for_response.get());
view! { cx,
<Show
when = move || register_response.get().is_some()
fallback = move |_| view!{ cx,
<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>
}
}

View file

@ -0,0 +1,18 @@
[package]
name = "server"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
anyhow = "1.0"
api-boundary = "*"
axum = { version = "0.6", features = ["headers"] }
env_logger = "0.10"
log = "0.4"
mailparse = "0.14"
pwhash = "1.0"
thiserror = "1.0"
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.3", features = ["cors"] }
uuid = { version = "1.3", features = ["v4"] }

View file

@ -0,0 +1,114 @@
use crate::{application::*, Error};
use api_boundary as json;
use axum::{
http::StatusCode,
response::Json,
response::{IntoResponse, 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()
}
}

View file

@ -0,0 +1,159 @@
use mailparse::addrparse;
use pwhash::bcrypt;
use std::{collections::HashMap, str::FromStr, sync::RwLock};
use thiserror::Error;
use uuid::Uuid;
#[derive(Default)]
pub struct AppState {
users: RwLock<HashMap<EmailAddress, Password>>,
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
}
impl AppState {
pub fn create_user(
&self,
credentials: Credentials,
) -> Result<(), CreateUserError> {
let Credentials { email, password } = credentials;
let user_exists = self.users.read().unwrap().get(&email).is_some();
if user_exists {
return Err(CreateUserError::UserExists);
}
self.users.write().unwrap().insert(email, password);
Ok(())
}
pub fn login(
&self,
email: EmailAddress,
password: &str,
) -> Result<Uuid, LoginError> {
let valid_credentials = self
.users
.read()
.unwrap()
.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.write().unwrap().insert(token, email);
Ok(token)
}
}
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
let token = token
.parse::<Uuid>()
.map_err(|_| LogoutError::NotLoggedIn)?;
self.tokens.write().unwrap().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
.read()
.unwrap()
.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,
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))
}
}

View file

@ -0,0 +1,113 @@
use std::{env, sync::Arc};
use axum::{
extract::{State, TypedHeader},
headers::{authorization::Bearer, Authorization},
http::Method,
response::Json,
routing::{get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use api_boundary as json;
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(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().unwrap();
log::info!("Listen on {addr}");
axum::Server::bind(&addr)
.serve(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<AppState>>,
Json(credentials): Json<json::Credentials>,
) -> Result<()> {
let credentials = Credentials::try_from(credentials)?;
state.create_user(credentials)?;
Ok(Json(()))
}
async fn login(
State(state): State<Arc<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.login(email, &password).map(|s| s.to_string())?;
Ok(Json(json::ApiToken { token }))
}
async fn logout(
State(state): State<Arc<AppState>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<()> {
state.logout(auth.token())?;
Ok(Json(()))
}
async fn get_user_info(
State(state): State<Arc<AppState>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<json::UserInfo> {
let user = state.authorize_user(auth.token())?;
let CurrentUser { email, .. } = user;
Ok(Json(json::UserInfo {
email: email.into_string(),
}))
}