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:
94
backend/src/services/auth.rs
Normal file
94
backend/src/services/auth.rs
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod panel_adapter;
|
||||
pub mod amp_adapter;
|
||||
pub mod pterodactyl_adapter;
|
||||
|
||||
Reference in New Issue
Block a user