otter/src/server/web/mod.rs

144 lines
4.0 KiB
Rust

use axum::{
extract::{Request, State},
http::HeaderMap,
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
routing::get,
Form, RequestExt, Router,
};
use axum_extra::{extract::CookieJar, headers::UserAgent, TypedHeader};
use cookie::{time::Duration, Cookie};
use gpodder::{AuthErr, Session};
use serde::Deserialize;
use crate::web::{Page, TemplateExt, TemplateResponse, View};
use super::{
error::{AppError, AppResult},
Context,
};
const SESSION_ID_COOKIE: &str = "sessionid";
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route("/", get(get_index))
.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))
}
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> {
View::Index.page(&headers).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>>,
_headers: HeaderMap,
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.create_session(&user, user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok((
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)),
),
Redirect::to("/"),
)
.into_response()),
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
todo!("serve login form with error messages")
}
Err(err) => Err(AppError::from(err)),
}
}
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.user);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
}