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" ); } } }
GitRust