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)
}