feat: add login page

main
Jef Roosens 2025-03-30 10:37:21 +02:00
parent 3071685950
commit 82ccad196c
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
6 changed files with 117 additions and 30 deletions

View File

@ -4,14 +4,19 @@ use axum::{
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
routing::get,
RequestExt, Router,
Form, RequestExt, Router,
};
use axum_extra::extract::CookieJar;
use cookie::Cookie;
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, Context};
use super::{
error::{AppError, AppResult},
Context,
};
const SESSION_ID_COOKIE: &str = "sessionid";
@ -22,23 +27,77 @@ pub fn router(ctx: Context) -> Router<Context> {
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)
}
/// 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 mut jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/_/login");
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())
@ -52,22 +111,33 @@ pub async fn auth_web_middleware(
.await
.unwrap()
{
Ok(session) => {
req.extensions_mut().insert(session.user);
(jar, next.run(req).await).into_response()
}
Err(gpodder::AuthErr::UnknownSession) => {
jar = jar.add(
Cookie::build((SESSION_ID_COOKIE, String::new()))
.max_age(cookie::time::Duration::ZERO),
);
(jar, redirect).into_response()
}
Err(err) => AppError::from(err).into_response(),
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
} else {
redirect.into_response()
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(),
}
}

View File

@ -75,6 +75,10 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
View::Index.template(),
include_str!("templates/views/index.html"),
),
(
View::Login.template(),
include_str!("templates/views/login.html"),
),
])?;
Ok(tera)

View File

@ -16,7 +16,7 @@ a:hover {
<main>
<nav>
</nav>
<article id="content">
<article id="inner">
{{ inner | safe }}
</article>
</main>

View File

@ -1,3 +1,5 @@
<h1>Otter</h1>
Otter is a self-hostable Gpodder implementation.
If you're seeing this, you're logged in.

View File

@ -0,0 +1,9 @@
<article>
<form hx-post="/_/login" hx-target="#inner">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<input type="submit" value="Login">
</form>
</article>

View File

@ -2,12 +2,14 @@ use super::Template;
pub enum View {
Index,
Login,
}
impl Template for View {
fn template(&self) -> &'static str {
match self {
Self::Index => "views/index.html",
Self::Login => "views/login.html",
}
}