🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / auth.rs

use crate::db::Db;
use crate::util::{handle_from_email, norm_email, now, random_token, sha256_hex};
use axum::http::HeaderMap;

pub const SESSION_COOKIE: &str = "okibi_session";
const SESSION_TTL: i64 = 60 * 60 * 24 * 30; // 30 days
const MAGIC_TTL: i64 = 60 * 15; // 15 min

#[derive(Clone, Debug, sqlx::FromRow)]
pub struct User {
    pub id: i64,
    pub handle: String,
    pub email: String,
    pub display_name: String,
    pub is_admin: i64,
}

/// Is this email on the atsm allowlist? Returns (display_name, is_admin).
pub async fn member(db: &Db, email: &str) -> Option<(String, bool)> {
    let email = norm_email(email);
    sqlx::query_as::<_, (String, i64)>(
        "SELECT display_name, is_admin FROM members WHERE email = ?",
    )
    .bind(&email)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
    .map(|(n, a)| (n, a != 0))
}

/// Add (or keep) an email on the allowlist. Used by admin invites.
pub async fn add_member(db: &Db, email: &str, is_admin: bool) -> anyhow::Result<()> {
    sqlx::query(
        "INSERT INTO members (email, display_name, is_admin, added_at)
         VALUES (?, '', ?, ?)
         ON CONFLICT(email) DO UPDATE SET is_admin = MAX(is_admin, excluded.is_admin)",
    )
    .bind(norm_email(email))
    .bind(is_admin as i64)
    .bind(now())
    .execute(db)
    .await?;
    Ok(())
}

/// All allowlisted emails (for the admin members page).
pub async fn list_members(db: &Db) -> Vec<(String, bool)> {
    sqlx::query_as::<_, (String, i64)>(
        "SELECT email, is_admin FROM members ORDER BY added_at",
    )
    .fetch_all(db)
    .await
    .unwrap_or_default()
    .into_iter()
    .map(|(e, a)| (e, a != 0))
    .collect()
}

/// Create a single-use magic link token for an allowlisted email.
pub async fn create_magic_link(db: &Db, email: &str) -> anyhow::Result<String> {
    let token = random_token();
    sqlx::query(
        "INSERT INTO magic_links (token_hash, email, expires_at, used) VALUES (?, ?, ?, 0)",
    )
    .bind(sha256_hex(&token))
    .bind(norm_email(email))
    .bind(now() + MAGIC_TTL)
    .execute(db)
    .await?;
    Ok(token)
}

/// Consume a magic link, returning the email if valid & unused & unexpired.
pub async fn consume_magic_link(db: &Db, token: &str) -> Option<String> {
    let h = sha256_hex(token);
    let row = sqlx::query_as::<_, (String, i64, i64)>(
        "SELECT email, expires_at, used FROM magic_links WHERE token_hash = ?",
    )
    .bind(&h)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()?;
    let (email, expires_at, used) = row;
    if used != 0 || expires_at < now() {
        return None;
    }
    sqlx::query("UPDATE magic_links SET used = 1 WHERE token_hash = ?")
        .bind(&h)
        .execute(db)
        .await
        .ok()?;
    Some(email)
}

/// Find-or-create the user for an allowlisted email.
pub async fn upsert_user(db: &Db, email: &str) -> anyhow::Result<User> {
    let email = norm_email(email);
    if let Some(u) = user_by_email(db, &email).await {
        return Ok(u);
    }
    let (display_name, is_admin) = member(db, &email)
        .await
        .unwrap_or_else(|| (String::new(), false));
    // Pick a unique handle.
    let base = handle_from_email(&email);
    let mut handle = base.clone();
    let mut n = 1;
    while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE handle = ?")
        .bind(&handle)
        .fetch_one(db)
        .await?
        > 0
    {
        n += 1;
        handle = format!("{base}{n}");
    }
    sqlx::query(
        "INSERT INTO users (handle, email, display_name, is_admin, created_at)
         VALUES (?, ?, ?, ?, ?)",
    )
    .bind(&handle)
    .bind(&email)
    .bind(&display_name)
    .bind(is_admin as i64)
    .bind(now())
    .execute(db)
    .await?;
    user_by_email(db, &email)
        .await
        .ok_or_else(|| anyhow::anyhow!("user vanished after insert"))
}

pub async fn user_by_email(db: &Db, email: &str) -> Option<User> {
    sqlx::query_as::<_, User>(
        "SELECT id, handle, email, display_name, is_admin FROM users WHERE email = ?",
    )
    .bind(norm_email(email))
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
}

pub async fn user_by_handle(db: &Db, handle: &str) -> Option<User> {
    sqlx::query_as::<_, User>(
        "SELECT id, handle, email, display_name, is_admin FROM users WHERE handle = ?",
    )
    .bind(handle)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
}

/// Create a web session, return the raw cookie value.
pub async fn create_session(db: &Db, user_id: i64) -> anyhow::Result<String> {
    let token = random_token();
    sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)")
        .bind(sha256_hex(&token))
        .bind(user_id)
        .bind(now() + SESSION_TTL)
        .execute(db)
        .await?;
    Ok(token)
}

pub async fn destroy_session(db: &Db, token: &str) {
    let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
        .bind(sha256_hex(token))
        .execute(db)
        .await;
}

/// Resolve the logged-in user from request cookies.
pub async fn current_user(db: &Db, headers: &HeaderMap) -> Option<User> {
    let token = cookie(headers, SESSION_COOKIE)?;
    let row = sqlx::query_as::<_, (i64, i64)>(
        "SELECT user_id, expires_at FROM sessions WHERE token_hash = ?",
    )
    .bind(sha256_hex(&token))
    .fetch_optional(db)
    .await
    .ok()
    .flatten()?;
    if row.1 < now() {
        return None;
    }
    sqlx::query_as::<_, User>(
        "SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
    )
    .bind(row.0)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
}

/// Create a personal access token (for git over HTTPS). Returns raw token.
pub async fn create_pat(db: &Db, user_id: i64, name: &str) -> anyhow::Result<String> {
    let token = format!("okibi_{}", random_token());
    sqlx::query(
        "INSERT INTO pats (user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?)",
    )
    .bind(user_id)
    .bind(name)
    .bind(sha256_hex(&token))
    .bind(now())
    .execute(db)
    .await?;
    Ok(token)
}

/// Resolve a user from a bare PAT (used by the MCP Bearer auth).
pub async fn user_by_pat(db: &Db, token: &str) -> Option<User> {
    let h = sha256_hex(token);
    let uid = sqlx::query_scalar::<_, i64>("SELECT user_id FROM pats WHERE token_hash = ?")
        .bind(&h)
        .fetch_optional(db)
        .await
        .ok()
        .flatten()?;
    let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
        .bind(now())
        .bind(&h)
        .execute(db)
        .await;
    sqlx::query_as::<_, User>(
        "SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
    )
    .bind(uid)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
}

/// Extract a Bearer token from the Authorization header.
pub fn bearer(headers: &HeaderMap) -> Option<String> {
    let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
    raw.strip_prefix("Bearer ").map(|s| s.trim().to_string())
}

/// Verify HTTP basic auth (handle + PAT) for git operations.
pub async fn verify_pat(db: &Db, handle: &str, token: &str) -> Option<User> {
    let user = user_by_handle(db, handle).await?;
    let h = sha256_hex(token);
    let ok = sqlx::query_scalar::<_, i64>(
        "SELECT COUNT(*) FROM pats WHERE user_id = ? AND token_hash = ?",
    )
    .bind(user.id)
    .bind(&h)
    .fetch_one(db)
    .await
    .ok()?
        > 0;
    if ok {
        let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
            .bind(now())
            .bind(&h)
            .execute(db)
            .await;
        Some(user)
    } else {
        None
    }
}

/// Extract a cookie value from request headers.
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
    let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
    for part in raw.split(';') {
        let part = part.trim();
        if let Some(rest) = part.strip_prefix(&format!("{name}=")) {
            return Some(rest.to_string());
        }
    }
    None
}

/// Parse HTTP Basic auth header into (user, pass).
pub fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
    let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
    let b64 = raw.strip_prefix("Basic ")?;
    let decoded = base64_decode(b64)?;
    let s = String::from_utf8(decoded).ok()?;
    let (u, p) = s.split_once(':')?;
    Some((u.to_string(), p.to_string()))
}

/// Minimal base64 decoder (avoids pulling a crate just for basic auth).
fn base64_decode(input: &str) -> Option<Vec<u8>> {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut lut = [255u8; 256];
    for (i, &c) in TABLE.iter().enumerate() {
        lut[c as usize] = i as u8;
    }
    let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
    let mut out = Vec::with_capacity(clean.len() * 3 / 4);
    let mut buf = 0u32;
    let mut bits = 0u32;
    for &c in &clean {
        let v = lut[c as usize];
        if v == 255 {
            return None;
        }
        buf = (buf << 6) | v as u32;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push((buf >> bits) as u8);
        }
    }
    Some(out)
}