Skip to main content

tuitbot_server/auth/
routes.rs

1//! Authentication HTTP endpoints.
2//!
3//! - `POST /api/auth/login` — passphrase login → session cookie
4//! - `POST /api/auth/logout` — clear session
5//! - `GET  /api/auth/status` — check if current session is valid
6
7use std::net::IpAddr;
8use std::sync::Arc;
9use std::time::Instant;
10
11use axum::extract::State;
12use axum::http::{HeaderMap, StatusCode};
13use axum::response::IntoResponse;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use tuitbot_core::auth::{passphrase, session};
17
18use crate::state::AppState;
19
20/// Maximum login attempts per IP before rate limiting.
21const MAX_ATTEMPTS_PER_MINUTE: u32 = 5;
22/// Rate limit window in seconds.
23const RATE_LIMIT_WINDOW_SECS: u64 = 60;
24
25#[derive(Deserialize)]
26pub struct LoginRequest {
27    passphrase: String,
28}
29
30#[derive(Serialize)]
31pub struct LoginResponse {
32    csrf_token: String,
33    expires_at: String,
34}
35
36#[derive(Serialize)]
37pub struct AuthStatusResponse {
38    authenticated: bool,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    csrf_token: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    expires_at: Option<String>,
43}
44
45/// Extract client IP from X-Forwarded-For or fall back to a default.
46fn client_ip(headers: &HeaderMap) -> IpAddr {
47    headers
48        .get("x-forwarded-for")
49        .and_then(|v| v.to_str().ok())
50        .and_then(|v| v.split(',').next())
51        .and_then(|ip| ip.trim().parse().ok())
52        .unwrap_or(IpAddr::from([127, 0, 0, 1]))
53}
54
55/// Extract the session cookie value from headers.
56fn extract_session_cookie(headers: &HeaderMap) -> Option<String> {
57    headers
58        .get("cookie")
59        .and_then(|v| v.to_str().ok())
60        .and_then(|cookies| {
61            cookies.split(';').find_map(|c| {
62                let c = c.trim();
63                c.strip_prefix("tuitbot_session=").map(|v| v.to_string())
64            })
65        })
66}
67
68/// Check rate limit for the given IP. Returns true if allowed.
69async fn check_rate_limit(state: &AppState, ip: IpAddr) -> bool {
70    let attempts = state.login_attempts.lock().await;
71    let now = Instant::now();
72
73    if let Some((count, window_start)) = attempts.get(&ip) {
74        if now.duration_since(*window_start).as_secs() < RATE_LIMIT_WINDOW_SECS
75            && *count >= MAX_ATTEMPTS_PER_MINUTE
76        {
77            return false;
78        }
79    }
80    true
81}
82
83/// Record a login attempt for rate limiting.
84async fn record_attempt(state: &AppState, ip: IpAddr) {
85    let mut attempts = state.login_attempts.lock().await;
86    let now = Instant::now();
87
88    let entry = attempts.entry(ip).or_insert((0, now));
89    if now.duration_since(entry.1).as_secs() >= RATE_LIMIT_WINDOW_SECS {
90        // Reset window
91        *entry = (1, now);
92    } else {
93        entry.0 += 1;
94    }
95}
96
97/// `POST /api/auth/login` — verify passphrase and create a session cookie.
98pub async fn login(
99    State(state): State<Arc<AppState>>,
100    headers: HeaderMap,
101    axum::Json(body): axum::Json<LoginRequest>,
102) -> impl IntoResponse {
103    let ip = client_ip(&headers);
104
105    // Rate limit check
106    if !check_rate_limit(&state, ip).await {
107        return (
108            StatusCode::TOO_MANY_REQUESTS,
109            axum::Json(json!({"error": "too many login attempts, try again later"})),
110        )
111            .into_response();
112    }
113
114    record_attempt(&state, ip).await;
115
116    // Check for out-of-band passphrase resets (e.g., CLI --reset-passphrase).
117    {
118        let disk_mtime = passphrase::passphrase_hash_mtime(&state.data_dir);
119        let cached_mtime = *state.passphrase_hash_mtime.read().await;
120        let needs_reload = match (disk_mtime, cached_mtime) {
121            (Some(disk), Some(cached)) => disk != cached,
122            (Some(_), None) => true,
123            (None, Some(_)) => true,
124            (None, None) => false,
125        };
126        if needs_reload {
127            if let Ok(new_hash) = passphrase::load_passphrase_hash(&state.data_dir) {
128                let mut hash_guard = state.passphrase_hash.write().await;
129                *hash_guard = new_hash;
130                let mut mtime_guard = state.passphrase_hash_mtime.write().await;
131                *mtime_guard = disk_mtime;
132                tracing::info!("passphrase hash reloaded from disk (out-of-band change detected)");
133            }
134        }
135    }
136
137    // Check if passphrase auth is configured
138    let hash = state.passphrase_hash.read().await;
139    let Some(ref hash) = *hash else {
140        return (
141            StatusCode::SERVICE_UNAVAILABLE,
142            axum::Json(json!({"error": "passphrase authentication not configured"})),
143        )
144            .into_response();
145    };
146
147    // Verify passphrase
148    match passphrase::verify_passphrase(&body.passphrase, hash) {
149        Ok(true) => { /* valid */ }
150        Ok(false) => {
151            return (
152                StatusCode::UNAUTHORIZED,
153                axum::Json(json!({"error": "invalid passphrase"})),
154            )
155                .into_response();
156        }
157        Err(e) => {
158            tracing::error!(error = %e, "Passphrase verification failed");
159            return (
160                StatusCode::INTERNAL_SERVER_ERROR,
161                axum::Json(json!({"error": "authentication error"})),
162            )
163                .into_response();
164        }
165    }
166
167    // Create session
168    match session::create_session(&state.db).await {
169        Ok(new_session) => {
170            let cookie = format!(
171                "tuitbot_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
172                new_session.raw_token,
173            );
174
175            let response = LoginResponse {
176                csrf_token: new_session.csrf_token,
177                expires_at: new_session.expires_at,
178            };
179
180            (
181                StatusCode::OK,
182                [(axum::http::header::SET_COOKIE, cookie)],
183                axum::Json(serde_json::to_value(response).unwrap()),
184            )
185                .into_response()
186        }
187        Err(e) => {
188            tracing::error!(error = %e, "Failed to create session");
189            (
190                StatusCode::INTERNAL_SERVER_ERROR,
191                axum::Json(json!({"error": "failed to create session"})),
192            )
193                .into_response()
194        }
195    }
196}
197
198/// `POST /api/auth/logout` — delete the session and clear the cookie.
199pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
200    if let Some(token) = extract_session_cookie(&headers) {
201        if let Err(e) = session::delete_session(&state.db, &token).await {
202            tracing::error!(error = %e, "Failed to delete session");
203        }
204    }
205
206    let clear_cookie = "tuitbot_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string();
207
208    (
209        StatusCode::OK,
210        [(axum::http::header::SET_COOKIE, clear_cookie)],
211        axum::Json(json!({"ok": true})),
212    )
213        .into_response()
214}
215
216/// `GET /api/auth/status` — check if the current request has a valid session.
217pub async fn status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
218    // Check bearer token first
219    let bearer_ok = headers
220        .get("authorization")
221        .and_then(|v| v.to_str().ok())
222        .and_then(|v| v.strip_prefix("Bearer "))
223        .is_some_and(|token| token == state.api_token);
224
225    if bearer_ok {
226        return axum::Json(
227            serde_json::to_value(AuthStatusResponse {
228                authenticated: true,
229                csrf_token: None,
230                expires_at: None,
231            })
232            .unwrap(),
233        )
234        .into_response();
235    }
236
237    // Check session cookie
238    if let Some(token) = extract_session_cookie(&headers) {
239        if let Ok(Some(sess)) = session::validate_session(&state.db, &token).await {
240            return axum::Json(
241                serde_json::to_value(AuthStatusResponse {
242                    authenticated: true,
243                    csrf_token: Some(sess.csrf_token),
244                    expires_at: Some(sess.expires_at),
245                })
246                .unwrap(),
247            )
248            .into_response();
249        }
250    }
251
252    axum::Json(
253        serde_json::to_value(AuthStatusResponse {
254            authenticated: false,
255            csrf_token: None,
256            expires_at: None,
257        })
258        .unwrap(),
259    )
260    .into_response()
261}