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::TryRng;
16use rand::rngs::SysRng;
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 if let Some(resp) = admin.require_operator() {
117 return resp;
118 }
119
120 let otp_config = &state.config.otp;
121
122 if !otp_config.enabled {
124 return (
125 StatusCode::BAD_REQUEST,
126 Json(serde_json::json!({
127 "error": "otp_disabled",
128 "detail": "OTP authentication is not enabled in server configuration"
129 })),
130 )
131 .into_response();
132 }
133
134 if req.entity_id.is_empty() {
135 return (
136 StatusCode::BAD_REQUEST,
137 Json(serde_json::json!({
138 "error": "invalid_entity_id",
139 "detail": "entity_id must not be empty"
140 })),
141 )
142 .into_response();
143 }
144
145 let ttl = req.ttl_seconds.unwrap_or(otp_config.ttl_seconds);
146 let max_usage = req.max_usage.unwrap_or(otp_config.max_usage);
147
148 let entropy_bytes = (otp_config.entropy_bits / 8) as usize;
150 let mut raw = vec![0u8; entropy_bytes];
151 if let Err(e) = SysRng.try_fill_bytes(&mut raw) {
155 tracing::error!(error = %e, "OS RNG failed while generating OTP token");
156 return (
157 StatusCode::INTERNAL_SERVER_ERROR,
158 Json(serde_json::json!({
159 "error": "rng_failure",
160 "detail": "failed to obtain secure random bytes for OTP generation"
161 })),
162 )
163 .into_response();
164 }
165 let token = URL_SAFE_NO_PAD.encode(&raw);
166
167 let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
169
170 let now = chrono::Utc::now();
171 let expires_at = (now + chrono::Duration::seconds(ttl as i64))
172 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
173
174 let insert_result = sqlx::query(crate::db::pg_sql(
176 "INSERT INTO otp_tokens (token_hash, entity_id, current_uses, max_uses, expires_at) \
177 VALUES (?, ?, 0, ?, ?)",
178 ))
179 .bind(&token_hash)
180 .bind(&req.entity_id)
181 .bind(max_usage as i64)
182 .bind(&expires_at)
183 .execute(&state.db)
184 .await;
185
186 if let Err(e) = insert_result {
187 tracing::error!(error = %e, "failed to store OTP token");
188 return (
189 StatusCode::INTERNAL_SERVER_ERROR,
190 Json(serde_json::json!({
191 "error": "storage_error",
192 "detail": "Failed to store OTP token"
193 })),
194 )
195 .into_response();
196 }
197
198 state
200 .record_audit_event("otp_generated", &format!("entity_id={}", req.entity_id))
201 .await;
202
203 (
204 StatusCode::CREATED,
205 Json(OtpResponse {
206 token,
207 entity_id: req.entity_id,
208 expires_at,
209 max_usage,
210 }),
211 )
212 .into_response()
213}
214
215pub async fn list_otps(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
221 if !state.config.otp.enabled {
222 return (
223 StatusCode::BAD_REQUEST,
224 Json(serde_json::json!({
225 "error": "otp_disabled",
226 "detail": "OTP authentication is not enabled"
227 })),
228 )
229 .into_response();
230 }
231
232 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
233
234 let rows: Vec<OtpRow> = match sqlx::query_as(crate::db::pg_sql(
235 "SELECT id, entity_id, expires_at, max_uses, current_uses, created_at \
236 FROM otp_tokens WHERE revoked = ? AND expires_at > ?",
237 ))
238 .bind(false)
239 .bind(&now)
240 .fetch_all(&state.db_ro)
241 .await
242 {
243 Ok(rows) => rows,
244 Err(e) => {
245 tracing::error!(error = %e, "failed to list OTP tokens");
246 return (
247 StatusCode::INTERNAL_SERVER_ERROR,
248 Json(serde_json::json!({
249 "error": "storage_error",
250 "detail": "Failed to query OTP tokens"
251 })),
252 )
253 .into_response();
254 }
255 };
256
257 let otps: Vec<OtpSummary> = rows
258 .into_iter()
259 .map(|r| OtpSummary {
260 id: r.id.to_string(),
261 entity_id: r.entity_id.unwrap_or_default(),
262 expires_at: r.expires_at,
263 max_usage: r.max_uses as u32,
264 usage_count: r.current_uses as u32,
265 created_at: r.created_at,
266 })
267 .collect();
268
269 (StatusCode::OK, Json(otps)).into_response()
270}
271
272pub async fn revoke_otp(
277 admin: AdminAuth,
278 Path(id): Path<String>,
279 State(state): State<Arc<AppState>>,
280) -> Response {
281 if let Some(resp) = admin.require_operator() {
283 return resp;
284 }
285
286 if !state.config.otp.enabled {
287 return (
288 StatusCode::BAD_REQUEST,
289 Json(serde_json::json!({
290 "error": "otp_disabled",
291 "detail": "OTP authentication is not enabled"
292 })),
293 )
294 .into_response();
295 }
296
297 let otp_id: i64 = match id.parse() {
299 Ok(v) => v,
300 Err(_) => {
301 return (
302 StatusCode::BAD_REQUEST,
303 Json(serde_json::json!({
304 "error": "invalid_id",
305 "detail": "OTP id must be a valid integer"
306 })),
307 )
308 .into_response();
309 }
310 };
311
312 let result = sqlx::query(crate::db::pg_sql(
313 "UPDATE otp_tokens SET revoked = ?, revoked_at = ? WHERE id = ?",
314 ))
315 .bind(true)
316 .bind(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
317 .bind(otp_id)
318 .execute(&state.db)
319 .await;
320
321 match result {
322 Ok(r) if r.rows_affected() == 0 => {
323 return (
324 StatusCode::NOT_FOUND,
325 Json(serde_json::json!({
326 "error": "not_found",
327 "detail": "OTP not found"
328 })),
329 )
330 .into_response();
331 }
332 Err(e) => {
333 tracing::error!(error = %e, "failed to revoke OTP");
334 return (
335 StatusCode::INTERNAL_SERVER_ERROR,
336 Json(serde_json::json!({
337 "error": "storage_error",
338 "detail": "Failed to revoke OTP"
339 })),
340 )
341 .into_response();
342 }
343 _ => {}
344 }
345
346 state
347 .record_audit_event("otp_revoked", &format!("otp_id={id}"))
348 .await;
349
350 tracing::info!(otp_id = %id, "OTP revoked");
351
352 StatusCode::NO_CONTENT.into_response()
353}
354
355#[derive(sqlx::FromRow)]
357struct OtpRow {
358 id: i64,
359 entity_id: Option<String>,
360 expires_at: String,
361 max_uses: i64,
362 current_uses: i64,
363 created_at: String,
364}