5.6 KiB · text History 7a00c02
use anyhow::{Context, Result};
use chrono::Utc;
use sea_orm::*;
use uuid::Uuid;

use crate::auth::{password, tokens};
use crate::entities::{user_sessions, users};

#[derive(Debug)]
pub struct CreateUserRequest {
    pub username: String,
    pub email: String,
    pub password: String,
    pub registration_token: String,
}

#[derive(Debug)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

/// Create a user after validating and consuming their invite token.
pub async fn create_user(db: &DatabaseConnection, request: CreateUserRequest) -> Result<users::Model> {
    let reg_token = tokens::validate_registration_token(db, &request.registration_token, &request.email)
        .await
        .context("failed to validate registration token")?
        .ok_or_else(|| anyhow::anyhow!("invalid or expired registration token"))?;

    let existing = users::Entity::find()
        .filter(
            Condition::any()
                .add(users::Column::Username.eq(&request.username))
                .add(users::Column::Email.eq(&request.email)),
        )
        .one(db)
        .await
        .context("failed to check existing users")?;
    if existing.is_some() {
        return Err(anyhow::anyhow!("username or email already exists"));
    }

    let password_hash = password::hash_password(&request.password)?;
    let now = Utc::now();
    let user = users::ActiveModel {
        id: Set(Uuid::now_v7()),
        username: Set(request.username),
        email: Set(request.email),
        password_hash: Set(password_hash),
        is_active: Set(true),
        created_at: Set(now.into()),
        updated_at: Set(now.into()),
        last_login_at: Set(None),
    }
    .insert(db)
    .await
    .context("failed to create user")?;

    tokens::mark_registration_token_used(db, reg_token.id, user.id)
        .await
        .context("failed to mark registration token used")?;

    Ok(user)
}

/// Authenticate by username + password. Returns `Ok(None)` on bad credentials.
pub async fn authenticate_user(
    db: &DatabaseConnection,
    request: LoginRequest,
) -> Result<Option<users::Model>> {
    let user = users::Entity::find()
        .filter(users::Column::Username.eq(&request.username))
        .filter(users::Column::IsActive.eq(true))
        .one(db)
        .await
        .context("failed to query user")?;

    let Some(user) = user else { return Ok(None) };
    if !password::verify_password(&request.password, &user.password_hash)? {
        return Ok(None);
    }

    let mut update: users::ActiveModel = user.into();
    update.last_login_at = Set(Some(Utc::now().into()));
    let user = update.update(db).await.context("failed to update last login")?;
    Ok(Some(user))
}

pub async fn get_user_by_id(db: &DatabaseConnection, user_id: Uuid) -> Result<Option<users::Model>> {
    users::Entity::find_by_id(user_id).one(db).await.context("failed to query user")
}

/// Update username and/or email, enforcing uniqueness.
pub async fn update_user(
    db: &DatabaseConnection,
    user_id: Uuid,
    username: Option<String>,
    email: Option<String>,
) -> Result<users::Model> {
    let user = get_user_by_id(db, user_id)
        .await?
        .ok_or_else(|| anyhow::anyhow!("user not found"))?;
    let mut update: users::ActiveModel = user.into();
    update.updated_at = Set(Utc::now().into());

    if let Some(username) = username {
        let taken = users::Entity::find()
            .filter(users::Column::Username.eq(&username))
            .filter(users::Column::Id.ne(user_id))
            .one(db)
            .await
            .context("failed to check username")?;
        if taken.is_some() {
            return Err(anyhow::anyhow!("username already taken"));
        }
        update.username = Set(username);
    }
    if let Some(email) = email {
        let taken = users::Entity::find()
            .filter(users::Column::Email.eq(&email))
            .filter(users::Column::Id.ne(user_id))
            .one(db)
            .await
            .context("failed to check email")?;
        if taken.is_some() {
            return Err(anyhow::anyhow!("email already taken"));
        }
        update.email = Set(email);
    }

    update.update(db).await.context("failed to update user")
}

/// Change a user's password after verifying the current one.
pub async fn change_password(
    db: &DatabaseConnection,
    user_id: Uuid,
    old_password: &str,
    new_password: &str,
) -> Result<()> {
    let user = get_user_by_id(db, user_id)
        .await?
        .ok_or_else(|| anyhow::anyhow!("user not found"))?;
    if !password::verify_password(old_password, &user.password_hash)? {
        return Err(anyhow::anyhow!("invalid old password"));
    }
    let new_hash = password::hash_password(new_password)?;
    let mut update: users::ActiveModel = user.into();
    update.password_hash = Set(new_hash);
    update.updated_at = Set(Utc::now().into());
    update.update(db).await.context("failed to update password")?;
    Ok(())
}

/// Deactivate an account and drop all of its sessions.
pub async fn deactivate_user(db: &DatabaseConnection, user_id: Uuid) -> Result<()> {
    let user = get_user_by_id(db, user_id)
        .await?
        .ok_or_else(|| anyhow::anyhow!("user not found"))?;
    let mut update: users::ActiveModel = user.into();
    update.is_active = Set(false);
    update.updated_at = Set(Utc::now().into());
    update.update(db).await.context("failed to deactivate user")?;

    user_sessions::Entity::delete_many()
        .filter(user_sessions::Column::UserId.eq(user_id))
        .exec(db)
        .await
        .context("failed to delete sessions")?;
    Ok(())
}