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:
230
backend/src/auth.rs
Normal file
230
backend/src/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user