Error handling and better granularity

demo-mode
Wes Holland 1 year ago
parent bba99009b4
commit db765c18be

35
Cargo.lock generated

@ -160,6 +160,7 @@ checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core",
"axum-macros",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.1.0", "http 1.1.0",
@ -207,6 +208,28 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-extra"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9"
dependencies = [
"axum",
"axum-core",
"bytes",
"futures-util",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"serde",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "axum-htmx" name = "axum-htmx"
version = "0.6.0" version = "0.6.0"
@ -218,6 +241,17 @@ dependencies = [
"http 1.1.0", "http 1.1.0",
] ]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.74"
@ -1093,6 +1127,7 @@ dependencies = [
"askama", "askama",
"askama_axum", "askama_axum",
"axum", "axum",
"axum-extra",
"axum-htmx", "axum-htmx",
"dotenvy", "dotenvy",
"httpc-test", "httpc-test",

@ -6,7 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.91" anyhow = "1.0.91"
askama = { version = "0.12.1", features = ["with-axum"] } askama = { version = "0.12.1", features = ["with-axum"] }
axum = "0.7.7" axum = { version = "0.7.7", features = ["macros"] }
axum-htmx = "0.6.0" axum-htmx = "0.6.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
oauth2 = "4.4.2" oauth2 = "4.4.2"
@ -22,6 +22,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
reqwest = { version = "0.12.9", features = ["json"] } reqwest = { version = "0.12.9", features = ["json"] }
askama_axum = "0.4.0" askama_axum = "0.4.0"
axum-extra = "0.9.4"
[dev-dependencies] [dev-dependencies]
httpc-test = "0.1.10" httpc-test = "0.1.10"

@ -9,3 +9,5 @@ OAUTH_TOKEN_URL=https://accounts.google.com/o/oauth2/token
OAUTH_REVOKE_URL=https://accounts.google.com/o/oauth2/revoke OAUTH_REVOKE_URL=https://accounts.google.com/o/oauth2/revoke
OAUTH_USER_INFO_URL=https://www.googleapis.com/oauth2/v1/userinfo OAUTH_USER_INFO_URL=https://www.googleapis.com/oauth2/v1/userinfo
OAUTH_REDIRECT_URL=http://localhost:4206/auth/authorized OAUTH_REDIRECT_URL=http://localhost:4206/auth/authorized
AUTHORIZED_USERS=user1@somewhere.com;user2@somewhere.com
ROUTES_INCLUDE_ERROR_TESTS=no

@ -1,9 +1,11 @@
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use axum::{async_trait, http}; use askama::Template;
use axum::{async_trait, http, Router};
use axum::extract::{FromRef, FromRequestParts, Query, State}; use axum::extract::{FromRef, FromRequestParts, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::http::request::Parts; use axum::http::request::Parts;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use oauth2::basic::BasicClient; use oauth2::basic::BasicClient;
use oauth2::reqwest::async_http_client; use oauth2::reqwest::async_http_client;
use oauth2::{AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RevocationUrl, Scope, TokenResponse, TokenUrl}; use oauth2::{AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RevocationUrl, Scope, TokenResponse, TokenUrl};
@ -11,9 +13,17 @@ use serde::{Deserialize, Serialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tower_sessions::Session; use tower_sessions::Session;
use crate::error::AppError; use crate::error::{AppError, AppForbiddenResponse};
use crate::{CSRF_TOKEN, USER_SESSION}; use crate::{auth, CSRF_TOKEN, USER_SESSION};
use crate::error::QueryExtractor;
use crate::app_state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/auth/login", get(auth_login))
.route("/auth/logout", get(auth_logout))
.route("/auth/authorized", get(auth_authorized))
}
pub fn init_client() -> anyhow::Result<BasicClient> { pub fn init_client() -> anyhow::Result<BasicClient> {
use std::env::var; use std::env::var;
@ -43,10 +53,15 @@ pub fn init_client() -> anyhow::Result<BasicClient> {
Ok(client) Ok(client)
} }
pub async fn auth_google( pub async fn auth_login(
session: Session, session: Session,
user: Option<User>,
State(oauth_client): State<BasicClient>, State(oauth_client): State<BasicClient>,
) -> anyhow::Result<impl IntoResponse, AppError> { ) -> anyhow::Result<impl IntoResponse, AppError> {
if user.is_some() {
return Ok(Redirect::to("/"));
}
let (auth_url, csrf_token) = oauth_client let (auth_url, csrf_token) = oauth_client
.authorize_url(CsrfToken::new_random) .authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("profile".to_string()))
@ -61,7 +76,7 @@ pub async fn auth_google(
pub async fn auth_authorized( pub async fn auth_authorized(
session: Session, session: Session,
Query(query_auth): Query<AuthRequest>, QueryExtractor(query_auth): QueryExtractor<AuthRequest>,
State(oauth_client): State<BasicClient>, State(oauth_client): State<BasicClient>,
) -> anyhow::Result<impl IntoResponse, AppError> { ) -> anyhow::Result<impl IntoResponse, AppError> {
let user_info_endpoint = std::env::var("OAUTH_USER_INFO_URL") let user_info_endpoint = std::env::var("OAUTH_USER_INFO_URL")
@ -106,24 +121,29 @@ pub async fn auth_authorized(
let is_authorized = valid_users.contains(&user_data.email.as_str()); let is_authorized = valid_users.contains(&user_data.email.as_str());
if is_authorized { if !is_authorized {
session.insert(USER_SESSION, user_data).await?; return Ok(AppForbiddenResponse::new(&user_data.email, "application").into_response())
//TODO Redirect somewhere sane
Ok(Redirect::to("/protected").into_response())
}
else {
Ok((http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response())
} }
session.insert(USER_SESSION, user_data).await?;
Ok(Redirect::to("/").into_response())
} }
#[derive(Template)]
#[template(path = "logged-out.html")]
struct LoggedOutTemplate;
pub async fn auth_logout( pub async fn auth_logout(
session: Session, session: Session,
user: Option<User>,
) -> anyhow::Result<impl IntoResponse, AppError> { ) -> anyhow::Result<impl IntoResponse, AppError> {
session.remove::<User>(USER_SESSION).await?; if user.is_some() {
session.remove::<User>(USER_SESSION).await?;
}
Ok(Redirect::to("/")) Ok(LoggedOutTemplate.into_response())
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -141,30 +161,20 @@ pub struct User {
pub picture: String, pub picture: String,
} }
pub struct UserExtractError(http::StatusCode); pub enum UserExtractError {
InternalServerError(anyhow::Error),
Unauthorized,
}
impl IntoResponse for UserExtractError { impl IntoResponse for UserExtractError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.0 { match self {
StatusCode::UNAUTHORIZED => { Redirect::temporary("/auth/login").into_response() } UserExtractError::InternalServerError(err) => AppError::from(err).into_response(),
StatusCode::FORBIDDEN => { StatusCode::FORBIDDEN.into_response() } UserExtractError::Unauthorized => { Redirect::temporary("/auth/login").into_response() }
_ => StatusCode::INTERNAL_SERVER_ERROR.into_response()
} }
} }
} }
impl From<(http::StatusCode, &'static str)> for UserExtractError {
fn from(value: (StatusCode, &'static str)) -> Self {
Self(value.0)
}
}
impl From<tower_sessions::session::Error> for UserExtractError {
fn from(_value: tower_sessions::session::Error) -> Self {
Self(StatusCode::INTERNAL_SERVER_ERROR)
}
}
#[async_trait] #[async_trait]
impl<S> FromRequestParts<S> for User impl<S> FromRequestParts<S> for User
where where
@ -175,11 +185,11 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state).await let session = Session::from_request_parts(parts, state).await
.map_err(|_| UserExtractError(StatusCode::INTERNAL_SERVER_ERROR))?; .map_err(|_| UserExtractError::InternalServerError(anyhow!("session from parts failed")))?;
let user: User = session.get(USER_SESSION).await let user = session.get(USER_SESSION).await
.map_err(|_| UserExtractError(StatusCode::INTERNAL_SERVER_ERROR))? .map_err(|e| UserExtractError::InternalServerError(anyhow::Error::from(e)))?
.ok_or(UserExtractError(StatusCode::UNAUTHORIZED))?; .ok_or(UserExtractError::Unauthorized)?;
Ok(user) Ok(user)
} }

@ -1,21 +1,59 @@
use anyhow::anyhow;
use askama::Template;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Router;
use axum::routing::get;
use axum::extract::FromRequestParts;
use crate::app_state::AppState;
use crate::auth::User;
/// These are just test routes. They shouldn't really be called directly
/// as they just return an error. But they are nice for testing
pub fn routes() -> Router<AppState> {
use std::env::var;
let mut router = Router::new();
let should_include = var("ROUTES_INCLUDE_ERROR_TESTS")
.unwrap_or("no".to_string());
if should_include.as_str() == "yes" {
router = router
.route("/error/forbidden", get(forbidden))
.route("/error/unhandled", get(fail));
}
router
}
/// Handler that always responds with 404 Not Found
pub async fn not_found() -> impl IntoResponse {
AppNotFoundResponse.into_response()
}
/// Application error that is a thin layer over anyhow::Error but has the distinction of having /// Application error that is a thin layer over anyhow::Error but has the distinction of having
/// the piping for converting from the error to an axum response /// the piping for converting from the error to an axum response
pub struct AppError(anyhow::Error); pub struct AppError(anyhow::Error);
/// A template for a bit nicer error page to the basic error that is just a bare string
#[derive(Template)]
#[template(path = "app-error.html")]
struct AppErrorTemplate;
// Convert app error into axum response. This is the default path for generic errors /// Convert app error into axum response. This is the default path for generic errors
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
tracing::error!("Unhandled error: {:#}", self.0); tracing::error!("Unhandled error: {:#}", self.0);
(StatusCode::INTERNAL_SERVER_ERROR, "Unhandled internal error",).into_response() let (mut parts, body) = AppErrorTemplate.into_response().into_parts();
parts.status = StatusCode::INTERNAL_SERVER_ERROR;
(parts, body).into_response()
} }
} }
// Quality-of-life helper that saves us from converting errors manually /// Quality-of-life helper that saves us from converting errors manually
impl<E> From<E> for AppError impl<E> From<E> for AppError
where where
E: Into<anyhow::Error>, E: Into<anyhow::Error>,
@ -24,3 +62,69 @@ where
Self(err.into()) Self(err.into())
} }
} }
/// A custom query extractor that yields an app error instead of
/// a query rejection error. This is automatically hooked up
/// because the query rejection is able to be Into'ed into an
/// anyhow::Error
#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(AppError))]
pub struct QueryExtractor<T>(pub T);
/// A response type for when returning a 403 (Forbidden) Response
pub struct AppForbiddenResponse {
email: String,
resource: String,
}
#[derive(Template)]
#[template(path = "forbidden.html")]
struct ForbiddenTemplate;
impl AppForbiddenResponse {
pub fn new(email: &str, resource: &str) -> Self {
Self { email: email.to_string(), resource: resource.to_string() }
}
}
impl IntoResponse for AppForbiddenResponse {
fn into_response(self) -> Response {
tracing::error!("forbidden {} accessing {}", self.email, self.resource);
let (mut parts, body) = ForbiddenTemplate.into_response().into_parts();
parts.status = StatusCode::FORBIDDEN;
(parts, body).into_response()
}
}
/// A response type for when returning a 404 (Not found) Response
pub struct AppNotFoundResponse;
#[derive(Template)]
#[template(path = "not-found.html")]
struct NotFoundTemplate;
impl IntoResponse for AppNotFoundResponse {
fn into_response(self) -> Response {
let (mut parts, body) = NotFoundTemplate.into_response().into_parts();
parts.status = StatusCode::NOT_FOUND;
(parts, body).into_response()
}
}
/// Handler that always fails with a 500 Error
async fn fail() -> anyhow::Result<(), AppError> {
let val = always_fails()?;
Ok(val)
}
fn always_fails() -> anyhow::Result<()> {
Err(anyhow!("I always fail"))
}
/// Handler that always responds with 403 Forbidden
async fn forbidden(user: User) -> impl IntoResponse {
AppForbiddenResponse::new(&user.email, "test endpoint")
}

@ -1,15 +1,13 @@
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::auth::User; use crate::auth::User;
use crate::error::AppError; use crate::error::{AppError, AppForbiddenResponse};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use askama::Template; use askama_axum::Template;
use axum::extract::{FromRef, Query, State}; use axum::extract::{FromRef, Query, State};
use axum::http::header::SET_COOKIE; use axum::http::header::SET_COOKIE;
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::{IntoResponse, Redirect}; use axum::response::{IntoResponse, Redirect};
use axum::{ use axum::{extract::Request, handler::HandlerWithoutStateExt, http, http::StatusCode, routing::get, Router};
extract::Request, handler::HandlerWithoutStateExt, http::StatusCode, routing::get, Router,
};
use tokio::signal; use tokio::signal;
use tokio::task::{AbortHandle, JoinHandle}; use tokio::task::{AbortHandle, JoinHandle};
use tower_http::{ use tower_http::{
@ -25,6 +23,7 @@ mod db;
mod error; mod error;
mod session; mod session;
mod auth; mod auth;
mod static_routes;
//NOTE TO FUTURE ME: I'm leaving a bunch of notes about these things as part of the learning //NOTE TO FUTURE ME: I'm leaving a bunch of notes about these things as part of the learning
// process. There is a lot of implementation details that are obscured by all these pieces, and it // process. There is a lot of implementation details that are obscured by all these pieces, and it
// can be hard to tell how heavy a line is. Lots of comment in this file and some of the kind of // can be hard to tell how heavy a line is. Lots of comment in this file and some of the kind of
@ -71,31 +70,22 @@ async fn main() -> Result<()>{
// Session // Session
let (session_layer, session_task) = session::init().await?; let (session_layer, session_task) = session::init().await?;
let auth_routes: Router<AppState> = Router::new() let auth_routes = auth::routes();
.route("/auth/login", get(auth::auth_google)) let static_routes = static_routes::routes();
.route("/auth/logout", get(auth::auth_logout))
.route("/auth/authorized", get(auth::auth_authorized));
let static_routes = Router::new()
.nest_service("/css/pico.min.css", ServeFile::new("static/css/pico.min.css"))
.nest_service("/js/htmx.min.js", ServeFile::new("static/js/htmx.min.js"))
.nest_service("/favicon.ico", ServeFile::new("static/favicon.ico"));
let test_routes: Router<AppState> = Router::new() let error_routes: Router<AppState> = error::routes();
.route("/fail", get(fail))
.route("/usertest", get(index))
.route("/protected", get(protected));
let app_routes: Router<AppState> = Router::new() let app_routes: Router<AppState> = Router::new()
.route("/", get(index)); .route("/", get(index));
let router = Router::new() let router = Router::new()
.merge(auth_routes) .merge(auth_routes)
.merge(test_routes) .merge(error_routes)
.merge(app_routes) .merge(app_routes)
.merge(static_routes) .merge(static_routes)
.layer(session_layer) .layer(session_layer)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.fallback(error::not_found)
.with_state(app_state); .with_state(app_state);
let address = "0.0.0.0:4206"; let address = "0.0.0.0:4206";
@ -115,43 +105,17 @@ async fn main() -> Result<()>{
Ok(()) Ok(())
} }
async fn fail() -> Result<(), AppError> {
let val = always_fails()?;
Ok(val)
}
fn always_fails() -> Result<()> {
Err(anyhow!("I always fail"))
}
#[derive(Template)] // this will generate the code... #[derive(Template)]
#[template(path = "index.html")] // using the template in this path, relative #[template(path = "index.html")]
// to the `templates` dir in the crate root struct IndexTemplate<'a> {
struct IndexTemplate<'a> { // the name of the struct can be anything name: &'a str,
name: &'a str, // the field name should match the variable name
// in your template
} }
async fn index(user: User) -> impl IntoResponse { async fn index(user: User) -> impl IntoResponse {
IndexTemplate { name: user.name.as_str() }.into_response() IndexTemplate { name: user.name.as_str() }.into_response()
//format!("Hello {}", user.email)
} }
async fn protected(
session: Session,
) -> Result<impl IntoResponse, AppError> {
let user: Option<User> = session.get(USER_SESSION).await?;
if let Some(user) = user {
info!("Protected route: Logged in user {}", user.email);
}
else {
info!("Protected route: No user");
}
Ok(Redirect::to("/"))
}
async fn shutdown_signal(tasks: Vec<JoinHandle<Result<()>>>) { async fn shutdown_signal(tasks: Vec<JoinHandle<Result<()>>>) {

@ -0,0 +1,10 @@
use axum::Router;
use tower_http::services::ServeFile;
use crate::app_state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.nest_service("/css/pico.min.css", ServeFile::new("static/css/pico.min.css"))
.nest_service("/js/htmx.min.js", ServeFile::new("static/js/htmx.min.js"))
.nest_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
}

@ -0,0 +1,10 @@
{% extends "problem.html" %}
{% block content %}
<h1>Error</h1>
<p>
Oops, something went wrong. Press the back button to try again.
</p>
{% endblock %}

@ -0,0 +1,11 @@
{% extends "problem.html" %}
{% block content %}
<h1>Forbidden</h1>
<p>
You are forbidden from accessing this resource. Please contact your supervisor or <a href="/auth/logout">logout</a>
and log back in with a different user.
</p>
{% endblock %}

@ -0,0 +1,9 @@
{% extends "main.html" %}
{% block content %}
<h1>Logged out</h1>
<p>You have been logged out</p>
<p><a href="/auth/login">Log In</a></p>
{% endblock %}

@ -4,13 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="css/pico.min.css"> <link rel="stylesheet" href="/css/pico.min.css">
<script src="js/htmx.min.js"></script> <script src="/js/htmx.min.js"></script>
<title>Test Page</title> <title>Test Page</title>
</head> </head>
<body> <body>
<main class="container"> <main class="container">
{% block content %}<p>Placeholder content</p>{% endblock %} {% block content %}<p>Content Missing</p>{% endblock %}
</main> </main>
</body> </body>
</html> </html>

@ -0,0 +1,11 @@
{% extends "problem.html" %}
{% block content %}
<h1>Not Found</h1>
<p>
Sorry, we can't seem to find the page you're looking for. Please press back button or return
<a href="/">home</a>.
</p>
{% endblock %}

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="/css/pico.min.css">
<script src="/js/htmx.min.js"></script>
<title>Problem</title>
</head>
<body>
<main class="container">
{% block content %}<p>Something went wrong</p>{% endblock %}
</main>
</body>
</html>
Loading…
Cancel
Save

Powered by TurnKey Linux.