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

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)
}
}