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 if passphrase auth is configured
117    let hash = state.passphrase_hash.read().await;
118    let Some(ref hash) = *hash else {
119        return (
120            StatusCode::SERVICE_UNAVAILABLE,
121            axum::Json(json!({"error": "passphrase authentication not configured"})),
122        )
123            .into_response();
124    };
125
126    // Verify passphrase
127    match passphrase::verify_passphrase(&body.passphrase, hash) {
128        Ok(true) => { /* valid */ }
129        Ok(false) => {
130            return (
131                StatusCode::UNAUTHORIZED,
132                axum::Json(json!({"error": "invalid passphrase"})),
133            )
134                .into_response();
135        }
136        Err(e) => {
137            tracing::error!(error = %e, "Passphrase verification failed");
138            return (
139                StatusCode::INTERNAL_SERVER_ERROR,
140                axum::Json(json!({"error": "authentication error"})),
141            )
142                .into_response();
143        }
144    }
145
146    // Create session
147    match session::create_session(&state.db).await {
148        Ok(new_session) => {
149            let cookie = format!(
150                "tuitbot_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
151                new_session.raw_token,
152            );
153
154            let response = LoginResponse {
155                csrf_token: new_session.csrf_token,
156                expires_at: new_session.expires_at,
157            };
158
159            (
160                StatusCode::OK,
161                [(axum::http::header::SET_COOKIE, cookie)],
162                axum::Json(serde_json::to_value(response).unwrap()),
163            )
164                .into_response()
165        }
166        Err(e) => {
167            tracing::error!(error = %e, "Failed to create session");
168            (
169                StatusCode::INTERNAL_SERVER_ERROR,
170                axum::Json(json!({"error": "failed to create session"})),
171            )
172                .into_response()
173        }
174    }
175}
176
177/// `POST /api/auth/logout` — delete the session and clear the cookie.
178pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
179    if let Some(token) = extract_session_cookie(&headers) {
180        if let Err(e) = session::delete_session(&state.db, &token).await {
181            tracing::error!(error = %e, "Failed to delete session");
182        }
183    }
184
185    let clear_cookie = "tuitbot_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0".to_string();
186
187    (
188        StatusCode::OK,
189        [(axum::http::header::SET_COOKIE, clear_cookie)],
190        axum::Json(json!({"ok": true})),
191    )
192        .into_response()
193}
194
195/// `GET /api/auth/status` — check if the current request has a valid session.
196pub async fn status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
197    // Check bearer token first
198    let bearer_ok = headers
199        .get("authorization")
200        .and_then(|v| v.to_str().ok())
201        .and_then(|v| v.strip_prefix("Bearer "))
202        .is_some_and(|token| token == state.api_token);
203
204    if bearer_ok {
205        return axum::Json(
206            serde_json::to_value(AuthStatusResponse {
207                authenticated: true,
208                csrf_token: None,
209                expires_at: None,
210            })
211            .unwrap(),
212        )
213        .into_response();
214    }
215
216    // Check session cookie
217    if let Some(token) = extract_session_cookie(&headers) {
218        if let Ok(Some(sess)) = session::validate_session(&state.db, &token).await {
219            return axum::Json(
220                serde_json::to_value(AuthStatusResponse {
221                    authenticated: true,
222                    csrf_token: Some(sess.csrf_token),
223                    expires_at: Some(sess.expires_at),
224                })
225                .unwrap(),
226            )
227            .into_response();
228        }
229    }
230
231    axum::Json(
232        serde_json::to_value(AuthStatusResponse {
233            authenticated: false,
234            csrf_token: None,
235            expires_at: None,
236        })
237        .unwrap(),
238    )
239    .into_response()
240}