Skip to main content

kipuka/routes/admin/
otp.rs

1//! OTP management endpoints for the admin API.
2//!
3//! RHELBU-3536 R9: Administrators generate OTPs for EST enrollment
4//! via the admin API.  Each OTP is bound to an entity-id and has
5//! configurable expiry and usage limits.
6
7use 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/// Request body for `POST /admin/otp/generate`.
24#[derive(Deserialize)]
25pub struct GenerateOtpRequest {
26    /// The entity identifier for which the OTP is valid.
27    ///
28    /// This is the device or service name that will present the OTP
29    /// in the HTTP Basic `username` field during EST enrollment.
30    pub entity_id: String,
31
32    /// Optional override for OTP expiry (seconds from creation).
33    /// Uses the global `[otp].ttl_seconds` when absent.
34    pub ttl_seconds: Option<u64>,
35
36    /// Optional override for maximum OTP usage count.
37    /// Uses the global `[otp].max_usage` when absent.
38    pub max_usage: Option<u32>,
39}
40
41/// Response for a successfully generated OTP.
42#[derive(Serialize)]
43pub struct OtpResponse {
44    /// The generated OTP token value.
45    ///
46    /// This value is shown exactly once — it is not recoverable after
47    /// this response.
48    pub token: String,
49
50    /// The entity identifier this OTP is bound to.
51    pub entity_id: String,
52
53    /// When the OTP expires (RFC 3339 timestamp).
54    pub expires_at: String,
55
56    /// Maximum number of times this OTP can be used.
57    pub max_usage: u32,
58}
59
60/// OTP summary for listing.
61#[derive(Serialize)]
62pub struct OtpSummary {
63    /// Opaque OTP identifier for management (not the OTP value).
64    pub id: String,
65
66    /// The entity identifier this OTP is bound to.
67    pub entity_id: String,
68
69    /// When the OTP expires (RFC 3339 timestamp).
70    pub expires_at: String,
71
72    /// Maximum allowed uses.
73    pub max_usage: u32,
74
75    /// How many times the OTP has been used so far.
76    pub usage_count: u32,
77
78    /// When the OTP was created (RFC 3339 timestamp).
79    pub created_at: String,
80}
81
82/// `POST /admin/otp/generate` — Generate a new OTP.
83///
84/// RHELBU-3536 R9: Creates a new one-time password bound to the
85/// specified entity identifier.
86///
87/// # Request
88///
89/// ```json
90/// {
91///   "entity_id": "device-001.example.com",
92///   "ttl_seconds": 7200,
93///   "max_usage": 1
94/// }
95/// ```
96///
97/// # Response
98///
99/// ```json
100/// {
101///   "token": "kE9x...",
102///   "entity_id": "device-001.example.com",
103///   "expires_at": "2026-06-22T20:00:00Z",
104///   "max_usage": 1
105/// }
106/// ```
107///
108/// The `token` field contains the actual OTP value.  It is returned
109/// exactly once and cannot be retrieved later.
110pub 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    // Check that OTP is enabled.
118    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    // Generate the OTP token with configured entropy.
144    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    // Hash the token with SHA-256 before storing (RHELBU-3536 R11).
150    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    // Insert the hashed token into the database.
157    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    // Audit log the OTP generation.
181    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
197/// `GET /admin/otp` — List active OTPs.
198///
199/// Returns all non-expired, non-fully-consumed OTPs.  The actual OTP
200/// token values are NOT included (they are one-time secrets shown only
201/// at generation time).
202pub 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
254/// `DELETE /admin/otp/{id}` — Revoke an OTP.
255///
256/// Immediately invalidates the specified OTP, preventing any further
257/// enrollment attempts using it.
258pub 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    // Parse the ID as an integer for the database lookup.
275    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/// Internal row type for reading OTP records from the database.
333#[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}