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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user