1use std::sync::Arc;
8
9use axum::Json;
10use axum::extract::{Path, State};
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use base64::Engine;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use rand::RngCore;
16use rand::rngs::OsRng;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use super::AdminAuth;
21use crate::state::AppState;
22
23#[derive(Deserialize)]
25pub struct GenerateOtpRequest {
26 pub entity_id: String,
31
32 pub ttl_seconds: Option<u64>,
35
36 pub max_usage: Option<u32>,
39}
40
41#[derive(Serialize)]
43pub struct OtpResponse {
44 pub token: String,
49
50 pub entity_id: String,
52
53 pub expires_at: String,
55
56 pub max_usage: u32,
58}
59
60#[derive(Serialize)]
62pub struct OtpSummary {
63 pub id: String,
65
66 pub entity_id: String,
68
69 pub expires_at: String,
71
72 pub max_usage: u32,
74
75 pub usage_count: u32,
77
78 pub created_at: String,
80}
81
82pub async fn generate_otp(
111 _admin: AdminAuth,
112 State(state): State<Arc<AppState>>,
113 Json(req): Json<GenerateOtpRequest>,
114) -> Response {
115 let otp_config = &state.config.otp;
116
117 if !otp_config.enabled {
119 return (
120 StatusCode::BAD_REQUEST,
121 Json(serde_json::json!({
122 "error": "otp_disabled",
123 "detail": "OTP authentication is not enabled in server configuration"
124 })),
125 )
126 .into_response();
127 }
128
129 if req.entity_id.is_empty() {
130 return (
131 StatusCode::BAD_REQUEST,
132 Json(serde_json::json!({
133 "error": "invalid_entity_id",
134 "detail": "entity_id must not be empty"
135 })),
136 )
137 .into_response();
138 }
139
140 let ttl = req.ttl_seconds.unwrap_or(otp_config.ttl_seconds);
141 let max_usage = req.max_usage.unwrap_or(otp_config.max_usage);
142
143 let entropy_bytes = (otp_config.entropy_bits / 8) as usize;
145 let mut raw = vec![0u8; entropy_bytes];
146 OsRng.fill_bytes(&mut raw);
147 let token = URL_SAFE_NO_PAD.encode(&raw);
148
149 let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
151
152 let now = chrono::Utc::now();
153 let expires_at = (now + chrono::Duration::seconds(ttl as i64))
154 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
155
156 let insert_result = sqlx::query(crate::db::pg_sql(
158 "INSERT INTO otp_tokens (token_hash, entity_id, current_uses, max_uses, expires_at) \
159 VALUES (?, ?, 0, ?, ?)",
160 ))
161 .bind(&token_hash)
162 .bind(&req.entity_id)
163 .bind(max_usage as i64)
164 .bind(&expires_at)
165 .execute(&state.db)
166 .await;
167
168 if let Err(e) = insert_result {
169 tracing::error!(error = %e, "failed to store OTP token");
170 return (
171 StatusCode::INTERNAL_SERVER_ERROR,
172 Json(serde_json::json!({
173 "error": "storage_error",
174 "detail": "Failed to store OTP token"
175 })),
176 )
177 .into_response();
178 }
179
180 state
182 .record_audit_event("otp_generated", &format!("entity_id={}", req.entity_id))
183 .await;
184
185 (
186 StatusCode::CREATED,
187 Json(OtpResponse {
188 token,
189 entity_id: req.entity_id,
190 expires_at,
191 max_usage,
192 }),
193 )
194 .into_response()
195}
196
197pub async fn list_otps(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
203 if !state.config.otp.enabled {
204 return (
205 StatusCode::BAD_REQUEST,
206 Json(serde_json::json!({
207 "error": "otp_disabled",
208 "detail": "OTP authentication is not enabled"
209 })),
210 )
211 .into_response();
212 }
213
214 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
215
216 let rows: Vec<OtpRow> = match sqlx::query_as(crate::db::pg_sql(
217 "SELECT id, entity_id, expires_at, max_uses, current_uses, created_at \
218 FROM otp_tokens WHERE revoked = ? AND expires_at > ?",
219 ))
220 .bind(false)
221 .bind(&now)
222 .fetch_all(&state.db_ro)
223 .await
224 {
225 Ok(rows) => rows,
226 Err(e) => {
227 tracing::error!(error = %e, "failed to list OTP tokens");
228 return (
229 StatusCode::INTERNAL_SERVER_ERROR,
230 Json(serde_json::json!({
231 "error": "storage_error",
232 "detail": "Failed to query OTP tokens"
233 })),
234 )
235 .into_response();
236 }
237 };
238
239 let otps: Vec<OtpSummary> = rows
240 .into_iter()
241 .map(|r| OtpSummary {
242 id: r.id.to_string(),
243 entity_id: r.entity_id.unwrap_or_default(),
244 expires_at: r.expires_at,
245 max_usage: r.max_uses as u32,
246 usage_count: r.current_uses as u32,
247 created_at: r.created_at,
248 })
249 .collect();
250
251 (StatusCode::OK, Json(otps)).into_response()
252}
253
254pub async fn revoke_otp(
259 _admin: AdminAuth,
260 Path(id): Path<String>,
261 State(state): State<Arc<AppState>>,
262) -> Response {
263 if !state.config.otp.enabled {
264 return (
265 StatusCode::BAD_REQUEST,
266 Json(serde_json::json!({
267 "error": "otp_disabled",
268 "detail": "OTP authentication is not enabled"
269 })),
270 )
271 .into_response();
272 }
273
274 let otp_id: i64 = match id.parse() {
276 Ok(v) => v,
277 Err(_) => {
278 return (
279 StatusCode::BAD_REQUEST,
280 Json(serde_json::json!({
281 "error": "invalid_id",
282 "detail": "OTP id must be a valid integer"
283 })),
284 )
285 .into_response();
286 }
287 };
288
289 let result = sqlx::query(crate::db::pg_sql(
290 "UPDATE otp_tokens SET revoked = ?, revoked_at = ? WHERE id = ?",
291 ))
292 .bind(true)
293 .bind(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
294 .bind(otp_id)
295 .execute(&state.db)
296 .await;
297
298 match result {
299 Ok(r) if r.rows_affected() == 0 => {
300 return (
301 StatusCode::NOT_FOUND,
302 Json(serde_json::json!({
303 "error": "not_found",
304 "detail": "OTP not found"
305 })),
306 )
307 .into_response();
308 }
309 Err(e) => {
310 tracing::error!(error = %e, "failed to revoke OTP");
311 return (
312 StatusCode::INTERNAL_SERVER_ERROR,
313 Json(serde_json::json!({
314 "error": "storage_error",
315 "detail": "Failed to revoke OTP"
316 })),
317 )
318 .into_response();
319 }
320 _ => {}
321 }
322
323 state
324 .record_audit_event("otp_revoked", &format!("otp_id={id}"))
325 .await;
326
327 tracing::info!(otp_id = %id, "OTP revoked");
328
329 StatusCode::NO_CONTENT.into_response()
330}
331
332#[derive(sqlx::FromRow)]
334struct OtpRow {
335 id: i64,
336 entity_id: Option<String>,
337 expires_at: String,
338 max_uses: i64,
339 current_uses: i64,
340 created_at: String,
341}