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 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 match passphrase::verify_passphrase(&body.passphrase, hash) {
128 Ok(true) => { }
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 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
177pub 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
195pub async fn status(State(state): State<Arc<AppState>>, headers: HeaderMap) -> impl IntoResponse {
197 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 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}