Login
4 branches 0 tags
Ben (Desktop/Arch) Improved layout 449b238 1 month ago 65 Commits
rubhub / src / pages / auth.rs
use axum::{
    Form,
    extract::State,
    http::StatusCode,
    response::{Html, IntoResponse, Redirect, Response},
};
use serde::Deserialize;
use tower_cookies::Cookies;
use uuid::Uuid;

use crate::{
    app,
    entities::{UserType, user},
    services::{
        session,
        user::{PasswordVerification, hash_password, verify_password_hash},
        validation::{slugify, validate_password, validate_username},
    },
    state::GlobalState,
};
use sea_orm::{ActiveModelTrait, ColumnTrait, Condition, EntityTrait, QueryFilter, Set};

#[derive(Debug, Deserialize)]
pub struct LoginForm {
    pub action: String,
    pub username: String,
    pub email: String,
    pub password: String,
}

pub async fn logout(
    State(state): State<GlobalState>,
    cookies: Cookies,
) -> Result<Redirect, (StatusCode, Html<String>)> {
    Ok(session::logout(&state, cookies).await)
}

pub async fn login_page(cookies: tower_cookies::Cookies) -> Html<String> {
    render_login_page(&cookies, None).await
}

async fn render_login_page(
    _cookies: &tower_cookies::Cookies,
    message: Option<&str>,
) -> Html<String> {
    Html(app::login(message).await)
}

async fn internal_error<E: std::fmt::Display>(
    cookies: &tower_cookies::Cookies,
    err: E,
) -> (axum::http::StatusCode, Html<String>) {
    (
        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
        render_login_page(cookies, Some(&format!("{err}"))).await,
    )
}

pub async fn handle_login(
    State(state): State<GlobalState>,
    cookies: tower_cookies::Cookies,
    Form(form): Form<LoginForm>,
) -> Result<Response, (axum::http::StatusCode, Html<String>)> {
    let username = form.username.trim();
    let email = form.email.trim();
    let password = form.password.trim();

    let action = form.action.to_lowercase();

    match action.as_str() {
        "login" => handle_login_action(&state, cookies, username, password)
            .await
            .map(IntoResponse::into_response),
        "register" => handle_register_action(&state, cookies, username, email, password)
            .await
            .map(IntoResponse::into_response),
        _ => Err((
            StatusCode::BAD_REQUEST,
            render_login_page(&cookies, Some("Unsupported action.")).await,
        )),
    }
}

async fn handle_login_action(
    state: &GlobalState,
    cookies: tower_cookies::Cookies,
    username: &str,
    password: &str,
) -> Result<Redirect, (axum::http::StatusCode, Html<String>)> {
    let user = match user::Entity::find()
        .filter(user::Column::Slug.eq(username))
        .one(&state.db)
        .await
    {
        Ok(Some(u)) => u,
        Ok(None) => {
            return Err((
                StatusCode::UNAUTHORIZED,
                render_login_page(&cookies, Some("Invalid username or password.")).await,
            ));
        }
        Err(err) => return Err(internal_error(&cookies, err).await),
    };

    let user_id = user.id;
    let stored_hash = user.password_hash.as_deref().unwrap_or("");
    let verification = verify_password_hash(password, stored_hash);
    match verification {
        PasswordVerification::Valid => {}
        PasswordVerification::ValidNeedsRehash { new_hash } => {
            let now = chrono::Utc::now().fixed_offset();
            let mut user_active: user::ActiveModel = user.clone().into();
            user_active.password_hash = Set(Some(new_hash));
            user_active.last_login = Set(Some(now));
            let _ = user_active.update(&state.db).await;
            if let Err(err) = session::create_session(state, &cookies, user_id, username).await {
                return Err(internal_error(&cookies, err).await);
            }
            return Ok(Redirect::to("/"));
        }
        PasswordVerification::Invalid | PasswordVerification::Error => {
            return Err((
                StatusCode::UNAUTHORIZED,
                render_login_page(&cookies, Some("Invalid username or password.")).await,
            ));
        }
    }

    let now = chrono::Utc::now().fixed_offset();
    let mut user_active: user::ActiveModel = user.into();
    user_active.last_login = Set(Some(now));
    let _ = user_active.update(&state.db).await;

    if let Err(err) = session::create_session(state, &cookies, user_id, username).await {
        return Err(internal_error(&cookies, err).await);
    }

    Ok(Redirect::to("/"))
}

async fn handle_register_action(
    state: &GlobalState,
    cookies: tower_cookies::Cookies,
    username: &str,
    email: &str,
    password: &str,
) -> Result<Redirect, (axum::http::StatusCode, Html<String>)> {
    if let Err(msg) = validate_username(username) {
        return Err((
            StatusCode::BAD_REQUEST,
            render_login_page(&cookies, Some(msg)).await,
        ));
    }

    if let Err(msg) = validate_password(password) {
        return Err((
            StatusCode::BAD_REQUEST,
            render_login_page(&cookies, Some(msg)).await,
        ));
    }

    let slug = slugify(username);

    let existing = match user::Entity::find()
        .filter(
            Condition::any()
                .add(user::Column::Slug.eq(slug.clone()))
                .add(user::Column::Email.eq(email)),
        )
        .one(&state.db)
        .await
    {
        Ok(result) => result,
        Err(err) => return Err(internal_error(&cookies, err).await),
    };

    if existing.is_some() {
        return Err((
            StatusCode::CONFLICT,
            render_login_page(&cookies, Some("That username is already taken.")).await,
        ));
    }

    let password_hash = match hash_password(password) {
        Ok(hash) => hash,
        Err(err) => return Err(internal_error(&cookies, err).await),
    };

    let new_user = user::ActiveModel {
        id: Set(Uuid::new_v4()),
        user_type: Set(UserType::Normal),
        name: Set(username.to_owned()),
        slug: Set(slug.to_owned()),
        email: Set(email.to_owned()),
        password_hash: Set(Some(password_hash)),
        ..Default::default()
    };

    let inserted = match new_user.insert(&state.db).await {
        Ok(user) => user,
        Err(err) => return Err(internal_error(&cookies, err).await),
    };
    if let Err(err) = session::create_session(state, &cookies, inserted.id, username).await {
        return Err(internal_error(&cookies, err).await);
    }

    Ok(Redirect::to("/"))
}