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>
186 lines
5.2 KiB
Rust
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);
|
|
}
|
|
}
|