diff options
| -rw-r--r-- | cmd/server/main.go | 277 | ||||
| -rw-r--r-- | handlers/auth.go | 407 | ||||
| -rw-r--r-- | handlers/auth_login.go | 165 | ||||
| -rw-r--r-- | handlers/auth_logout.go | 41 | ||||
| -rw-r--r-- | handlers/auth_register.go | 92 | ||||
| -rw-r--r-- | handlers/auth_token.go | 138 | ||||
| -rw-r--r-- | router/router.go | 228 |
7 files changed, 676 insertions, 672 deletions
diff --git a/cmd/server/main.go b/cmd/server/main.go index c97dbdd..3ac9bfc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,16 +6,12 @@ import ( "auth-proxy/handlers" "auth-proxy/logging" "auth-proxy/middleware" - "auth-proxy/models" - "auth-proxy/types" + "auth-proxy/router" "auth-proxy/worker" "net/http" "os" - "strings" - "time" "github.com/joho/godotenv" - "github.com/gorilla/csrf" ) func main() { @@ -39,175 +35,29 @@ func main() { defer worker.Close(pool) handlers.SetWorkerPool(pool) - // Get target URL for proxy - targetURL := os.Getenv("TARGET_URL") - if targetURL == "" { - logging.AppLog.Error("TARGET_URL environment variable not set") - os.Exit(1) - } - - // --- Create routers for Web and API --- - webMux := http.NewServeMux() - apiMux := http.NewServeMux() - - // --- Static file serving (Web) --- - assetsPath := strings.TrimRight(config.Paths.Assets, "/") + "/" - assetsFS := http.FileServer(http.Dir("templates")) - webMux.Handle(assetsPath, http.StripPrefix(assetsPath, assetsFS)) - - // --- Rate Limiter --- - rateLimiter := middleware.NewRateLimiter() - - // Helper to switch handler based on HTTP method - methodSwitch := func(get, post http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - post.ServeHTTP(w, r) - } else { - get.ServeHTTP(w, r) - } - }) - } - - // --- Web UI Routes (CSRF-protected) --- - if frontendType := os.Getenv("FRONTEND_TYPE"); frontendType == "php" { - // PHP frontend routes - loginPageHandler := handlers.NewPhpProxyHandler("login.php") - registerPageHandler := handlers.NewPhpProxyHandler("register.php") - accountPageHandler := handlers.NewPhpProxyHandler("account.php") - accountPasswordPageHandler := handlers.NewPhpProxyHandler("account_password.php") - - loginHandler := methodSwitch(loginPageHandler, http.HandlerFunc(handlers.LoginHandler)) - webMux.Handle(config.Paths.Login, loginHandler) - - if config.Paths.RegisterEnabled { - registerPostHandler := middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler)) - registerHandler := methodSwitch(registerPageHandler, registerPostHandler) - webMux.Handle(config.Paths.Register, registerHandler) - } - - accountPasswordPostHandler := http.HandlerFunc(handlers.ChangePasswordHandler) - accountPasswordHandler := methodSwitch(accountPasswordPageHandler, accountPasswordPostHandler) - webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(accountPasswordHandler)) - webMux.Handle(config.Paths.Account, middleware.SessionAuth(accountPageHandler)) - webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler)))) - } else { - // Go template frontend routes - webMux.HandleFunc(config.Paths.Login, handlers.LoginHandler) - if config.Paths.RegisterEnabled { - webMux.Handle(config.Paths.Register, middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler))) - } - webMux.Handle(config.Paths.Account, middleware.SessionAuth(http.HandlerFunc(handlers.AccountPageHandler))) - webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler)))) - webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(http.HandlerFunc(handlers.ChangePasswordHandler))) - } - webMux.HandleFunc(config.Paths.Logout, handlers.LogoutHandler) - - // --- API Routes (No CSRF) --- - apiMux.HandleFunc("/api/auth/token", handlers.TokenHandler) - apiMux.HandleFunc("/auth/refresh", handlers.RefreshTokenHandler) - - // Admin API endpoints - adminCreateUserAPI := http.HandlerFunc(handlers.CreateUserHandler) - getUsersHandler := http.HandlerFunc(handlers.GetUsersHandler) - adminUsersAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - adminCreateUserAPI.ServeHTTP(w, r) - } else { - getUsersHandler.ServeHTTP(w, r) - } - }) - adminUpdateRoleAPI := http.HandlerFunc(handlers.UpdateUserRoleHandler) - adminDeleteUserAPI := http.HandlerFunc(handlers.DeleteUserHandler) - adminSetStatusAPI := http.HandlerFunc(handlers.SetUserActiveStatusHandler) - - apiMux.Handle(config.Paths.AdminUsersAPI, middleware.BearerAuth(middleware.AdminMiddleware(adminUsersAPI))) - apiMux.Handle(config.Paths.AdminUsersAPI+"/", middleware.BearerAuth(middleware.AdminMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/role") && r.Method == http.MethodPost: - adminUpdateRoleAPI.ServeHTTP(w, r) - case strings.HasSuffix(path, "/status") && r.Method == http.MethodPost: - adminSetStatusAPI.ServeHTTP(w, r) - case r.Method == http.MethodDelete: - adminDeleteUserAPI.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - })))) - - // --- Main Application Proxy --- - proxyHandler := handlers.NewProxy(targetURL) - // --- Secret Key Validation --- sessionSecret := os.Getenv("SESSION_SECRET") if sessionSecret == "" { logging.AppLog.Warn("SESSION_SECRET environment variable not set. Using default key. THIS IS NOT SAFE FOR PRODUCTION.") - sessionSecret = "default-secret-key-for-dev" } csrfSecret := os.Getenv("CSRF_SECRET_KEY") if csrfSecret == "" { logging.AppLog.Error("CSRF_SECRET_KEY environment variable not set") os.Exit(1) } - csrfOptions := []csrf.Option{ - csrf.Secure(os.Getenv("ENV") == "production"), - csrf.Path("/"), - } - sameSiteMode := csrf.SameSiteLaxMode - switch strings.ToLower(os.Getenv("CSRF_SAME_SITE")) { - case "strict": - sameSiteMode = csrf.SameSiteStrictMode - case "none": - sameSiteMode = csrf.SameSiteNoneMode - } - csrfOptions = append(csrfOptions, csrf.SameSite(sameSiteMode)) - trustedOrigins := os.Getenv("CSRF_TRUSTED_ORIGINS") - if trustedOrigins != "" { - origins := strings.Split(trustedOrigins, ",") - csrfOptions = append(csrfOptions, csrf.TrustedOrigins(origins)) - } - csrfMiddleware := csrf.Protect([]byte(csrfSecret), csrfOptions...) - csrfProtectedWebMux := middleware.HandleOptions(csrfMiddleware(webMux)) - - // --- Final Handler --- - finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - - // Handle API paths - if strings.HasPrefix(r.URL.Path, config.Paths.APIPath) { - // Check if a specific handler exists in the API mux - _, pattern := apiMux.Handler(r) - // If a specific pattern is found, let the API mux handle it internally. - if pattern != "" { - apiMux.ServeHTTP(w, r) - return - } - - // If no specific handler is found, it's a request to be proxied. - // Protect it with Bearer/Cookie auth if PROTECT_API is enabled. - if config.Paths.ProtectAPI { - middleware.BearerAuth(proxyHandler).ServeHTTP(w, r) - } else { - proxyHandler.ServeHTTP(w, r) - } - return - } + // Get target URL for proxy + targetURL := os.Getenv("TARGET_URL") + if targetURL == "" { + logging.AppLog.Error("TARGET_URL environment variable not set") + os.Exit(1) + } - // Check if the path is a web UI path and apply CSRF protection - _, webPattern := webMux.Handler(r) - if webPattern != "" { - csrfProtectedWebMux.ServeHTTP(w, r) - return - } + // Create the main handler + mainRouter := router.NewRouter() - // Fallback to proxy for all other paths - if config.Paths.ProtectFrontend { - middleware.SessionAuth(proxyHandler).ServeHTTP(w, r) - } else { - proxyHandler.ServeHTTP(w, r) - } - }) + // Start periodic background tasks + go router.StartBackgroundTasks(pool, middleware.NewRateLimiter()) // Get server port port := os.Getenv("LISTEN_PORT") @@ -219,111 +69,8 @@ func main() { // --- Start Server --- logging.AppLog.Info("Server starting on " + listenAddr) - // Start periodic background tasks - go startBackgroundTasks(pool, rateLimiter) - - if err := http.ListenAndServe(listenAddr, finalHandler); err != nil { + if err := http.ListenAndServe(listenAddr, mainRouter); err != nil { logging.AppLog.Error("Server failed to start", "error", err) os.Exit(1) } } - - - - // startBackgroundTasks launches periodic jobs in the background. - - func startBackgroundTasks(pool *types.WorkerPool, limiter *types.RateLimiter) { - - // Ticker for expired token cleanup (every hour) - - tokenTicker := time.NewTicker(1 * time.Hour) - - defer tokenTicker.Stop() - - - - // Ticker for rate limiter cleanup (every 10 minutes) - - rateLimiterTicker := time.NewTicker(24 * time.Hour) - - defer rateLimiterTicker.Stop() - - - - logging.AppLog.Info("Background task scheduler started") - - - - // Run initial cleanup tasks at startup - - submitTokenCleanup(pool) - - submitRateLimiterCleanup(pool, limiter) - - - - // Run tasks on their respective tickers - - for { - - select { - - case <-tokenTicker.C: - - submitTokenCleanup(pool) - - case <-rateLimiterTicker.C: - - submitRateLimiterCleanup(pool, limiter) - - } - - } - - } - - - - // submitTokenCleanup submits the expired token deletion task to the worker pool. - - func submitTokenCleanup(pool *types.WorkerPool) { - - worker.Submit(pool, func() { - - logging.AppLog.Info("Running background task: deleting expired tokens") - - rowsAffected, err := models.DeleteExpiredTokens() - - if err != nil { - - logging.AppLog.Error("Failed to delete expired tokens", "error", err) - - } else { - - if rowsAffected > 0 { - - logging.AppLog.Info("Finished deleting expired tokens", "deleted_count", rowsAffected) - - } - - } - - }) - - } - - - - // submitRateLimiterCleanup submits the rate limiter cleanup task to the worker pool. - - func submitRateLimiterCleanup(pool *types.WorkerPool, limiter *types.RateLimiter) { - - worker.Submit(pool, func() { - - logging.AppLog.Info("Running background task: cleaning up rate limiter") - - middleware.Cleanup(limiter) - - }) - - } diff --git a/handlers/auth.go b/handlers/auth.go deleted file mode 100644 index 4b75e0b..0000000 --- a/handlers/auth.go +++ /dev/null @@ -1,407 +0,0 @@ -package handlers - -import ( - "auth-proxy/config" - "auth-proxy/logging" - "auth-proxy/models" - "auth-proxy/types" - "auth-proxy/utils" - "encoding/json" - "errors" - "html/template" - "net/http" - "time" - - "github.com/gorilla/csrf" -) - -var templates = template.Must(template.ParseGlob("templates/*.html")) - -// authenticateUser handles the core logic for authenticating a user, including brute-force protection. -// It returns the authenticated user or an error if authentication fails. -func authenticateUser(username, password, ip string) (*types.User, error) { - user, err := models.GetUserByUsername(username) - if err != nil { - logging.AppLog.Error("Database error during authentication", "error", err, "username", username, "ip", ip) - return nil, errors.New("internal server error") - } - - if user == nil { - logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "user not found") - return nil, errors.New("invalid username or password") - } - - // Step 1: Check if the account is locked. - if !user.IsActive { - lockoutDuration := time.Duration(config.Paths.LockoutDurationMinutes) * time.Minute - - // Check if the lockout period has expired. - if time.Since(user.UpdatedAt) < lockoutDuration { - logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "account locked") - return nil, errors.New("account is temporarily locked") - } else { - // Lockout has expired. Reactivate the account but do not log the user in on this attempt. - if err := models.SetUserActiveStatus(user.ID, true); err != nil { - logging.AppLog.Error("Failed to reactivate user", "error", err, "user_id", user.ID, "ip", ip) - return nil, errors.New("internal server error") - } - if err := models.RecordLoginSuccess(user.ID); err != nil { - logging.AppLog.Error("Failed to reset failed login attempts on reactivation", "error", err, "user_id", user.ID, "ip", ip) - } - logging.SecurityLog.Info("ACCOUNT UNLOCKED", "username", username, "ip", ip) - return nil, errors.New("account has been unlocked, please try again") - } - } - - // Step 2: If the account is active, validate credentials. - if !models.CheckPasswordHash(password, user.PasswordHash) { - // Record the failure - if err := models.RecordLoginFailure(user.ID); err != nil { - logging.AppLog.Error("Failed to record login failure", "error", err, "user_id", user.ID, "ip", ip) - } - - // Re-fetch user to get updated failed_logins count - updatedUser, _ := models.GetUserByID(user.ID) - maxAttempts := config.Paths.MaxLoginAttempts - - // Lock the account if max attempts are exceeded - if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts { - if err := models.SetUserActiveStatus(user.ID, false); err != nil { - logging.AppLog.Error("Failed to lock account", "error", err, "user_id", user.ID, "ip", ip) - } - logging.SecurityLog.Warn("ACCOUNT LOCKED", "username", username, "ip", ip) - } - - logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "invalid credentials") - return nil, errors.New("invalid username or password") - } - - // Step 3: Login successful. - if err := models.RecordLoginSuccess(user.ID); err != nil { - logging.AppLog.Error("Failed to record login success", "error", err, "user_id", user.ID, "ip", ip) - } - logging.SecurityLog.Info("AUTH SUCCESS", "username", user.Username, "ip", ip) - - return user, nil -} - -func renderLoginWithError(w http.ResponseWriter, r *http.Request, message string) { - data := map[string]interface{}{ - "Paths": config.Paths, - "Error": message, - "RegisterEnabled": config.Paths.RegisterEnabled, - "CSRFToken": csrf.Token(r), - } - w.WriteHeader(http.StatusUnauthorized) // Set status to indicate failure - if err := templates.ExecuteTemplate(w, "login.html", data); err != nil { - logging.AppLog.Error("Failed to execute login template with error", "error", err) - } -} - -// LoginHandler handles displaying the login form and processing login requests. -func LoginHandler(w http.ResponseWriter, r *http.Request) { - ip := logging.GetClientIP(r) - - if r.Method == http.MethodGet { - data := map[string]interface{}{ - "Paths": config.Paths, - "RegisterEnabled": config.Paths.RegisterEnabled, - "CSRFToken": csrf.Token(r), - } - if err := templates.ExecuteTemplate(w, "login.html", data); err != nil { - logging.AppLog.Error("Failed to execute login template", "error", err, "ip", ip) - } - return - } - - if r.Method == http.MethodPost { - if err := r.ParseForm(); err != nil { - logging.AppLog.Error("Failed to parse login form", "error", err, "ip", ip) - renderLoginWithError(w, r, "An internal error occurred. Please try again.") - return - } - username := r.FormValue("username") - password := r.FormValue("password") - - user, err := authenticateUser(username, password, ip) - if err != nil { - // Map authentication error to user-facing message - errMsg := "Invalid username or password." - if err.Error() == "account is temporarily locked" { - errMsg = "Account is temporarily locked." - } else if err.Error() == "internal server error" { - errMsg = "An internal error occurred. Please try again." - } else if err.Error() == "account has been unlocked, please try again" { - errMsg = "Account has been unlocked. Please try logging in again." - } - renderLoginWithError(w, r, errMsg) - return - } - - // --- Create Session Cookie --- - duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour - - token, err := models.CreateAuthToken(user.ID, duration) - if err != nil { - logging.AppLog.Error("Failed to create auth token", "error", err, "username", user.Username, "ip", ip) - renderLoginWithError(w, r, "Could not create session.") - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "auth_token", - Value: token.Token, - Expires: token.ExpiresAt, - Path: "/", - HttpOnly: true, - Secure: r.TLS != nil, // Use Secure flag if on HTTPS - SameSite: http.SameSiteLaxMode, - }) - - // Redirect to the root path, which is the proxied application. - http.Redirect(w, r, "/", http.StatusFound) - return - } - - w.WriteHeader(http.StatusMethodNotAllowed) -} - -// LogoutHandler invalidates the user's token by expiring the cookie. -func LogoutHandler(w http.ResponseWriter, r *http.Request) { - ip := logging.GetClientIP(r) - cookie, err := r.Cookie("auth_token") - if err != nil { - // No cookie, nothing to do. - http.Redirect(w, r, config.Paths.Login, http.StatusFound) - return - } - - err = models.DeleteAuthToken(cookie.Value) - if err != nil { - // Log the error but still proceed with logging the user out client-side. - logging.AppLog.Error("Failed to delete token on logout", "error", err, "ip", ip) - } - - // Expire the cookie by setting its max age to a negative number. - http.SetCookie(w, &http.Cookie{ - Name: "auth_token", - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - MaxAge: -1, - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteLaxMode, - }) - - logging.SecurityLog.Info("LOGOUT SUCCESS", "ip", ip) - http.Redirect(w, r, config.Paths.Login, http.StatusFound) -} - -func renderRegisterWithError(w http.ResponseWriter, r *http.Request, message string) { - data := map[string]interface{}{ - "Paths": config.Paths, - "Error": message, - "CSRFToken": csrf.Token(r), - } - w.WriteHeader(http.StatusBadRequest) // Set status to indicate failure - if err := templates.ExecuteTemplate(w, "register.html", data); err != nil { - logging.AppLog.Error("Failed to execute register template with error", "error", err) - } -} - -// RegisterHandler handles displaying and processing the user registration form. -func RegisterHandler(w http.ResponseWriter, r *http.Request) { - if !config.Paths.RegisterEnabled { - http.Error(w, "User registration is disabled.", http.StatusForbidden) - return - } - ip := logging.GetClientIP(r) - data := map[string]interface{}{ - "Paths": config.Paths, - "CSRFToken": csrf.Token(r), - } - - if r.Method == http.MethodGet { - if err := templates.ExecuteTemplate(w, "register.html", data); err != nil { - logging.AppLog.Error("Failed to execute register template", "error", err, "ip", ip) - } - return - } - - if r.Method == http.MethodPost { - if err := r.ParseForm(); err != nil { - logging.AppLog.Error("Failed to parse registration form", "error", err, "ip", ip) - renderRegisterWithError(w, r, "An internal error occurred. Please try again.") - return - } - username := r.FormValue("username") - password := r.FormValue("password") - confirmPassword := r.FormValue("confirm_password") - - // Validate password policy - if ok, message := utils.ValidatePassword(password); !ok { - renderRegisterWithError(w, r, message) - return - } - - if password != confirmPassword { - logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip) - renderRegisterWithError(w, r, "Passwords do not match") - return - } - - existingUser, err := models.GetUserByUsername(username) - if err != nil { - logging.AppLog.Error("Error checking username during registration", "error", err, "ip", ip) - renderRegisterWithError(w, r, "An internal error occurred while checking the username.") - return - } - if existingUser != nil { - logging.AppLog.Warn("User registration failed", "reason", "username is already taken", "username", username, "ip", ip) - renderRegisterWithError(w, r, "Username is already taken") - return - } - - user, err := models.CreateUser(username, password, "user") - if err != nil { - logging.AppLog.Error("Failed to create user", "error", err, "username", username, "ip", ip) - renderRegisterWithError(w, r, "Failed to create user due to an internal error.") - return - } - logging.SecurityLog.Info("USER CREATED", "username", user.Username, "ip", ip) - - // Redirect to the login page so the user can sign in. - http.Redirect(w, r, config.Paths.Login, http.StatusFound) - return - } - - w.WriteHeader(http.StatusMethodNotAllowed) -} - -// RefreshTokenHandler handles exchanging a valid refresh token for a new access token. -func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { - ip := logging.GetClientIP(r) - - // 1. Parse request body - var reqBody struct { - RefreshToken string `json:"refresh_token"` - } - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - if reqBody.RefreshToken == "" { - http.Error(w, "Missing refresh_token", http.StatusBadRequest) - return - } - - // 2. Validate the refresh token in DB - refreshToken, err := models.GetRefreshTokenByToken(reqBody.RefreshToken) - if err != nil { - logging.AppLog.Error("Error getting refresh token from DB", "error", err, "ip", ip) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if refreshToken == nil || refreshToken.IsRevoked || time.Now().After(refreshToken.ExpiresAt) { - logging.SecurityLog.Warn("REFRESH TOKEN FAIL", "reason", "token not found, revoked, or expired", "ip", ip) - http.Error(w, "Unauthorized: Invalid refresh token", http.StatusUnauthorized) - return - } - - // 3. Get user info - user, err := models.GetUserByID(refreshToken.UserID) - if err != nil || user == nil { - logging.AppLog.Error("Failed to get user from refresh token", "error", err, "user_id", refreshToken.UserID, "ip", ip) - http.Error(w, "Unauthorized: User not found", http.StatusUnauthorized) - return - } - - // 4. Issue a new access token - newAccessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) - if err != nil { - logging.AppLog.Error("Failed to generate new access token", "error", err, "username", user.Username, "ip", ip) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // --- Optional: Token Rotation --- - // 5. Delete the old refresh token - if err := models.DeleteRefreshTokenByToken(refreshToken.Token); err != nil { - logging.AppLog.Error("Failed to delete old refresh token", "error", err, "ip", ip) - // Continue, but log the error - } - - // 6. Issue a new refresh token - newRefreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) - if err != nil { - logging.AppLog.Error("Failed to create new refresh token", "error", err, "username", user.Username, "ip", ip) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - // --- End Token Rotation --- - - // 7. Respond with new tokens - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]string{ - "access_token": newAccessToken, - "refresh_token": newRefreshToken.Token, - }); err != nil { - logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip) - } -} - -// TokenHandler handles issuing JWT tokens for API clients. -func TokenHandler(w http.ResponseWriter, r *http.Request) { - ip := logging.GetClientIP(r) - - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var creds struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - // Use the shared authentication logic - user, err := authenticateUser(creds.Username, creds.Password, ip) - if err != nil { - // Return a generic error to the API client - http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) - return - } - - // --- Issue Tokens --- - // Access Token - accessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) - if err != nil { - logging.AppLog.Error("Failed to generate access token", "error", err, "username", user.Username, "ip", ip) - http.Error(w, "Failed to generate access token", http.StatusInternalServerError) - return - } - - // Refresh Token - refreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) - if err != nil { - logging.AppLog.Error("Failed to create refresh token", "error", err, "username", user.Username, "ip", ip) - http.Error(w, "Failed to create refresh token", http.StatusInternalServerError) - return - } - // --- End Issue Tokens --- - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]string{ - "access_token": accessToken, - "refresh_token": refreshToken.Token, - }); err != nil { - logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip) - } -} diff --git a/handlers/auth_login.go b/handlers/auth_login.go new file mode 100644 index 0000000..855fa8a --- /dev/null +++ b/handlers/auth_login.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "auth-proxy/config" + "auth-proxy/logging" + "auth-proxy/models" + "auth-proxy/types" + "errors" + "html/template" + "net/http" + "time" + + "github.com/gorilla/csrf" +) + +var templates = template.Must(template.ParseGlob("templates/*.html")) + +// authenticateUser handles the core logic for authenticating a user, including brute-force protection. +// It returns the authenticated user or an error if authentication fails. +func authenticateUser(username, password, ip string) (*types.User, error) { + user, err := models.GetUserByUsername(username) + if err != nil { + logging.AppLog.Error("Database error during authentication", "error", err, "username", username, "ip", ip) + return nil, errors.New("internal server error") + } + + if user == nil { + logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "user not found") + return nil, errors.New("invalid username or password") + } + + // Step 1: Check if the account is locked. + if !user.IsActive { + lockoutDuration := time.Duration(config.Paths.LockoutDurationMinutes) * time.Minute + + // Check if the lockout period has expired. + if time.Since(user.UpdatedAt) < lockoutDuration { + logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "account locked") + return nil, errors.New("account is temporarily locked") + } else { + // Lockout has expired. Reactivate the account but do not log the user in on this attempt. + if err := models.SetUserActiveStatus(user.ID, true); err != nil { + logging.AppLog.Error("Failed to reactivate user", "error", err, "user_id", user.ID, "ip", ip) + return nil, errors.New("internal server error") + } + if err := models.RecordLoginSuccess(user.ID); err != nil { + logging.AppLog.Error("Failed to reset failed login attempts on reactivation", "error", err, "user_id", user.ID, "ip", ip) + } + logging.SecurityLog.Info("ACCOUNT UNLOCKED", "username", username, "ip", ip) + return nil, errors.New("account has been unlocked, please try again") + } + } + + // Step 2: If the account is active, validate credentials. + if !models.CheckPasswordHash(password, user.PasswordHash) { + // Record the failure + if err := models.RecordLoginFailure(user.ID); err != nil { + logging.AppLog.Error("Failed to record login failure", "error", err, "user_id", user.ID, "ip", ip) + } + + // Re-fetch user to get updated failed_logins count + updatedUser, _ := models.GetUserByID(user.ID) + maxAttempts := config.Paths.MaxLoginAttempts + + // Lock the account if max attempts are exceeded + if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts { + if err := models.SetUserActiveStatus(user.ID, false); err != nil { + logging.AppLog.Error("Failed to lock account", "error", err, "user_id", user.ID, "ip", ip) + } + logging.SecurityLog.Warn("ACCOUNT LOCKED", "username", username, "ip", ip) + } + + logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "invalid credentials") + return nil, errors.New("invalid username or password") + } + + // Step 3: Login successful. + if err := models.RecordLoginSuccess(user.ID); err != nil { + logging.AppLog.Error("Failed to record login success", "error", err, "user_id", user.ID, "ip", ip) + } + logging.SecurityLog.Info("AUTH SUCCESS", "username", user.Username, "ip", ip) + + return user, nil +} + +func renderLoginWithError(w http.ResponseWriter, r *http.Request, message string) { + data := map[string]interface{}{ + "Paths": config.Paths, + "Error": message, + "RegisterEnabled": config.Paths.RegisterEnabled, + "CSRFToken": csrf.Token(r), + } + w.WriteHeader(http.StatusUnauthorized) // Set status to indicate failure + if err := templates.ExecuteTemplate(w, "login.html", data); err != nil { + logging.AppLog.Error("Failed to execute login template with error", "error", err) + } +} + +// LoginHandler handles displaying the login form and processing login requests. +func LoginHandler(w http.ResponseWriter, r *http.Request) { + ip := logging.GetClientIP(r) + + if r.Method == http.MethodGet { + data := map[string]interface{}{ + "Paths": config.Paths, + "RegisterEnabled": config.Paths.RegisterEnabled, + "CSRFToken": csrf.Token(r), + } + if err := templates.ExecuteTemplate(w, "login.html", data); err != nil { + logging.AppLog.Error("Failed to execute login template", "error", err, "ip", ip) + } + return + } + + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + logging.AppLog.Error("Failed to parse login form", "error", err, "ip", ip) + renderLoginWithError(w, r, "An internal error occurred. Please try again.") + return + } + username := r.FormValue("username") + password := r.FormValue("password") + + user, err := authenticateUser(username, password, ip) + if err != nil { + // Map authentication error to user-facing message + errMsg := "Invalid username or password." + if err.Error() == "account is temporarily locked" { + errMsg = "Account is temporarily locked." + } else if err.Error() == "internal server error" { + errMsg = "An internal error occurred. Please try again." + } else if err.Error() == "account has been unlocked, please try again" { + errMsg = "Account has been unlocked. Please try logging in again." + } + renderLoginWithError(w, r, errMsg) + return + } + + // --- Create Session Cookie --- + duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour + + token, err := models.CreateAuthToken(user.ID, duration) + if err != nil { + logging.AppLog.Error("Failed to create auth token", "error", err, "username", user.Username, "ip", ip) + renderLoginWithError(w, r, "Could not create session.") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: token.Token, + Expires: token.ExpiresAt, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, // Use Secure flag if on HTTPS + SameSite: http.SameSiteLaxMode, + }) + + // Redirect to the root path, which is the proxied application. + http.Redirect(w, r, "/", http.StatusFound) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) +} diff --git a/handlers/auth_logout.go b/handlers/auth_logout.go new file mode 100644 index 0000000..d008a81 --- /dev/null +++ b/handlers/auth_logout.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "auth-proxy/config" + "auth-proxy/logging" + "auth-proxy/models" + "net/http" + "time" +) + +// LogoutHandler invalidates the user's token by expiring the cookie. +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + ip := logging.GetClientIP(r) + cookie, err := r.Cookie("auth_token") + if err != nil { + // No cookie, nothing to do. + http.Redirect(w, r, config.Paths.Login, http.StatusFound) + return + } + + err = models.DeleteAuthToken(cookie.Value) + if err != nil { + // Log the error but still proceed with logging the user out client-side. + logging.AppLog.Error("Failed to delete token on logout", "error", err, "ip", ip) + } + + // Expire the cookie by setting its max age to a negative number. + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + logging.SecurityLog.Info("LOGOUT SUCCESS", "ip", ip) + http.Redirect(w, r, config.Paths.Login, http.StatusFound) +} diff --git a/handlers/auth_register.go b/handlers/auth_register.go new file mode 100644 index 0000000..9b93494 --- /dev/null +++ b/handlers/auth_register.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "auth-proxy/config" + "auth-proxy/logging" + "auth-proxy/models" + "auth-proxy/utils" + "net/http" + + "github.com/gorilla/csrf" +) + +func renderRegisterWithError(w http.ResponseWriter, r *http.Request, message string) { + data := map[string]interface{}{ + "Paths": config.Paths, + "Error": message, + "CSRFToken": csrf.Token(r), + } + w.WriteHeader(http.StatusBadRequest) // Set status to indicate failure + if err := templates.ExecuteTemplate(w, "register.html", data); err != nil { + logging.AppLog.Error("Failed to execute register template with error", "error", err) + } +} + +// RegisterHandler handles displaying and processing the user registration form. +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + if !config.Paths.RegisterEnabled { + http.Error(w, "User registration is disabled.", http.StatusForbidden) + return + } + ip := logging.GetClientIP(r) + data := map[string]interface{}{ + "Paths": config.Paths, + "CSRFToken": csrf.Token(r), + } + + if r.Method == http.MethodGet { + if err := templates.ExecuteTemplate(w, "register.html", data); err != nil { + logging.AppLog.Error("Failed to execute register template", "error", err, "ip", ip) + } + return + } + + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + logging.AppLog.Error("Failed to parse registration form", "error", err, "ip", ip) + renderRegisterWithError(w, r, "An internal error occurred. Please try again.") + return + } + username := r.FormValue("username") + password := r.FormValue("password") + confirmPassword := r.FormValue("confirm_password") + + // Validate password policy + if ok, message := utils.ValidatePassword(password); !ok { + renderRegisterWithError(w, r, message) + return + } + + if password != confirmPassword { + logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip) + renderRegisterWithError(w, r, "Passwords do not match") + return + } + + existingUser, err := models.GetUserByUsername(username) + if err != nil { + logging.AppLog.Error("Error checking username during registration", "error", err, "ip", ip) + renderRegisterWithError(w, r, "An internal error occurred while checking the username.") + return + } + if existingUser != nil { + logging.AppLog.Warn("User registration failed", "reason", "username is already taken", "username", username, "ip", ip) + renderRegisterWithError(w, r, "Username is already taken") + return + } + + user, err := models.CreateUser(username, password, "user") + if err != nil { + logging.AppLog.Error("Failed to create user", "error", err, "username", username, "ip", ip) + renderRegisterWithError(w, r, "Failed to create user due to an internal error.") + return + } + logging.SecurityLog.Info("USER CREATED", "username", user.Username, "ip", ip) + + // Redirect to the login page so the user can sign in. + http.Redirect(w, r, config.Paths.Login, http.StatusFound) + return + } + + w.WriteHeader(http.StatusMethodNotAllowed) +} diff --git a/handlers/auth_token.go b/handlers/auth_token.go new file mode 100644 index 0000000..7ea7ef6 --- /dev/null +++ b/handlers/auth_token.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "auth-proxy/config" + "auth-proxy/logging" + "auth-proxy/models" + "auth-proxy/utils" + "encoding/json" + "net/http" + "time" +) + +// RefreshTokenHandler handles exchanging a valid refresh token for a new access token. +func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { + ip := logging.GetClientIP(r) + + // 1. Parse request body + var reqBody struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if reqBody.RefreshToken == "" { + http.Error(w, "Missing refresh_token", http.StatusBadRequest) + return + } + + // 2. Validate the refresh token in DB + refreshToken, err := models.GetRefreshTokenByToken(reqBody.RefreshToken) + if err != nil { + logging.AppLog.Error("Error getting refresh token from DB", "error", err, "ip", ip) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if refreshToken == nil || refreshToken.IsRevoked || time.Now().After(refreshToken.ExpiresAt) { + logging.SecurityLog.Warn("REFRESH TOKEN FAIL", "reason", "token not found, revoked, or expired", "ip", ip) + http.Error(w, "Unauthorized: Invalid refresh token", http.StatusUnauthorized) + return + } + + // 3. Get user info + user, err := models.GetUserByID(refreshToken.UserID) + if err != nil || user == nil { + logging.AppLog.Error("Failed to get user from refresh token", "error", err, "user_id", refreshToken.UserID, "ip", ip) + http.Error(w, "Unauthorized: User not found", http.StatusUnauthorized) + return + } + + // 4. Issue a new access token + newAccessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) + if err != nil { + logging.AppLog.Error("Failed to generate new access token", "error", err, "username", user.Username, "ip", ip) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // --- Optional: Token Rotation --- + // 5. Delete the old refresh token + if err := models.DeleteRefreshTokenByToken(refreshToken.Token); err != nil { + logging.AppLog.Error("Failed to delete old refresh token", "error", err, "ip", ip) + // Continue, but log the error + } + + // 6. Issue a new refresh token + newRefreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) + if err != nil { + logging.AppLog.Error("Failed to create new refresh token", "error", err, "username", user.Username, "ip", ip) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + // --- End Token Rotation --- + + // 7. Respond with new tokens + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]string{ + "access_token": newAccessToken, + "refresh_token": newRefreshToken.Token, + }); err != nil { + logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip) + } +} + +// TokenHandler handles issuing JWT tokens for API clients. +func TokenHandler(w http.ResponseWriter, r *http.Request) { + ip := logging.GetClientIP(r) + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Use the shared authentication logic + user, err := authenticateUser(creds.Username, creds.Password, ip) + if err != nil { + // Return a generic error to the API client + http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized) + return + } + + // --- Issue Tokens --- + // Access Token + accessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) + if err != nil { + logging.AppLog.Error("Failed to generate access token", "error", err, "username", user.Username, "ip", ip) + http.Error(w, "Failed to generate access token", http.StatusInternalServerError) + return + } + + // Refresh Token + refreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) + if err != nil { + logging.AppLog.Error("Failed to create refresh token", "error", err, "username", user.Username, "ip", ip) + http.Error(w, "Failed to create refresh token", http.StatusInternalServerError) + return + } + // --- End Issue Tokens --- + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]string{ + "access_token": accessToken, + "refresh_token": refreshToken.Token, + }); err != nil { + logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip) + } +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..78b422d --- /dev/null +++ b/router/router.go @@ -0,0 +1,228 @@ +package router + +import ( + "auth-proxy/config" + "auth-proxy/handlers" + "auth-proxy/logging" + "auth-proxy/middleware" + "auth-proxy/models" + "auth-proxy/types" + "auth-proxy/worker" + "net/http" + "os" + "strings" + "time" + + "github.com/gorilla/csrf" +) + +// NewRouter creates and configures the main application router. +func NewRouter() http.Handler { + targetURL := os.Getenv("TARGET_URL") + + // --- Create routers for Web and API --- + webMux := http.NewServeMux() + apiMux := http.NewServeMux() + + // --- Static file serving (Web) --- + assetsPath := strings.TrimRight(config.Paths.Assets, "/") + "/" + assetsFS := http.FileServer(http.Dir("templates")) + webMux.Handle(assetsPath, http.StripPrefix(assetsPath, assetsFS)) + + // --- Rate Limiter --- + rateLimiter := middleware.NewRateLimiter() + + // Helper to switch handler based on HTTP method + methodSwitch := func(get, post http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + post.ServeHTTP(w, r) + } else { + get.ServeHTTP(w, r) + } + }) + } + + // --- Web UI Routes (CSRF-protected) --- + if os.Getenv("FRONTEND_TYPE") == "php" { + // PHP frontend routes + loginPageHandler := handlers.NewPhpProxyHandler("login.php") + registerPageHandler := handlers.NewPhpProxyHandler("register.php") + accountPageHandler := handlers.NewPhpProxyHandler("account.php") + accountPasswordPageHandler := handlers.NewPhpProxyHandler("account_password.php") + + loginHandler := methodSwitch(loginPageHandler, http.HandlerFunc(handlers.LoginHandler)) + webMux.Handle(config.Paths.Login, loginHandler) + + if config.Paths.RegisterEnabled { + registerPostHandler := middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler)) + registerHandler := methodSwitch(registerPageHandler, registerPostHandler) + webMux.Handle(config.Paths.Register, registerHandler) + } + + accountPasswordPostHandler := http.HandlerFunc(handlers.ChangePasswordHandler) + accountPasswordHandler := methodSwitch(accountPasswordPageHandler, accountPasswordPostHandler) + webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(accountPasswordHandler)) + webMux.Handle(config.Paths.Account, middleware.SessionAuth(accountPageHandler)) + webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler)))) + } else { + // Go template frontend routes + webMux.HandleFunc(config.Paths.Login, handlers.LoginHandler) + if config.Paths.RegisterEnabled { + webMux.Handle(config.Paths.Register, middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler))) + } + webMux.Handle(config.Paths.Account, middleware.SessionAuth(http.HandlerFunc(handlers.AccountPageHandler))) + webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler)))) + webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(http.HandlerFunc(handlers.ChangePasswordHandler))) + } + webMux.HandleFunc(config.Paths.Logout, handlers.LogoutHandler) + + // --- API Routes (No CSRF) --- + apiMux.HandleFunc("/api/auth/token", handlers.TokenHandler) + apiMux.HandleFunc("/auth/refresh", handlers.RefreshTokenHandler) + + // Admin API endpoints + adminCreateUserAPI := http.HandlerFunc(handlers.CreateUserHandler) + getUsersHandler := http.HandlerFunc(handlers.GetUsersHandler) + adminUsersAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + adminCreateUserAPI.ServeHTTP(w, r) + } else { + getUsersHandler.ServeHTTP(w, r) + } + }) + adminUpdateRoleAPI := http.HandlerFunc(handlers.UpdateUserRoleHandler) + adminDeleteUserAPI := http.HandlerFunc(handlers.DeleteUserHandler) + adminSetStatusAPI := http.HandlerFunc(handlers.SetUserActiveStatusHandler) + + apiMux.Handle(config.Paths.AdminUsersAPI, middleware.BearerAuth(middleware.AdminMiddleware(adminUsersAPI))) + apiMux.Handle(config.Paths.AdminUsersAPI+"/", middleware.BearerAuth(middleware.AdminMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/role") && r.Method == http.MethodPost: + adminUpdateRoleAPI.ServeHTTP(w, r) + case strings.HasSuffix(path, "/status") && r.Method == http.MethodPost: + adminSetStatusAPI.ServeHTTP(w, r) + case r.Method == http.MethodDelete: + adminDeleteUserAPI.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + })))) + + // --- Main Application Proxy --- + proxyHandler := handlers.NewProxy(targetURL) + + // --- CSRF Protection (for Web UI) --- + csrfSecret := os.Getenv("CSRF_SECRET_KEY") + csrfOptions := []csrf.Option{ + csrf.Secure(os.Getenv("ENV") == "production"), + csrf.Path("/"), + } + sameSiteMode := csrf.SameSiteLaxMode + switch strings.ToLower(os.Getenv("CSRF_SAME_SITE")) { + case "strict": + sameSiteMode = csrf.SameSiteStrictMode + case "none": + sameSiteMode = csrf.SameSiteNoneMode + } + csrfOptions = append(csrfOptions, csrf.SameSite(sameSiteMode)) + trustedOrigins := os.Getenv("CSRF_TRUSTED_ORIGINS") + if trustedOrigins != "" { + origins := strings.Split(trustedOrigins, ",") + csrfOptions = append(csrfOptions, csrf.TrustedOrigins(origins)) + } + csrfMiddleware := csrf.Protect([]byte(csrfSecret), csrfOptions...) + csrfProtectedWebMux := middleware.HandleOptions(csrfMiddleware(webMux)) + + // --- Final Handler --- + finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle API paths + if strings.HasPrefix(r.URL.Path, config.Paths.APIPath) { + // Check if a specific handler exists in the API mux + _, pattern := apiMux.Handler(r) + + // If a specific pattern is found, let the API mux handle it internally. + if pattern != "" { + apiMux.ServeHTTP(w, r) + return + } + + // If no specific handler is found, it's a request to be proxied. + // Protect it with Bearer/Cookie auth if PROTECT_API is enabled. + if config.Paths.ProtectAPI { + middleware.BearerAuth(proxyHandler).ServeHTTP(w, r) + } else { + proxyHandler.ServeHTTP(w, r) + } + return + } + + // Check if the path is a web UI path and apply CSRF protection + _, webPattern := webMux.Handler(r) + if webPattern != "" { + csrfProtectedWebMux.ServeHTTP(w, r) + return + } + + // Fallback to proxy for all other paths + if config.Paths.ProtectFrontend { + middleware.SessionAuth(proxyHandler).ServeHTTP(w, r) + } else { + proxyHandler.ServeHTTP(w, r) + } + }) + + return finalHandler +} + +// StartBackgroundTasks launches periodic jobs for cleanup. +func StartBackgroundTasks(pool *types.WorkerPool, limiter *types.RateLimiter) { + // Ticker for expired token cleanup (every hour) + tokenTicker := time.NewTicker(1 * time.Hour) + defer tokenTicker.Stop() + + // Ticker for rate limiter cleanup (every 24 hours) + rateLimiterTicker := time.NewTicker(24 * time.Hour) + defer rateLimiterTicker.Stop() + + logging.AppLog.Info("Background task scheduler started") + + // Run initial cleanup tasks at startup + submitTokenCleanup(pool) + submitRateLimiterCleanup(pool, limiter) + + // Run tasks on their respective tickers + for { + select { + case <-tokenTicker.C: + submitTokenCleanup(pool) + case <-rateLimiterTicker.C: + submitRateLimiterCleanup(pool, limiter) + } + } +} + +// submitTokenCleanup submits the expired token deletion task to the worker pool. +func submitTokenCleanup(pool *types.WorkerPool) { + worker.Submit(pool, func() { + logging.AppLog.Info("Running background task: deleting expired tokens") + rowsAffected, err := models.DeleteExpiredTokens() + if err != nil { + logging.AppLog.Error("Failed to delete expired tokens", "error", err) + } else { + if rowsAffected > 0 { + logging.AppLog.Info("Finished deleting expired tokens", "deleted_count", rowsAffected) + } + } + }) +} + +// submitRateLimiterCleanup submits the rate limiter cleanup task to the worker pool. +func submitRateLimiterCleanup(pool *types.WorkerPool, limiter *types.RateLimiter) { + worker.Submit(pool, func() { + logging.AppLog.Info("Running background task: cleaning up rate limiter") + middleware.Cleanup(limiter) + }) +} + |
