Add user management system with JWT authentication

- Database schema: users, permissions, client_access tables
- Auth: JWT tokens with Argon2 password hashing
- API: login, user CRUD, permission management
- Dashboard: login required, admin Users tab
- Auto-creates initial admin user on first run
This commit is contained in:
2025-12-29 20:57:30 -07:00
parent 743b73dfe7
commit 3fc4e1f96a
13 changed files with 2354 additions and 70 deletions

140
server/src/auth/jwt.rs Normal file
View File

@@ -0,0 +1,140 @@
//! JWT token handling
use anyhow::{anyhow, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// JWT claims
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
/// Subject (user ID)
pub sub: String,
/// Username
pub username: String,
/// Role (admin, operator, viewer)
pub role: String,
/// Permissions list
pub permissions: Vec<String>,
/// Expiration time (unix timestamp)
pub exp: i64,
/// Issued at (unix timestamp)
pub iat: i64,
}
impl Claims {
/// Check if user has a specific permission
pub fn has_permission(&self, permission: &str) -> bool {
// Admins have all permissions
if self.role == "admin" {
return true;
}
self.permissions.contains(&permission.to_string())
}
/// Check if user is admin
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
/// Get user ID as UUID
pub fn user_id(&self) -> Result<Uuid> {
Uuid::parse_str(&self.sub).map_err(|e| anyhow!("Invalid user ID in token: {}", e))
}
}
/// JWT configuration
#[derive(Clone)]
pub struct JwtConfig {
secret: String,
expiry_hours: i64,
}
impl JwtConfig {
/// Create new JWT config
pub fn new(secret: String, expiry_hours: i64) -> Self {
Self { secret, expiry_hours }
}
/// Create a JWT token for a user
pub fn create_token(
&self,
user_id: Uuid,
username: &str,
role: &str,
permissions: Vec<String>,
) -> Result<String> {
let now = Utc::now();
let exp = now + Duration::hours(self.expiry_hours);
let claims = Claims {
sub: user_id.to_string(),
username: username.to_string(),
role: role.to_string(),
permissions,
exp: exp.timestamp(),
iat: now.timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.secret.as_bytes()),
)
.map_err(|e| anyhow!("Failed to create token: {}", e))?;
Ok(token)
}
/// Validate and decode a JWT token
pub fn validate_token(&self, token: &str) -> Result<Claims> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.secret.as_bytes()),
&Validation::default(),
)
.map_err(|e| anyhow!("Invalid token: {}", e))?;
Ok(token_data.claims)
}
}
/// Default JWT secret if not configured (NOT for production!)
pub fn default_jwt_secret() -> String {
// In production, this should come from environment variable
std::env::var("JWT_SECRET").unwrap_or_else(|_| {
tracing::warn!("JWT_SECRET not set, using default (INSECURE!)");
"guruconnect-dev-secret-change-me-in-production".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_validate_token() {
let config = JwtConfig::new("test-secret".to_string(), 24);
let user_id = Uuid::new_v4();
let token = config.create_token(
user_id,
"testuser",
"admin",
vec!["view".to_string(), "control".to_string()],
).unwrap();
let claims = config.validate_token(&token).unwrap();
assert_eq!(claims.username, "testuser");
assert_eq!(claims.role, "admin");
assert!(claims.has_permission("view"));
assert!(claims.has_permission("manage_users")); // admin has all
}
#[test]
fn test_invalid_token() {
let config = JwtConfig::new("test-secret".to_string(), 24);
assert!(config.validate_token("invalid.token.here").is_err());
}
}

View File

@@ -3,17 +3,51 @@
//! Handles JWT validation for dashboard users and API key
//! validation for agents.
pub mod jwt;
pub mod password;
pub use jwt::{Claims, JwtConfig};
pub use password::{hash_password, verify_password, generate_random_password};
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use std::sync::Arc;
/// Authenticated user from JWT
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
pub user_id: String,
pub email: String,
pub roles: Vec<String>,
pub username: String,
pub role: String,
pub permissions: Vec<String>,
}
impl AuthenticatedUser {
/// Check if user has a specific permission
pub fn has_permission(&self, permission: &str) -> bool {
if self.role == "admin" {
return true;
}
self.permissions.contains(&permission.to_string())
}
/// Check if user is admin
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
impl From<Claims> for AuthenticatedUser {
fn from(claims: Claims) -> Self {
Self {
user_id: claims.sub,
username: claims.username,
role: claims.role,
permissions: claims.permissions,
}
}
}
/// Authenticated agent from API key
@@ -23,7 +57,21 @@ pub struct AuthenticatedAgent {
pub org_id: String,
}
/// Extract authenticated user from request (placeholder for MVP)
/// JWT configuration stored in app state
#[derive(Clone)]
pub struct AuthState {
pub jwt_config: Arc<JwtConfig>,
}
impl AuthState {
pub fn new(jwt_secret: String, expiry_hours: i64) -> Self {
Self {
jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)),
}
}
}
/// Extract authenticated user from request
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
@@ -32,28 +80,77 @@ where
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// TODO: Implement JWT validation
// For MVP, accept any request
// Look for Authorization header
let _auth_header = parts
// Get Authorization header
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok());
.and_then(|v| v.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
// Placeholder - in production, validate JWT
Ok(AuthenticatedUser {
user_id: "mvp-user".to_string(),
email: "mvp@example.com".to_string(),
roles: vec!["admin".to_string()],
})
// Extract Bearer token
let token = auth_header
.strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
// Get JWT config from extensions (set by middleware)
let jwt_config = parts
.extensions
.get::<Arc<JwtConfig>>()
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
// Validate token
let claims = jwt_config
.validate_token(token)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?;
Ok(AuthenticatedUser::from(claims))
}
}
/// Optional authenticated user (doesn't reject if not authenticated)
#[derive(Debug, Clone)]
pub struct OptionalUser(pub Option<AuthenticatedUser>);
#[axum::async_trait]
impl<S> FromRequestParts<S> for OptionalUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
match AuthenticatedUser::from_request_parts(parts, state).await {
Ok(user) => Ok(OptionalUser(Some(user))),
Err(_) => Ok(OptionalUser(None)),
}
}
}
/// Require admin role
#[derive(Debug, Clone)]
pub struct AdminUser(pub AuthenticatedUser);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
if user.is_admin() {
Ok(AdminUser(user))
} else {
Err((StatusCode::FORBIDDEN, "Admin access required"))
}
}
}
/// Validate an agent API key (placeholder for MVP)
pub fn validate_agent_key(_api_key: &str) -> Option<AuthenticatedAgent> {
// TODO: Implement actual API key validation
// For MVP, accept any key
// TODO: Implement actual API key validation against database
// For now, accept any key for agent connections
Some(AuthenticatedAgent {
agent_id: "mvp-agent".to_string(),
org_id: "mvp-org".to_string(),

View File

@@ -0,0 +1,57 @@
//! Password hashing using Argon2id
use anyhow::{anyhow, Result};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
/// 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!("Failed to hash password: {}", e))?;
Ok(hash.to_string())
}
/// Verify a password against a stored hash
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow!("Invalid password hash format: {}", e))?;
let argon2 = Argon2::default();
Ok(argon2.verify_password(password.as_bytes(), &parsed_hash).is_ok())
}
/// Generate a random password (for initial admin)
pub fn generate_random_password(length: usize) -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify() {
let password = "test_password_123";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
assert!(!verify_password("wrong_password", &hash).unwrap());
}
#[test]
fn test_random_password() {
let password = generate_random_password(16);
assert_eq!(password.len(), 16);
}
}