5.2 KiB · text History 25c4237
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use sea_orm::*;
use uuid::Uuid;

use crate::entities::{registration_tokens, user_sessions, users};

/// Generate an opaque session token.
pub fn generate_session_token() -> String {
    Uuid::new_v4().to_string()
}

/// Generate an opaque registration (invite) token.
pub fn generate_registration_token() -> String {
    Uuid::new_v4().to_string()
}

/// Create a new session for `user_id`, valid for `session_duration_hours`.
pub async fn create_session(
    db: &DatabaseConnection,
    user_id: Uuid,
    session_duration_hours: i64,
) -> Result<user_sessions::Model> {
    let now = Utc::now();
    let session = user_sessions::ActiveModel {
        id: Set(Uuid::now_v7()),
        user_id: Set(user_id),
        session_token: Set(generate_session_token()),
        expires_at: Set((now + Duration::hours(session_duration_hours)).into()),
        created_at: Set(now.into()),
        last_used_at: Set(now.into()),
    };
    session.insert(db).await.context("failed to create session")
}

/// Validate a session token; on success bumps `last_used_at` and returns the
/// owning user alongside the session.
pub async fn validate_session(
    db: &DatabaseConnection,
    token: &str,
) -> Result<Option<(users::Model, user_sessions::Model)>> {
    let found = user_sessions::Entity::find()
        .filter(user_sessions::Column::SessionToken.eq(token))
        .filter(user_sessions::Column::ExpiresAt.gt(Utc::now()))
        .find_also_related(users::Entity)
        .one(db)
        .await
        .context("failed to query session")?;

    if let Some((session, Some(user))) = found {
        let mut update: user_sessions::ActiveModel = session.clone().into();
        update.last_used_at = Set(Utc::now().into());
        update.update(db).await.context("failed to touch session")?;
        Ok(Some((user, session)))
    } else {
        Ok(None)
    }
}

/// Delete a session (logout). Returns whether a row was removed.
pub async fn delete_session(db: &DatabaseConnection, token: &str) -> Result<bool> {
    let result = user_sessions::Entity::delete_many()
        .filter(user_sessions::Column::SessionToken.eq(token))
        .exec(db)
        .await
        .context("failed to delete session")?;
    Ok(result.rows_affected > 0)
}

/// Purge expired sessions. Handy to call from a periodic task.
pub async fn cleanup_expired_sessions(db: &DatabaseConnection) -> Result<u64> {
    let result = user_sessions::Entity::delete_many()
        .filter(user_sessions::Column::ExpiresAt.lt(Utc::now()))
        .exec(db)
        .await
        .context("failed to clean up sessions")?;
    Ok(result.rows_affected)
}

/// Create an invite token, optionally pinned to an email address.
pub async fn create_registration_token(
    db: &DatabaseConnection,
    created_by_user_id: Option<Uuid>,
    email: Option<String>,
    expires_in_days: i64,
) -> Result<registration_tokens::Model> {
    let now = Utc::now();
    let token = registration_tokens::ActiveModel {
        id: Set(Uuid::now_v7()),
        token: Set(generate_registration_token()),
        email: Set(email),
        created_by_user_id: Set(created_by_user_id),
        expires_at: Set((now + Duration::days(expires_in_days)).into()),
        is_used: Set(false),
        used_by_user_id: Set(None),
        used_at: Set(None),
        created_at: Set(now.into()),
    };
    token.insert(db).await.context("failed to create registration token")
}

/// Validate an invite token for the given email. An email-pinned token only
/// matches when the email matches.
pub async fn validate_registration_token(
    db: &DatabaseConnection,
    token: &str,
    email: &str,
) -> Result<Option<registration_tokens::Model>> {
    let found = registration_tokens::Entity::find()
        .filter(registration_tokens::Column::Token.eq(token))
        .filter(registration_tokens::Column::IsUsed.eq(false))
        .filter(registration_tokens::Column::ExpiresAt.gt(Utc::now()))
        .one(db)
        .await
        .context("failed to query registration token")?;

    match found {
        Some(t) => {
            if let Some(pinned) = &t.email {
                if pinned != email {
                    return Ok(None);
                }
            }
            Ok(Some(t))
        }
        None => Ok(None),
    }
}

/// Mark an invite token as consumed by `used_by_user_id`.
pub async fn mark_registration_token_used(
    db: &DatabaseConnection,
    token_id: Uuid,
    used_by_user_id: Uuid,
) -> Result<()> {
    let mut token: registration_tokens::ActiveModel = registration_tokens::Entity::find_by_id(token_id)
        .one(db)
        .await
        .context("failed to find registration token")?
        .context("registration token not found")?
        .into();
    token.is_used = Set(true);
    token.used_by_user_id = Set(Some(used_by_user_id));
    token.used_at = Set(Some(Utc::now().into()));
    token.update(db).await.context("failed to mark token used")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tokens_are_distinct_uuids() {
        let a = generate_session_token();
        let b = generate_registration_token();
        assert_ne!(a, b);
        assert!(Uuid::parse_str(&a).is_ok());
        assert!(Uuid::parse_str(&b).is_ok());
    }
}