add auth system: users, login, JWT, protected routes

Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports.
Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based),
config-sqlite user repository, http-api auth routes + extractors.
Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
2026-06-19 01:39:42 +02:00
parent 4139330234
commit adda731dc6
41 changed files with 1331 additions and 153 deletions

View File

@@ -0,0 +1,56 @@
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, aead::Aead};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use domain::SecretStore;
use rand_core::{OsRng, RngCore};
pub struct AesSecretStore {
key: Key<Aes256Gcm>,
}
impl AesSecretStore {
pub fn from_env() -> Result<Self, String> {
let hex_key = std::env::var("KFRAME_ENCRYPTION_KEY")
.map_err(|_| "KFRAME_ENCRYPTION_KEY env var is required".to_string())?;
let bytes = hex::decode(&hex_key)
.map_err(|e| format!("KFRAME_ENCRYPTION_KEY must be 64 hex chars: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"KFRAME_ENCRYPTION_KEY must be 32 bytes (64 hex chars), got {}",
bytes.len()
));
}
let key = Key::<Aes256Gcm>::from_slice(&bytes);
Ok(Self { key: *key })
}
}
impl SecretStore for AesSecretStore {
fn encrypt(&self, plaintext: &str) -> String {
let cipher = Aes256Gcm::new(&self.key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.expect("AES-GCM encryption should not fail");
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
B64.encode(combined)
}
fn decrypt(&self, ciphertext: &str) -> String {
let combined = B64
.decode(ciphertext)
.expect("invalid base64 in encrypted field");
if combined.len() < 12 {
panic!("encrypted data too short");
}
let (nonce_bytes, ct) = combined.split_at(12);
let cipher = Aes256Gcm::new(&self.key);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ct)
.expect("AES-GCM decryption failed — wrong key or corrupted data");
String::from_utf8(plaintext).expect("decrypted data is not valid UTF-8")
}
}