feat: Implement full auth pipeline — login, register, JWT, bootstrap

Backend auth flow is now functional:
- services/auth.rs: Argon2id password hashing, JWT access/refresh tokens
- services/encryption.rs: AES-256-GCM encrypt/decrypt, hex token generation
- api/auth.rs: Login, register, refresh, logout, /me endpoints
- middleware/auth.rs: JWT Bearer token extractor (FromRequestParts)
- db/users.rs + licenses.rs: Full CRUD with runtime queries (no compile-time DB)
- main.rs: Bootstrap admin user on first run via ADMIN_EMAIL/ADMIN_PASSWORD env vars
- NATS connection now optional for dev (graceful fallback)
- Added hex and http crates to Cargo.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-14 21:49:37 -05:00
parent 9217f77998
commit 5668675b6a
10 changed files with 671 additions and 101 deletions

View File

@@ -0,0 +1,94 @@
use anyhow::{Context, Result};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use uuid::Uuid;
use crate::config::AppConfig;
use crate::models::auth::Claims;
/// Hash a password using Argon2id.
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Password hashing failed: {e}"))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id hash.
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("Invalid password hash: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
/// Create a JWT access token.
pub fn create_access_token(
config: &AppConfig,
user_id: Uuid,
email: &str,
license_id: Option<Uuid>,
role: Option<String>,
) -> Result<String> {
let now = Utc::now().timestamp();
let claims = Claims {
sub: user_id,
email: email.to_string(),
license_id,
role,
exp: now + config.jwt_access_expiry_seconds,
iat: now,
token_type: "access".to_string(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)
.context("Failed to create access token")
}
/// Create a JWT refresh token (longer-lived).
pub fn create_refresh_token(
config: &AppConfig,
user_id: Uuid,
email: &str,
) -> Result<String> {
let now = Utc::now().timestamp();
let claims = Claims {
sub: user_id,
email: email.to_string(),
license_id: None,
role: None,
exp: now + config.jwt_refresh_expiry_seconds,
iat: now,
token_type: "refresh".to_string(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)
.context("Failed to create refresh token")
}
/// Validate and decode a JWT token. Returns the claims on success.
pub fn validate_token(config: &AppConfig, token: &str) -> Result<Claims> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
&Validation::default(),
)
.context("Invalid or expired token")?;
Ok(token_data.claims)
}

View File

@@ -1,46 +1,67 @@
use anyhow::Result;
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
/// AES-256-GCM encryption utilities.
///
/// Used to encrypt sensitive data at rest: panel API keys, companion
/// agent tokens, webhook URLs, and other secrets stored in the database.
/// Keys are derived from the application's ENCRYPTION_KEY environment
/// variable.
/// Derive a 256-bit key from a string using SHA-256.
fn derive_key(key: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
hasher.finalize().into()
}
/// Encrypt a plaintext string using AES-256-GCM.
///
/// Returns a base64-encoded string containing the nonce + ciphertext.
/// The nonce is randomly generated and prepended to the ciphertext
/// before encoding.
pub fn encrypt(_plaintext: &str, _key: &str) -> Result<String> {
// TODO: Derive 256-bit key from key string (SHA-256 or HKDF)
// TODO: Generate random 96-bit nonce
// TODO: Encrypt plaintext with AES-256-GCM
// TODO: Prepend nonce to ciphertext
// TODO: Base64-encode the result
// TODO: Return encoded string
todo!()
pub fn encrypt(plaintext: &str, key: &str) -> Result<String> {
let key_bytes = derive_key(key);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.context("Failed to create AES cipher")?;
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())
.map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))?;
// Prepend nonce to ciphertext
let mut combined = Vec::with_capacity(12 + ciphertext.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&combined))
}
/// Decrypt an AES-256-GCM encrypted string.
///
/// Expects a base64-encoded string containing nonce + ciphertext
/// (as produced by `encrypt`).
pub fn decrypt(_ciphertext: &str, _key: &str) -> Result<String> {
// TODO: Base64-decode the input
// TODO: Split nonce (first 12 bytes) from ciphertext
// TODO: Derive 256-bit key from key string (same as encrypt)
// TODO: Decrypt with AES-256-GCM
// TODO: Return plaintext string
todo!()
pub fn decrypt(encoded: &str, key: &str) -> Result<String> {
let combined = BASE64.decode(encoded).context("Invalid base64")?;
if combined.len() < 12 {
anyhow::bail!("Ciphertext too short");
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let key_bytes = derive_key(key);
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
.context("Failed to create AES cipher")?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("Decryption failed: {e}"))?;
String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
}
/// Generate a cryptographically random token string.
///
/// Returns a hex-encoded random string of the specified byte length.
/// Used for generating API keys, agent tokens, and session secrets.
pub fn generate_token(_length: usize) -> String {
// TODO: Generate `length` random bytes using rand/OsRng
// TODO: Hex-encode and return
todo!()
/// Generate a cryptographically random hex token of the specified byte length.
pub fn generate_token(length: usize) -> String {
let mut bytes = vec![0u8; length];
OsRng.fill_bytes(&mut bytes);
hex::encode(&bytes)
}

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod panel_adapter;
pub mod amp_adapter;
pub mod pterodactyl_adapter;