routes.rs 309 lignes · 11043 octets
//! Ready-to-mount router for the built-in JSON authentication API.

use axum::{
    Router,
    routing::{get, post},
};
use sea_orm::DatabaseConnection;

use crate::auth::{email_change_handlers, handlers};

/// Build the JSON authentication API router (`/api/v1/auth/*`).
///
/// Mounts the framework's built-in auth handlers: login, register, refresh,
/// logout, current-user, password reset, email verification, and 2FA
/// verification. These endpoints authenticate by `Authorization: Bearer` (or
/// are pre-session, for login/register) and are **not** CSRF-vulnerable, so
/// mount this on the machine channel of the application builder
/// ([`crate::app::RustwardenBuilder::merge_machine_routes`]) — never behind
/// the CSRF layer.
///
/// Headless (UI-less) applications get a complete, working authentication API
/// simply by wiring this router; `rustwarden-ui` mounts the same set inside
/// its own `api_routes()`.
///
/// # Example
///
/// ```rust,ignore
/// RustwardenApp::builder()
///     .from_env()
///     .headless()
///     .merge_machine_routes(rustwarden_core::auth::auth_api_routes())
///     .build()
///     .await?;
/// ```
pub fn auth_api_routes() -> Router<DatabaseConnection> {
    let limited = Router::new()
        .route("/api/v1/auth/login", post(handlers::login))
        .route("/api/v1/auth/register", post(handlers::register))
        .route("/api/v1/auth/refresh", post(handlers::refresh_token))
        .route(
            "/api/v1/auth/forgot-password",
            post(handlers::forgot_password),
        )
        .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
        .route(
            "/api/v1/auth/resend-verification",
            post(handlers::resend_verification),
        )
        .route(
            "/api/v1/auth/resend-verification/by-username",
            post(handlers::resend_verification_by_username),
        )
        .route(
            "/api/v1/auth/email-change",
            post(email_change_handlers::request_email_change),
        )
        .layer(axum::middleware::from_fn(
            crate::middleware::rate_limit::auth_ip_rate_limit,
        ));

    let unlimited = Router::new()
        .route("/api/v1/auth/logout", post(handlers::logout))
        .route("/api/v1/auth/me", get(handlers::get_current_user))
        .route(
            "/api/v1/auth/reset-password",
            post(handlers::reset_password),
        )
        .route(
            "/api/v1/auth/verify-email/{token}",
            get(handlers::verify_email),
        )
        .route(
            "/api/v1/auth/email-change/confirm/{token}",
            get(email_change_handlers::confirm_email_change_page)
                .post(email_change_handlers::confirm_email_change),
        )
        .route(
            "/api/v1/auth/email-change/report/{token}",
            get(email_change_handlers::report_email_change_page)
                .post(email_change_handlers::report_email_change),
        );

    limited.merge(unlimited)
}

#[cfg(test)]
mod tests {
    use super::auth_api_routes;
    use axum::{
        Json, Router,
        body::Body,
        extract::connect_info::MockConnectInfo,
        http::{Request, StatusCode},
        routing::get,
    };
    use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
    use std::net::SocketAddr;
    use tower::ServiceExt;

    /// Mirrors the protected handler the CLI scaffolds into headless
    /// projects. Kept in sync so the generated code's interface (the
    /// `AuthUser` extractor composing into a `Router<DatabaseConnection>`
    /// with a `Json<serde_json::Value>` response) is verified at compile
    /// time against the real framework surface.
    async fn whoami(user: crate::auth::extractors::AuthUser) -> Json<serde_json::Value> {
        Json(serde_json::json!({
            "user_id": user.user_id,
            "username": user.username,
            "roles": user.roles,
        }))
    }

    #[test]
    fn auth_api_routes_compose_with_scaffolded_protected_handler() {
        // Type-checks the exact wiring the headless scaffold emits:
        //   .merge_machine_routes(auth_api_routes())
        //   .merge_routes(app_routes())  // app_routes mounts `whoami`
        let _router: Router<DatabaseConnection> =
            auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami)));
    }

    /// TEST-NET-3 address so this budget never shares `auth:unknown` with
    /// other tests that call `check_auth_rate_limit` without ConnectInfo.
    fn testnet_peer() -> SocketAddr {
        SocketAddr::from(([203, 0, 113, 1], 12345))
    }

    fn login_request() -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/api/v1/auth/login")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"username":"u","password":"p"}"#))
            .expect("login request")
    }

    fn logout_request() -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/api/v1/auth/logout")
            .body(Body::empty())
            .expect("logout request")
    }

    fn verify_2fa_request() -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/api/v1/auth/2fa/verify")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"challenge_token":"dummy","code":"123456"}"#))
            .expect("2fa verify request")
    }

    fn auth_app_with_peer(peer: SocketAddr) -> Router {
        let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        auth_api_routes()
            .with_state(db)
            .layer(MockConnectInfo(peer))
    }

    #[tokio::test]
    async fn auth_limiter_separates_two_connect_ips() {
        let app_ip1 = auth_app_with_peer(testnet_peer());
        let app_ip2 = auth_app_with_peer(SocketAddr::from(([198, 51, 100, 1], 12345)));

        for i in 1..=10 {
            let resp = app_ip1
                .clone()
                .oneshot(login_request())
                .await
                .expect("oneshot");
            assert_ne!(
                resp.status(),
                StatusCode::TOO_MANY_REQUESTS,
                "request {i}/10 from 203.0.113.1 must not be 429"
            );
        }

        let eleventh = app_ip1
            .clone()
            .oneshot(login_request())
            .await
            .expect("oneshot");
        assert_eq!(
            eleventh.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "11th request from 203.0.113.1 must be 429"
        );
        let retry_after = eleventh.headers().get("retry-after");
        assert!(
            retry_after.is_some_and(|v| !v.as_bytes().is_empty()),
            "429 must carry a non-empty Retry-After header"
        );

        let logout = app_ip1.oneshot(logout_request()).await.expect("oneshot");
        assert_ne!(
            logout.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "unlimited /logout must not be 429 when the limited login budget is exhausted"
        );

        let other = app_ip2.oneshot(login_request()).await.expect("oneshot");
        assert_ne!(
            other.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "first request from 198.51.100.1 must not share 203.0.113.1's exhausted budget"
        );
    }

    /// Distinct TEST-NET-1 IP so this budget never collides with
    /// `auth_limiter_separates_two_connect_ips` (203.0.113.1 / 198.51.100.1)
    /// on the process-wide OnceLock limiter.
    #[tokio::test]
    async fn auth_limiter_applies_to_2fa_verify() {
        let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 80], 12345)));

        for i in 1..=10 {
            let resp = app
                .clone()
                .oneshot(verify_2fa_request())
                .await
                .expect("oneshot");
            assert_ne!(
                resp.status(),
                StatusCode::TOO_MANY_REQUESTS,
                "request {i}/10 to /2fa/verify from 192.0.2.80 must not be 429"
            );
        }

        let eleventh = app.oneshot(verify_2fa_request()).await.expect("oneshot");
        assert_eq!(
            eleventh.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "11th request to /2fa/verify from 192.0.2.80 must be 429"
        );
    }
    fn email_change_request() -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/api/v1/auth/email-change")
            .header("content-type", "application/json")
            .body(Body::from(
                r#"{"new_email":"new@example.test","current_password":"p"}"#,
            ))
            .expect("email-change request")
    }

    fn confirm_link_request() -> Request<Body> {
        Request::builder()
            .method("GET")
            .uri("/api/v1/auth/email-change/confirm/11111111-1111-1111-1111-111111111111")
            .body(Body::empty())
            .expect("confirm link request")
    }

    /// RFC 0041 — opening a change is the expensive, mail-sending operation,
    /// so it sits in the IP-limited group next to login.
    #[tokio::test]
    async fn auth_limiter_applies_to_the_email_change_request() {
        let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 81], 12345)));

        for i in 1..=10 {
            let resp = app
                .clone()
                .oneshot(email_change_request())
                .await
                .expect("oneshot");
            assert_ne!(
                resp.status(),
                StatusCode::TOO_MANY_REQUESTS,
                "request {i}/10 to /email-change from 192.0.2.81 must not be 429"
            );
        }

        let eleventh = app.oneshot(email_change_request()).await.expect("oneshot");
        assert_eq!(
            eleventh.status(),
            StatusCode::TOO_MANY_REQUESTS,
            "11th request to /email-change from 192.0.2.81 must be 429"
        );
    }

    /// RFC 0041 — the emailed links stay out of the IP budget on purpose. A
    /// user clicking from their mailbox must not be turned away because a
    /// neighbour behind the same NAT exhausted the login budget; the tokens
    /// are single-use and unguessable, which is what bounds abuse here.
    #[tokio::test]
    async fn the_email_change_links_are_not_ip_limited() {
        let app = auth_app_with_peer(SocketAddr::from(([203, 0, 113, 2], 12345)));

        // Exhaust the IP budget on a limited route first.
        for _ in 1..=11 {
            let _ = app.clone().oneshot(login_request()).await.expect("oneshot");
        }

        for i in 1..=12 {
            let resp = app
                .clone()
                .oneshot(confirm_link_request())
                .await
                .expect("oneshot");
            assert_ne!(
                resp.status(),
                StatusCode::TOO_MANY_REQUESTS,
                "click {i} on the confirmation link must never be 429"
            );
        }
    }
}