Files
foxhunt/services/api/src/auth/mfa/qr_code.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

186 lines
5.2 KiB
Rust

//! QR Code Generator for TOTP Enrollment
//!
//! Generates QR codes for TOTP secret enrollment in authenticator apps.
use anyhow::Result;
use qrcode::{render::svg, QrCode};
use thiserror::Error;
/// QR code generation errors
#[derive(Debug, Error)]
pub enum QrCodeError {
#[error("QR code generation failed: {0}")]
GenerationFailed(String),
#[error("Invalid URI format: {0}")]
InvalidUri(String),
#[error("Rendering failed: {0}")]
RenderingFailed(String),
}
/// QR code generator for TOTP enrollment
pub struct QrCodeGenerator {
/// Image size in pixels
image_size: u32,
/// Error correction level
error_correction: qrcode::EcLevel,
}
impl QrCodeGenerator {
/// Create new QR code generator with default settings
pub fn new() -> Self {
Self {
image_size: 256,
error_correction: qrcode::EcLevel::M, // Medium error correction
}
}
/// Create QR code generator with custom size
pub fn with_size(size: u32) -> Self {
Self {
image_size: size,
error_correction: qrcode::EcLevel::M,
}
}
/// Generate PNG image from otpauth:// URI
pub fn generate_png(&self, uri: &str) -> Result<Vec<u8>> {
// Validate URI format
if !uri.starts_with("otpauth://") {
return Err(
QrCodeError::InvalidUri("URI must start with otpauth://".to_string()).into(),
);
}
// Create QR code
let code = QrCode::with_error_correction_level(uri, self.error_correction)
.map_err(|e| QrCodeError::GenerationFailed(e.to_string()))?;
// Render to PNG
let image = code
.render::<image::Luma<u8>>()
.min_dimensions(self.image_size, self.image_size)
.build();
// Convert to PNG bytes
let mut png_bytes = Vec::new();
image
.write_to(
&mut std::io::Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.map_err(|e| QrCodeError::RenderingFailed(e.to_string()))?;
Ok(png_bytes)
}
/// Generate SVG image from otpauth:// URI
pub fn generate_svg(&self, uri: &str) -> Result<String> {
// Validate URI format
if !uri.starts_with("otpauth://") {
return Err(
QrCodeError::InvalidUri("URI must start with otpauth://".to_string()).into(),
);
}
// Create QR code
let code = QrCode::with_error_correction_level(uri, self.error_correction)
.map_err(|e| QrCodeError::GenerationFailed(e.to_string()))?;
// Render to SVG
let svg = code
.render()
.min_dimensions(self.image_size, self.image_size)
.dark_color(svg::Color("#000000"))
.light_color(svg::Color("#ffffff"))
.build();
Ok(svg)
}
/// Generate data URL for inline display (base64 encoded PNG)
pub fn generate_data_url(&self, uri: &str) -> Result<String> {
let png_bytes = self.generate_png(uri)?;
let base64_encoded =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png_bytes);
Ok(format!("data:image/png;base64,{}", base64_encoded))
}
/// Set image size
pub fn set_size(&mut self, size: u32) {
self.image_size = size;
}
/// Set error correction level
pub fn set_error_correction(&mut self, level: qrcode::EcLevel) {
self.error_correction = level;
}
}
impl Default for QrCodeGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_png() {
let generator = QrCodeGenerator::new();
let uri =
"otpauth://totp/FoxhuntHFT:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=FoxhuntHFT";
let png_bytes = generator.generate_png(uri).unwrap();
// PNG should have magic bytes
assert_eq!(&png_bytes[0..8], b"\x89PNG\r\n\x1a\n");
// Should be a valid size
assert!(png_bytes.len() > 100);
}
#[test]
fn test_generate_svg() {
let generator = QrCodeGenerator::new();
let uri =
"otpauth://totp/FoxhuntHFT:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=FoxhuntHFT";
let svg = generator.generate_svg(uri).unwrap();
// SVG should contain proper XML
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_generate_data_url() {
let generator = QrCodeGenerator::new();
let uri =
"otpauth://totp/FoxhuntHFT:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=FoxhuntHFT";
let data_url = generator.generate_data_url(uri).unwrap();
assert!(data_url.starts_with("data:image/png;base64,"));
}
#[test]
fn test_invalid_uri() {
let generator = QrCodeGenerator::new();
let invalid_uri = "https://example.com/invalid";
assert!(generator.generate_png(invalid_uri).is_err());
assert!(generator.generate_svg(invalid_uri).is_err());
}
#[test]
fn test_custom_size() {
let generator = QrCodeGenerator::with_size(512);
assert_eq!(generator.image_size, 512);
}
}