refactor: split web auth routes
parent
5cd1f4f736
commit
97b30b1840
|
@ -0,0 +1,149 @@
|
||||||
|
use axum::{
|
||||||
|
Form, RequestExt, Router,
|
||||||
|
extract::{Request, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
|
||||||
|
use cookie::{Cookie, time::Duration};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use gpodder::{AuthErr, Session};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
server::{
|
||||||
|
Context,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
},
|
||||||
|
web::{TemplateExt, View},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn router(ctx: Context) -> Router<Context> {
|
||||||
|
Router::new()
|
||||||
|
// .layer(middleware::from_fn_with_state(
|
||||||
|
// ctx.clone(),
|
||||||
|
// auth_web_middleware,
|
||||||
|
// ))
|
||||||
|
// Login route needs to be handled differently, as the middleware turns it into a redirect
|
||||||
|
// loop
|
||||||
|
.route("/login", get(get_login).post(post_login))
|
||||||
|
.route("/logout", post(post_logout))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
|
||||||
|
if extract_session(ctx.clone(), &jar)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
} else {
|
||||||
|
View::Login
|
||||||
|
.page(&headers)
|
||||||
|
.response(&ctx.tera)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginForm {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_login(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
user_agent: Option<TypedHeader<UserAgent>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(login): Form<LoginForm>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
match tokio::task::spawn_blocking(move || {
|
||||||
|
let user = ctx
|
||||||
|
.store
|
||||||
|
.validate_credentials(&login.username, &login.password)?;
|
||||||
|
|
||||||
|
let user_agent = user_agent.map(|header| header.to_string());
|
||||||
|
let session = ctx.store.user(&user).create_session(user_agent)?;
|
||||||
|
|
||||||
|
Ok::<_, AuthErr>(session)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Ok(session) => Ok((
|
||||||
|
// Redirect forces htmx to reload the full page, refreshing the navbar
|
||||||
|
[("HX-Redirect", "/")],
|
||||||
|
(jar.add(
|
||||||
|
Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string()))
|
||||||
|
.secure(true)
|
||||||
|
.same_site(cookie::SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/")
|
||||||
|
.max_age(Duration::days(365)),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
|
||||||
|
todo!("serve login form with error messages")
|
||||||
|
}
|
||||||
|
Err(err) => Err(AppError::from(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log out the user by simply removing the session
|
||||||
|
async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
|
||||||
|
if let Some(session) = extract_session(ctx.clone(), &jar).await? {
|
||||||
|
ctx.store.remove_session(session.id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect forces htmx to reload the full page, refreshing the navbar
|
||||||
|
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
|
||||||
|
if let Some(session_id) = jar
|
||||||
|
.get(super::SESSION_ID_COOKIE)
|
||||||
|
.and_then(|c| c.value().parse::<i64>().ok())
|
||||||
|
{
|
||||||
|
match tokio::task::spawn_blocking(move || {
|
||||||
|
let session = ctx.store.get_session(session_id)?;
|
||||||
|
ctx.store.refresh_session(&session)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Ok(session) => Ok(Some(session)),
|
||||||
|
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
|
||||||
|
Err(err) => Err(AppError::from(err)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware that authenticates the current user via the session token. If the credentials are
|
||||||
|
/// invalid, the user is redirected to the login page.
|
||||||
|
pub async fn auth_web_middleware(
|
||||||
|
State(ctx): State<Context>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// SAFETY: this extractor's error type is Infallible
|
||||||
|
let jar: CookieJar = req.extract_parts().await.unwrap();
|
||||||
|
let redirect = Redirect::to("/login");
|
||||||
|
|
||||||
|
match extract_session(ctx, &jar).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
req.extensions_mut().insert(session);
|
||||||
|
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
Ok(None) => redirect.into_response(),
|
||||||
|
Err(err) => err.into_response(),
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,25 +1,14 @@
|
||||||
|
mod auth;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
use axum::{
|
use axum::{Router, extract::State, http::HeaderMap, routing::get};
|
||||||
Form, RequestExt, Router,
|
use axum_extra::extract::CookieJar;
|
||||||
extract::{Request, State},
|
|
||||||
http::HeaderMap,
|
|
||||||
middleware::Next,
|
|
||||||
response::{IntoResponse, Redirect, Response},
|
|
||||||
routing::{get, post},
|
|
||||||
};
|
|
||||||
use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
|
|
||||||
use cookie::{Cookie, time::Duration};
|
|
||||||
use gpodder::{AuthErr, Session};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View};
|
use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View};
|
||||||
|
|
||||||
use super::{
|
use super::{Context, error::AppResult};
|
||||||
Context,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SESSION_ID_COOKIE: &str = "sessionid";
|
const SESSION_ID_COOKIE: &str = "sessionid";
|
||||||
|
|
||||||
|
@ -74,8 +63,7 @@ pub fn router(ctx: Context) -> Router<Context> {
|
||||||
// ))
|
// ))
|
||||||
// Login route needs to be handled differently, as the middleware turns it into a redirect
|
// Login route needs to be handled differently, as the middleware turns it into a redirect
|
||||||
// loop
|
// loop
|
||||||
.route("/login", get(get_login).post(post_login))
|
.merge(auth::router(ctx.clone()))
|
||||||
.route("/logout", post(post_logout))
|
|
||||||
.merge(sessions::router(ctx.clone()))
|
.merge(sessions::router(ctx.clone()))
|
||||||
.merge(users::router(ctx.clone()))
|
.merge(users::router(ctx.clone()))
|
||||||
}
|
}
|
||||||
|
@ -85,126 +73,10 @@ async fn get_index(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
) -> AppResult<TemplateResponse<Page<View>>> {
|
) -> AppResult<TemplateResponse<Page<View>>> {
|
||||||
let authenticated = extract_session(ctx.clone(), &jar).await?.is_some();
|
let authenticated = auth::extract_session(ctx.clone(), &jar).await?.is_some();
|
||||||
|
|
||||||
Ok(View::Index
|
Ok(View::Index
|
||||||
.page(&headers)
|
.page(&headers)
|
||||||
.authenticated(authenticated)
|
.authenticated(authenticated)
|
||||||
.response(&ctx.tera))
|
.response(&ctx.tera))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
|
|
||||||
if extract_session(ctx.clone(), &jar)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
Redirect::to("/").into_response()
|
|
||||||
} else {
|
|
||||||
View::Login
|
|
||||||
.page(&headers)
|
|
||||||
.response(&ctx.tera)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct LoginForm {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_login(
|
|
||||||
State(ctx): State<Context>,
|
|
||||||
user_agent: Option<TypedHeader<UserAgent>>,
|
|
||||||
jar: CookieJar,
|
|
||||||
Form(login): Form<LoginForm>,
|
|
||||||
) -> AppResult<Response> {
|
|
||||||
match tokio::task::spawn_blocking(move || {
|
|
||||||
let user = ctx
|
|
||||||
.store
|
|
||||||
.validate_credentials(&login.username, &login.password)?;
|
|
||||||
|
|
||||||
let user_agent = user_agent.map(|header| header.to_string());
|
|
||||||
let session = ctx.store.user(&user).create_session(user_agent)?;
|
|
||||||
|
|
||||||
Ok::<_, AuthErr>(session)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
{
|
|
||||||
Ok(session) => Ok((
|
|
||||||
// Redirect forces htmx to reload the full page, refreshing the navbar
|
|
||||||
[("HX-Redirect", "/")],
|
|
||||||
(jar.add(
|
|
||||||
Cookie::build((SESSION_ID_COOKIE, session.id.to_string()))
|
|
||||||
.secure(true)
|
|
||||||
.same_site(cookie::SameSite::Lax)
|
|
||||||
.http_only(true)
|
|
||||||
.path("/")
|
|
||||||
.max_age(Duration::days(365)),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.into_response()),
|
|
||||||
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
|
|
||||||
todo!("serve login form with error messages")
|
|
||||||
}
|
|
||||||
Err(err) => Err(AppError::from(err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log out the user by simply removing the session
|
|
||||||
async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
|
|
||||||
if let Some(session) = extract_session(ctx.clone(), &jar).await? {
|
|
||||||
ctx.store.remove_session(session.id)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect forces htmx to reload the full page, refreshing the navbar
|
|
||||||
Ok(([("HX-Redirect", "/")], jar.remove(SESSION_ID_COOKIE)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
|
|
||||||
if let Some(session_id) = jar
|
|
||||||
.get(SESSION_ID_COOKIE)
|
|
||||||
.and_then(|c| c.value().parse::<i64>().ok())
|
|
||||||
{
|
|
||||||
match tokio::task::spawn_blocking(move || {
|
|
||||||
let session = ctx.store.get_session(session_id)?;
|
|
||||||
ctx.store.refresh_session(&session)?;
|
|
||||||
|
|
||||||
Ok(session)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
{
|
|
||||||
Ok(session) => Ok(Some(session)),
|
|
||||||
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
|
|
||||||
Err(err) => Err(AppError::from(err)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Middleware that authenticates the current user via the session token. If the credentials are
|
|
||||||
/// invalid, the user is redirected to the login page.
|
|
||||||
pub async fn auth_web_middleware(
|
|
||||||
State(ctx): State<Context>,
|
|
||||||
mut req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
// SAFETY: this extractor's error type is Infallible
|
|
||||||
let jar: CookieJar = req.extract_parts().await.unwrap();
|
|
||||||
let redirect = Redirect::to("/login");
|
|
||||||
|
|
||||||
match extract_session(ctx, &jar).await {
|
|
||||||
Ok(Some(session)) => {
|
|
||||||
req.extensions_mut().insert(session);
|
|
||||||
|
|
||||||
next.run(req).await
|
|
||||||
}
|
|
||||||
Ok(None) => redirect.into_response(),
|
|
||||||
Err(err) => err.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router<Context> {
|
||||||
.route("/sessions/{id}", delete(delete_session))
|
.route("/sessions/{id}", delete(delete_session))
|
||||||
.route_layer(axum::middleware::from_fn_with_state(
|
.route_layer(axum::middleware::from_fn_with_state(
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
super::auth_web_middleware,
|
super::auth::auth_web_middleware,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ pub fn router(ctx: Context) -> Router<Context> {
|
||||||
.route("/users", get(get_users))
|
.route("/users", get(get_users))
|
||||||
.route_layer(axum::middleware::from_fn_with_state(
|
.route_layer(axum::middleware::from_fn_with_state(
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
super::auth_web_middleware,
|
super::auth::auth_web_middleware,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue