feat: add login page
parent
3071685950
commit
82ccad196c
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -16,7 +16,7 @@ a:hover {
|
|||
<main>
|
||||
<nav>
|
||||
</nav>
|
||||
<article id="content">
|
||||
<article id="inner">
|
||||
{{ inner | safe }}
|
||||
</article>
|
||||
</main>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
<h1>Otter</h1>
|
||||
|
||||
Otter is a self-hostable Gpodder implementation.
|
||||
|
||||
If you're seeing this, you're logged in.
|
||||
|
|
|
@ -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>
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue