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)
|
||||
}
|
||||
Reference in New Issue
Block a user