Implement complete full-stack authentication system

- Restructure project with separate frontend/backend architecture
- Create dedicated backend with Axum, SQLite, JWT authentication
- Implement real API endpoints for register/login/verify
- Update frontend to use HTTP requests instead of mock auth
- Add bcrypt password hashing and secure token generation
- Separate Cargo.toml files for frontend and backend builds
- Fix Trunk compilation by isolating WASM-incompatible dependencies
- Create demo user in database for easy testing
- Both servers running: frontend (8081), backend (3000)

🤖 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 16:15:37 -04:00
parent 08c333dcba
commit 25bf194d19
12 changed files with 641 additions and 82 deletions

29
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "calendar-backend"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "backend"
path = "src/main.rs"
[dependencies]
# Backend authentication dependencies
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] }
bcrypt = "0.15"
jsonwebtoken = "9.0"
tokio = { version = "1.0", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
# Shared dependencies
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0"
[dev-dependencies]
tokio = { version = "1.0", features = ["macros", "rt"] }

230
backend/src/auth.rs Normal file
View File

@@ -0,0 +1,230 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{DateTime, 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};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // Subject (user ID)
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 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> {
// Validate input
self.validate_registration(&request)?;
// 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()));
}
// 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?;
Ok(UserInfo {
id: user.id,
username: user.username,
email: user.email,
})
}
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> {
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.email.trim().is_empty() || !request.email.contains('@') {
return Err(ApiError::BadRequest("Valid email is required".to_string()));
}
if request.password.len() < 6 {
return Err(ApiError::BadRequest("Password must be at least 6 characters".to_string()));
}
Ok(())
}
fn generate_token(&self, user: &User) -> 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(),
exp: expires_at.timestamp(),
iat: now.timestamp(),
username: user.username.clone(),
email: user.email.clone(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.jwt_secret.as_bytes()),
)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
Ok(token)
}
fn decode_token(&self, token: &str) -> Result<Claims, ApiError> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
Ok(token_data.claims)
}
}

57
backend/src/handlers.rs Normal file
View File

@@ -0,0 +1,57 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::{AppState, models::{LoginRequest, RegisterRequest, AuthResponse, ApiError}};
#[derive(Deserialize)]
pub struct VerifyQuery {
pub token: String,
}
pub async fn register(
State(state): State<Arc<AppState>>,
Json(request): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
let response = state.auth_service.register(request).await?;
Ok(Json(response))
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(request): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
let response = state.auth_service.login(request).await?;
Ok(Json(response))
}
pub async fn verify_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
// Try to get token from Authorization header
let token = if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
token.to_string()
} else {
return Err(ApiError::BadRequest("Authorization header must start with 'Bearer '".to_string()));
}
} else {
return Err(ApiError::Unauthorized("Authorization header required".to_string()));
};
let user_info = state.auth_service.verify_token(&token).await?;
Ok(Json(serde_json::json!({
"valid": true,
"user": user_info
})))
}

85
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,85 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use sqlx::{sqlite::SqlitePool, Row};
use tower_http::cors::{CorsLayer, Any};
use uuid::Uuid;
use std::sync::Arc;
mod auth;
mod models;
mod handlers;
use auth::AuthService;
use models::{LoginRequest, RegisterRequest, AuthResponse, ApiError};
#[derive(Clone)]
pub struct AppState {
pub auth_service: AuthService,
}
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
println!("🚀 Starting Calendar Backend Server");
// Set up database
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:calendar.db?mode=rwc".to_string());
let db_pool = SqlitePool::connect(&database_url).await?;
// Run migrations - create database file if it doesn't exist
// The migrate!() macro looks for migrations in the current directory
// so we don't need to run explicit migrations here since we handle it in init_db()
// Create auth service
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
let auth_service = AuthService::new(db_pool, jwt_secret);
// Initialize database schema
auth_service.init_db().await?;
let app_state = AppState { auth_service };
// Build our application with routes
let app = Router::new()
.route("/", get(root))
.route("/api/health", get(health_check))
.route("/api/auth/register", post(handlers::register))
.route("/api/auth/login", post(handlers::login))
.route("/api/auth/verify", get(handlers::verify_token))
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
.with_state(Arc::new(app_state));
// Start server
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("📡 Server listening on http://0.0.0.0:3000");
axum::serve(listener, app).await?;
Ok(())
}
async fn root() -> &'static str {
"Calendar Backend API v0.1.0"
}
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"service": "calendar-backend",
"version": "0.1.0"
}))
}

7
backend/src/main.rs Normal file
View File

@@ -0,0 +1,7 @@
// Backend main entry point
use calendar_backend::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_server().await
}

90
backend/src/models.rs Normal file
View File

@@ -0,0 +1,90 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
// Database models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
pub email: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
}
// API request/response types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserInfo,
}
// Error handling
#[derive(Debug)]
pub enum ApiError {
Database(String),
NotFound(String),
Unauthorized(String),
BadRequest(String),
Conflict(String),
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
ApiError::Database(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg),
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
let body = Json(serde_json::json!({
"error": error_message,
"status": status.as_u16()
}));
(status, body).into_response()
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiError::Database(msg) => write!(f, "Database error: {}", msg),
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
ApiError::Conflict(msg) => write!(f, "Conflict: {}", msg),
ApiError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for ApiError {}