tuitbot_server/auth/
routes.rs1use 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
20const MAX_ATTEMPTS_PER_MINUTE: u32 = 5;
22const 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
45fn 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
55fn 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
68async 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
83async 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 *entry = (1, now);
92 } else {
93 entry.0 += 1;
94 }
95}
96
97pub 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 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 {
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 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 match passphrase::verify_passphrase(&body.passphrase, hash) {
149 Ok(true) => { }
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 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
198pub 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
216pub async fn status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
218 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 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}