Refactor authentication from database to direct CalDAV authentication

Major architectural change to simplify authentication by authenticating directly against CalDAV servers instead of maintaining a local user database.

Backend changes:
- Remove SQLite database dependencies and user storage
- Refactor AuthService to authenticate directly against CalDAV servers
- Update JWT tokens to store CalDAV server info instead of user IDs
- Implement proper CalDAV calendar discovery with XML parsing
- Fix URL construction for CalDAV REPORT requests
- Add comprehensive debug logging for authentication flow

Frontend changes:
- Add server URL input field to login form
- Remove registration functionality entirely
- Update calendar service to pass CalDAV passwords via headers
- Store CalDAV credentials in localStorage for API calls

Key improvements:
- Simplified architecture eliminates database complexity
- Direct CalDAV authentication ensures credentials always work
- Proper calendar discovery automatically finds user calendars
- Robust error handling and debug logging for troubleshooting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 18:40:22 -04:00
parent 0741afd0b2
commit d85898cae7
12 changed files with 276 additions and 582 deletions

View File

@@ -1,210 +1,118 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use crate::models::{User, UserInfo, LoginRequest, RegisterRequest, AuthResponse, ApiError};
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
use crate::config::CalDAVConfig;
use crate::calendar::CalDAVClient;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // Subject (user ID)
pub username: String,
pub server_url: String,
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
pub username: String,
pub email: String,
}
#[derive(Clone)]
pub struct AuthService {
db: SqlitePool,
jwt_secret: String,
}
impl AuthService {
pub fn new(db: SqlitePool, jwt_secret: String) -> Self {
Self { db, jwt_secret }
pub fn new(jwt_secret: String) -> Self {
Self { jwt_secret }
}
pub async fn init_db(&self) -> Result<(), ApiError> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
Ok(())
}
pub async fn register(&self, request: RegisterRequest) -> Result<AuthResponse, ApiError> {
/// Authenticate user directly against CalDAV server
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, ApiError> {
// Validate input
self.validate_registration(&request)?;
self.validate_login(&request)?;
println!("✅ Input validation passed");
// Check if user already exists
if self.user_exists(&request.username, &request.email).await? {
return Err(ApiError::Conflict("Username or email already exists".to_string()));
}
// Create CalDAV config with provided credentials
let caldav_config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
println!("📝 Created CalDAV config");
// Hash password
let password_hash = hash(&request.password, DEFAULT_COST)
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {}", e)))?;
// Create user
let user_id = Uuid::new_v4().to_string();
let now = Utc::now();
sqlx::query(
r#"
INSERT INTO users (id, username, email, password_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&user_id)
.bind(&request.username)
.bind(&request.email)
.bind(&password_hash)
.bind(now)
.bind(now)
.execute(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
// Get the created user
let user = self.get_user_by_id(&user_id).await?;
// Generate token
let token = self.generate_token(&user)?;
Ok(AuthResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
email: user.email,
},
})
}
pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, ApiError> {
// Get user by username
let user = self.get_user_by_username(&request.username).await?;
// Verify password
let is_valid = verify(&request.password, &user.password_hash)
.map_err(|e| ApiError::Internal(format!("Password verification failed: {}", e)))?;
if !is_valid {
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
}
// Generate token
let token = self.generate_token(&user)?;
Ok(AuthResponse {
token,
user: UserInfo {
id: user.id,
username: user.username,
email: user.email,
},
})
}
pub async fn verify_token(&self, token: &str) -> Result<UserInfo, ApiError> {
let claims = self.decode_token(token)?;
let user = self.get_user_by_id(&claims.sub).await?;
// Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
Ok(UserInfo {
id: user.id,
username: user.username,
email: user.email,
// Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await {
Ok(calendars) => {
println!("✅ Authentication successful! Found {} calendars", calendars.len());
// Authentication successful, generate JWT token
let token = self.generate_token(&request.username, &request.server_url)?;
Ok(AuthResponse {
token,
username: request.username,
server_url: request.server_url,
})
}
Err(err) => {
println!("❌ Authentication failed: {:?}", err);
// Authentication failed
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
}
}
}
/// Verify JWT token and extract CalDAV credentials info
pub fn verify_token(&self, token: &str) -> Result<Claims, ApiError> {
self.decode_token(token)
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
let claims = self.verify_token(token)?;
Ok(CalDAVConfig {
server_url: claims.server_url,
username: claims.username,
password: password.to_string(),
calendar_path: None,
tasks_path: None,
})
}
async fn get_user_by_username(&self, username: &str) -> Result<User, ApiError> {
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE username = ?")
.bind(username)
.fetch_one(&self.db)
.await
.map_err(|_| ApiError::Unauthorized("Invalid credentials".to_string()))?;
self.row_to_user(row)
}
async fn get_user_by_id(&self, user_id: &str) -> Result<User, ApiError> {
let row = sqlx::query("SELECT id, username, email, password_hash, created_at FROM users WHERE id = ?")
.bind(user_id)
.fetch_one(&self.db)
.await
.map_err(|_| ApiError::NotFound("User not found".to_string()))?;
self.row_to_user(row)
}
fn row_to_user(&self, row: sqlx::sqlite::SqliteRow) -> Result<User, ApiError> {
Ok(User {
id: row.try_get("id").map_err(|e| ApiError::Database(e.to_string()))?,
username: row.try_get("username").map_err(|e| ApiError::Database(e.to_string()))?,
email: row.try_get("email").map_err(|e| ApiError::Database(e.to_string()))?,
password_hash: row.try_get("password_hash").map_err(|e| ApiError::Database(e.to_string()))?,
created_at: row.try_get("created_at").map_err(|e| ApiError::Database(e.to_string()))?,
})
}
async fn user_exists(&self, username: &str, email: &str) -> Result<bool, ApiError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE username = ? OR email = ?"
)
.bind(username)
.bind(email)
.fetch_one(&self.db)
.await
.map_err(|e| ApiError::Database(e.to_string()))?;
Ok(count > 0)
}
fn validate_registration(&self, request: &RegisterRequest) -> Result<(), ApiError> {
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
if request.username.trim().is_empty() {
return Err(ApiError::BadRequest("Username is required".to_string()));
}
if request.username.len() < 3 {
return Err(ApiError::BadRequest("Username must be at least 3 characters".to_string()));
if request.password.trim().is_empty() {
return Err(ApiError::BadRequest("Password is required".to_string()));
}
if request.email.trim().is_empty() || !request.email.contains('@') {
return Err(ApiError::BadRequest("Valid email is required".to_string()));
if request.server_url.trim().is_empty() {
return Err(ApiError::BadRequest("Server URL is required".to_string()));
}
if request.password.len() < 6 {
return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string()));
// Basic URL validation
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
}
Ok(())
}
fn generate_token(&self, user: &User) -> Result<String, ApiError> {
fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
let now = Utc::now();
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
let claims = Claims {
sub: user.id.clone(),
username: username.to_string(),
server_url: server_url.to_string(),
exp: expires_at.timestamp(),
iat: now.timestamp(),
username: user.username.clone(),
email: user.email.clone(),
};
let token = encode(