- Add tower and tower-cookies dependencies for session management - Create login page template with username/password form - Implement /login and /logout routes with cookie-based sessions - Update navigation bar to show Login/Logout based on auth state - Add is_authenticated field to all page templates - Hide create/update/delete UI controls when unauthenticated - Session tokens stored in secure HttpOnly cookies with SameSite=Lax - Password verification uses constant-time comparison via Argon2 Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
99 lines
2.9 KiB
Rust
99 lines
2.9 KiB
Rust
use askama::Template;
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Redirect, Response};
|
|
use axum::Form;
|
|
use serde::Deserialize;
|
|
use tower_cookies::{Cookie, Cookies};
|
|
use tracing::warn;
|
|
use crate::infrastructure::auth::{generate_session_token, verify_password};
|
|
use crate::server::routes::render_html;
|
|
use crate::server::server::AppState;
|
|
|
|
const SESSION_COOKIE_NAME: &str = "brewlog_session";
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "login.html")]
|
|
struct LoginTemplate {
|
|
nav_active: &'static str,
|
|
is_authenticated: bool,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginForm {
|
|
username: String,
|
|
password: String,
|
|
}
|
|
|
|
pub(crate) async fn login_page(cookies: Cookies) -> Result<Response, StatusCode> {
|
|
// Check if already authenticated
|
|
if cookies.get(SESSION_COOKIE_NAME).is_some() {
|
|
return Ok(Redirect::to("/timeline").into_response());
|
|
}
|
|
|
|
let template = LoginTemplate {
|
|
nav_active: "login",
|
|
is_authenticated: false,
|
|
error: None,
|
|
};
|
|
|
|
render_html(template).map(IntoResponse::into_response)
|
|
}
|
|
|
|
pub(crate) async fn login_submit(
|
|
State(state): State<AppState>,
|
|
cookies: Cookies,
|
|
Form(form): Form<LoginForm>,
|
|
) -> Result<Response, StatusCode> {
|
|
// Validate credentials
|
|
let user = match state.user_repo.get_by_username(&form.username).await {
|
|
Ok(user) => user,
|
|
Err(err) => {
|
|
warn!(username = %form.username, error = %err, "login attempt with non-existent username or error");
|
|
return show_login_error("Invalid username or password");
|
|
}
|
|
};
|
|
|
|
// Verify password
|
|
if !verify_password(&form.password, &user.password_hash)
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
{
|
|
warn!(username = %form.username, "login attempt with incorrect password");
|
|
return show_login_error("Invalid username or password");
|
|
}
|
|
|
|
// Create session token
|
|
let session_token = generate_session_token();
|
|
|
|
// Set secure cookie
|
|
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token);
|
|
cookie.set_path("/");
|
|
cookie.set_http_only(true);
|
|
cookie.set_same_site(tower_cookies::cookie::SameSite::Lax);
|
|
// In production, set secure flag: cookie.set_secure(true);
|
|
|
|
cookies.add(cookie);
|
|
|
|
Ok(Redirect::to("/timeline").into_response())
|
|
}
|
|
|
|
pub(crate) async fn logout(cookies: Cookies) -> Redirect {
|
|
cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
|
|
Redirect::to("/timeline")
|
|
}
|
|
|
|
fn show_login_error(message: &str) -> Result<Response, StatusCode> {
|
|
let template = LoginTemplate {
|
|
nav_active: "login",
|
|
is_authenticated: false,
|
|
error: Some(message.to_string()),
|
|
};
|
|
|
|
render_html(template).map(IntoResponse::into_response)
|
|
}
|
|
|
|
/// Check if user is authenticated based on session cookie
|
|
pub fn is_authenticated(cookies: &Cookies) -> bool {
|
|
cookies.get(SESSION_COOKIE_NAME).is_some()
|
|
}
|