1use std::sync::Arc;
24
25use axum::http::header::{AUTHORIZATION, RETRY_AFTER, WWW_AUTHENTICATE};
26use axum::http::request::Parts;
27use axum::http::{HeaderValue, StatusCode};
28use axum::response::{IntoResponse, Response};
29use base64::Engine as _;
30use sha2::{Digest, Sha256};
31use subtle::ConstantTimeEq;
32use tracing::{debug, warn};
33
34use super::failure_tracker::LockoutStatus;
35use super::{AuthMethod, AuthResult};
36use crate::state::AppState;
37
38pub async fn try_extract_otp(
45 parts: &Parts,
46 app: &Arc<AppState>,
47) -> Option<Result<AuthResult, Response>> {
48 let auth_header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
49
50 let credentials_b64 = auth_header.strip_prefix("Basic ")?;
52
53 let decoded = match base64::engine::general_purpose::STANDARD.decode(credentials_b64) {
54 Ok(d) => d,
55 Err(_) => {
56 return Some(Err(unauthorized_response("malformed Basic auth encoding")));
57 }
58 };
59
60 if decoded.contains(&0x00) {
62 return Some(Err(unauthorized_response(
63 "Basic auth credentials contain null byte (rejected for security)",
64 )));
65 }
66
67 let credentials = match String::from_utf8(decoded) {
69 Ok(s) => s,
70 Err(_) => {
71 return Some(Err(unauthorized_response(
72 "Basic auth credentials are not valid UTF-8 (RFC 7617 §2.1)",
73 )));
74 }
75 };
76
77 let (entity_id, otp_value) = match credentials.split_once(':') {
79 Some((u, p)) => (u.to_string(), p.to_string()),
80 None => {
81 return Some(Err(unauthorized_response(
82 "malformed Basic auth credentials (missing ':' separator, RFC 7617 §2)",
83 )));
84 }
85 };
86
87 if entity_id.is_empty() {
89 return Some(Err(unauthorized_response(
90 "entity-id must not be empty (RFC 7617 §2)",
91 )));
92 }
93
94 if otp_value.is_empty() {
95 return Some(Err(unauthorized_response("OTP value must not be empty")));
96 }
97
98 debug!(entity_id = %entity_id, "validating OTP for entity");
99
100 if let LockoutStatus::LockedOut { retry_after } = app.failure_tracker.check(&entity_id) {
106 warn!(entity_id = %entity_id, "OTP attempt refused: identity is locked out");
107 return Some(Err(locked_out_response(retry_after)));
108 }
109
110 match validate_otp(app, &entity_id, &otp_value).await {
112 Ok(()) => {
113 app.failure_tracker.record_success(&entity_id);
115 Some(Ok(AuthResult {
116 identity: entity_id,
117 method: AuthMethod::Otp,
118 client_cert_der: None,
119 subject_dn: None,
120 subject_alt_names: Vec::new(),
121 extended_key_usage: Vec::new(),
122 }))
123 }
124 Err(e) => {
125 warn!(entity_id = %entity_id, error = %e, "OTP validation failed");
126
127 app.record_audit_event_with_actor(
129 "otp_auth_failure",
130 &entity_id,
131 &format!("entity_id={entity_id}, reason={e}"),
132 )
133 .await;
134
135 let track = has_active_otp(app, &entity_id).await.unwrap_or(false);
143 let status = if track {
144 app.failure_tracker.record_failure(&entity_id)
145 } else {
146 LockoutStatus::Allowed
147 };
148 match status {
149 LockoutStatus::LockedOut { retry_after } => {
150 warn!(entity_id = %entity_id, "OTP failure threshold reached: identity locked out");
151 app.record_audit_event_with_actor(
152 "auth_lockout",
153 &entity_id,
154 &format!(
155 "entity_id={entity_id}, lockout_secs={}",
156 retry_after.as_secs()
157 ),
158 )
159 .await;
160 Some(Err(locked_out_response(retry_after)))
161 }
162 LockoutStatus::Allowed => {
163 Some(Err(unauthorized_response("OTP authentication failed")))
164 }
165 }
166 }
167 }
168}
169
170async fn has_active_otp(app: &Arc<AppState>, entity_id: &str) -> Result<bool, sqlx::Error> {
173 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
174 sqlx::query_scalar::<_, i64>(crate::db::pg_sql(
175 "SELECT id FROM otp_tokens WHERE entity_id = ? AND revoked = ? \
176 AND expires_at > ? AND current_uses < max_uses LIMIT 1",
177 ))
178 .bind(entity_id)
179 .bind(false)
180 .bind(now)
181 .fetch_optional(&app.db_ro)
182 .await
183 .map(|row| row.is_some())
184}
185
186fn locked_out_response(retry_after: std::time::Duration) -> Response {
190 let secs = retry_after.as_secs() + u64::from(retry_after.subsec_nanos() > 0);
192 let mut resp = (
193 StatusCode::TOO_MANY_REQUESTS,
194 "too many failed authentication attempts; account temporarily locked",
195 )
196 .into_response();
197 if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
198 resp.headers_mut().insert(RETRY_AFTER, value);
199 }
200 resp
201}
202
203fn unauthorized_response(detail: &str) -> Response {
209 let mut resp = (StatusCode::UNAUTHORIZED, detail.to_string()).into_response();
210 resp.headers_mut().insert(
211 WWW_AUTHENTICATE,
212 HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
213 );
214 resp
215}
216
217#[derive(sqlx::FromRow)]
219struct OtpValidationRow {
220 id: i64,
221 token_hash: String,
222 current_uses: i64,
223 max_uses: i64,
224}
225
226async fn validate_otp(app: &Arc<AppState>, entity_id: &str, otp_value: &str) -> Result<(), String> {
241 let otp_config = &app.config.otp;
243 if !otp_config.enabled {
244 return Err("OTP authentication is not enabled".into());
245 }
246
247 let incoming_hash = hex::encode(Sha256::digest(otp_value.as_bytes()));
249
250 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
251
252 let row: OtpValidationRow = sqlx::query_as(crate::db::pg_sql(
256 "SELECT id, token_hash, current_uses, max_uses FROM otp_tokens \
257 WHERE entity_id = ? AND token_hash = ? AND revoked = ? AND expires_at > ? AND current_uses < max_uses",
258 ))
259 .bind(entity_id)
260 .bind(&incoming_hash)
261 .bind(false)
262 .bind(&now)
263 .fetch_optional(&app.db_ro)
264 .await
265 .map_err(|e| format!("database error: {e}"))?
266 .ok_or_else(|| "no valid OTP found for this entity".to_string())?;
267
268 if incoming_hash
273 .as_bytes()
274 .ct_eq(row.token_hash.as_bytes())
275 .unwrap_u8()
276 == 0
277 {
278 return Err("no valid OTP found for this entity".to_string());
279 }
280
281 let result = sqlx::query(crate::db::pg_sql(
284 "UPDATE otp_tokens SET current_uses = current_uses + 1 \
285 WHERE entity_id = ? AND token_hash = ? AND revoked = ? AND expires_at > ? AND current_uses < max_uses",
286 ))
287 .bind(entity_id)
288 .bind(&incoming_hash)
289 .bind(false)
290 .bind(&now)
291 .execute(&app.db)
292 .await
293 .map_err(|e| format!("database error: {e}"))?;
294
295 if result.rows_affected() == 0 {
296 return Err("OTP was consumed by a concurrent request".to_string());
297 }
298
299 debug!(
300 entity_id = %entity_id,
301 otp_id = row.id,
302 current_uses = row.current_uses + 1,
303 max_uses = row.max_uses,
304 "OTP validated and consumed"
305 );
306
307 Ok(())
308}