dragoon-server 0.1.0

Public-relay server for the dragoon remote-executor: axum + rusqlite + ed25519 task signing + per-user message inbox.
Documentation
//! `/v1/auth/{challenge, login, logout}` routes.

use axum::{
    extract::State,
    http::{header, HeaderMap, StatusCode},
    middleware,
    response::IntoResponse,
    routing::post,
    Extension, Json, Router,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chrono::Duration;
use serde_json::json;

use dragoon_proto::models::{AuthChallenge, AuthLoginRequest, AuthLoginResponse};
use dragoon_proto::verify::verify_ssh_wire_signature;

use crate::{
    app::{signed_request, AppState, SignedSession},
    audit, auth as authmod, users_repo,
};

/// Apply `signed_request` middleware to the protected sub-tree, then
/// merge with the public sub-tree. Used by `app::create_app`.
pub fn router(state: AppState) -> Router {
    let public = Router::new()
        .route("/v1/auth/challenge", post(challenge))
        .route("/v1/auth/login", post(login))
        .with_state(state.clone());

    let signed = Router::new()
        .route("/v1/auth/logout", post(logout))
        .layer(middleware::from_fn_with_state(state.clone(), signed_request))
        .with_state(state);

    public.merge(signed)
}

// --------------------------------------------------------------------------
// Handlers
// --------------------------------------------------------------------------

async fn challenge(
    State(state): State<AppState>,
) -> Result<Json<AuthChallenge>, StatusCode> {
    let conn = state.conn.lock().unwrap();
    let issued = authmod::issue_challenge(&conn, None)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(AuthChallenge {
        challenge: issued.challenge,
        expires_at: issued.expires_at,
    }))
}

async fn login(
    State(state): State<AppState>,
    Json(req): Json<AuthLoginRequest>,
) -> Result<Json<AuthLoginResponse>, StatusCode> {
    let conn = state.conn.lock().unwrap();

    let fail = |reason: &str| -> StatusCode {
        let _ = audit::log(
            &conn,
            Some(&format!("user:{}", req.username)),
            "login",
            Some("/v1/auth/login"),
            None,
            &json!({"ok": false, "reason": reason}),
        );
        StatusCode::UNAUTHORIZED
    };

    let user = users_repo::get_user(&conn, &req.username)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or_else(|| fail("unknown_user"))?;
    if user.revoked_at.is_some() {
        return Err(fail("unknown_user"));
    }

    if !authmod::verify_password(&req.password, &user.password_hash) {
        return Err(fail("bad_password"));
    }
    if !authmod::verify_totp(&user.totp_secret, &req.totp_code) {
        return Err(fail("bad_totp"));
    }
    let consumed = authmod::consume_challenge(&conn, &req.challenge)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    if !consumed {
        return Err(fail("bad_challenge"));
    }

    let row: Option<(String, Vec<u8>, Option<String>)> = conn
        .query_row(
            "SELECT alg, pubkey_blob, revoked_at FROM user_pubkeys
             WHERE user_id=? AND fingerprint=?",
            rusqlite::params![user.id, req.key_fingerprint],
            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
        )
        .ok();
    let Some((_alg, pub_blob, revoked_at)) = row else {
        return Err(fail("unknown_fp"));
    };
    if revoked_at.is_some() {
        return Err(fail("revoked_fp"));
    }

    let sig_wire = B64
        .decode(req.challenge_signature_b64.as_bytes())
        .map_err(|_| fail("bad_sig"))?;
    verify_ssh_wire_signature(&pub_blob, &sig_wire, req.challenge.as_bytes())
        .map_err(|_| fail("bad_sig"))?;

    let (token, expires) = authmod::issue_session(
        &conn,
        user.id,
        &req.key_fingerprint,
        Duration::hours(state.settings.session_ttl_hours),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let _ = audit::log(
        &conn,
        Some(&format!("user:{}", req.username)),
        "login",
        Some("/v1/auth/login"),
        Some(&req.key_fingerprint),
        &json!({"ok": true}),
    );

    Ok(Json(AuthLoginResponse {
        session_token: token,
        expires_at: expires,
    }))
}

async fn logout(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(sess): Extension<SignedSession>,
) -> impl IntoResponse {
    let conn = state.conn.lock().unwrap();
    let auth_hdr = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .unwrap_or("");
    let _ = authmod::revoke_session(&conn, auth_hdr);
    let username = crate::app::username_for(&conn, sess.0.user_id);
    let _ = audit::log(
        &conn,
        Some(&username),
        "logout",
        Some("/v1/auth/logout"),
        Some(&sess.0.fingerprint),
        &json!({}),
    );
    Json(json!({"ok": true}))
}